diff --git a/.gitignore b/.gitignore index c5b83a3..be0fcd2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ __pycache__/ /.vscode/ /apps/hd_generated.cu8 +CMakeLists.txt.bak* +.claude/ +.history/ +/examples/* +!/examples/README diff --git a/CMakeLists.txt b/CMakeLists.txt index ff4b884..d598090 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,15 +158,16 @@ else() include (ExternalProject) set (FDK_AAC_PREFIX "${CMAKE_BINARY_DIR}/fdk-aac-prefix") + ExternalProject_Add ( fdk_aac_external - GIT_REPOSITORY "https://github.com/argilo/fdk-aac.git" - GIT_TAG 3b63dab59416a629f3de82463eb3875319a086d5 + GIT_REPOSITORY "https://github.com/secludedhusky/fdk-aac.git" + GIT_TAG edits-parametric PREFIX ${FDK_AAC_PREFIX} BINARY_DIR ${FDK_AAC_PREFIX}/src/fdk_aac_external UPDATE_COMMAND "" - CONFIGURE_COMMAND ./autogen.sh && ./configure --prefix=${FDK_AAC_PREFIX} --disable-shared --enable-static CXXFLAGS=-fPIC + CONFIGURE_COMMAND chmod +x autogen.sh && ./autogen.sh && ./configure --prefix=${FDK_AAC_PREFIX} --disable-shared --enable-static CXXFLAGS=-fPIC BUILD_COMMAND make ) diff --git a/README.md b/README.md index 5de69e5..da48377 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,45 @@ This block encodes audio into High-Definition Coding (HDC) frames. The input sam ### PSD encoder -This block encodes Program Service Data PDUs, as described in https://www.nrscstandards.org/standards-and-guidelines/documents/standards/nrsc-5-d/reference-docs/1028s.pdf. PSD conveys information (e.g. track title & artist) about the audio that is currently playing. +This block encodes Program Service Data PDUs, as described in https://www.nrscstandards.org/standards-and-guidelines/documents/standards/nrsc-5-d/reference-docs/1028s.pdf. PSD conveys information (e.g. track title & artist) about the audio that is currently playing. The encoder supports all ID3 frames defined in NRSC-5 Table 5-1. To control latency, connect the "clock" output of the Layer 1 encoder to the "clock" input of the PSD encoder, and set "Bytes/frame limit" to 128 (if the L2 frame size is 24000 or larger) or 64 (if the L2 frame size is smaller than 24000). -To dynamically update title, artist, and XHDR data, connect a Socket PDU (TCP Server) block to the "set_meta" input, and send any of the following commands via TCP, followed by a carriage return: +To dynamically update metadata, connect a Socket PDU (TCP Server) block to the "set_meta" input, and send any of the following commands via TCP, followed by a carriage return: -* `titleExample Title` — set title to `Example Title` -* `artistExample Artist` — set artist to `Example Artist` +**Basic Metadata (PSD Types 1-4):** +* `titleExample Title` — set title to `Example Title` (TIT2) +* `artistExample Artist` — set artist to `Example Artist` (TPE1) +* `albumExample Album` — set album to `Example Album` (TALB) +* `genreRock` — set genre to `Rock` (TCON) + +**Comment (PSD Type 5 - COMM):** +* `commentRecorded live at Madison Square Garden` — set comment text +* `comment_shortInfo` — set comment short description +* `comment_languageeng` — set comment language (3-byte ISO 639-2 code, default: eng) + +**Commercial Frame (PSD Type 6 - COMR):** +* `commercial_price$9.99` — set price string +* `commercial_valid20261231` — set expiration date (YYYYMMDD format) +* `commercial_urlhttps://example.com/buy` — set contact URL for purchase +* `commercial_received0` — set received-as byte (how merchandise is received) +* `commercial_sellerExample Store` — set seller name +* `commercial_descBuy now and save 20%!` — set advertisement description + +**Reference Identifier (PSD Type 7 - UFID):** +* `ufid_ownerhttp://musicbrainz.org` — set owner identifier URL +* `ufid_idabc123def456` — set unique identifier (up to 64 bytes) + +**Album Art (XHDR):** * `lot1337` — display album art contained in LOT file 1337 * `lot-1` — display station logo +**Notes:** +* All metadata fields are optional except Title and Artist (per NRSC-5 requirements) +* Maximum PSD message size is 1,018 bytes (excluding 6-byte overhead) +* Title, Artist, Album, and Genre frames should be limited to less than 128 characters +* Binary pictures are not supported in PSD; use LOT protocol for album art transmission + ### SIS & SIG encoder This block encodes Station Information Service PDUs, as described in https://www.nrscstandards.org/standards-and-guidelines/documents/standards/nrsc-5-d/reference-docs/1020s.pdf, and assembles them into the PIDS and SIDS logical channels. SIS provides information about the station. @@ -89,23 +117,57 @@ The "clock" output of the Layer 1 encoder must be connected to the "clock" input This block sends files to the receiver (for instance, containing album art or a station logo) by encoding them as Advanced Application Services (AAS) PDUs, according to the Large Object Transfer (LOT) protocol. The "aas" output must be connected to the Layer 2 encoder's "aas" input, and the "ready" output of the Layer 2 encoder must be connected to the "ready" input of the LOT encoder to tell it when it should produce output. +#### File Expiry + +Files transmitted via LOT include an expiry date/time. The expiry can be set in the GRC block's "Expiry" parameter (default: "45 minutes") or specified per-file via TCP commands. The expiry parameter supports two formats: + +**Relative Time Format** (recommended): +* `15 milliseconds`, `15ms`, `15mil` +* `120 seconds`, `120s`, `120sec` +* `15 minutes`, `15m`, `15min` +* `2 hours`, `2h`, `2hr` +* `7 days`, `7d` +* `2 weeks`, `2w`, `2wk` +* `6 months`, `6mn`, `6month` +* `1 year`, `1y`, `1yr` + +**ISO 8601 / TZ Format:** +* `2026-12-31T23:59:59+00:00` (with timezone offset) +* `2026-12-31T23:59:59Z` (UTC with 'Z' suffix) +* `2026-12-31 23:59:59+00:00` (space separator also supported) + +#### TCP Commands + To allow new files to be sent at runtime, connect a Socket PDU (TCP Server) block to the "file" input. To read a new file from disk, send the following command, followed by a carriage return: ``` -file|| +file||| +``` + +Example: +``` +file|1337|album_art.png|15 minutes ``` To stream in a file over the network connection, send the following command, followed by a carriage return: ``` -streamfile||| +streamfile|||| +``` + +Example: +``` +streamfile|1337|4096|album_art.png|2 hours ``` Then send the file itself over the same network connection. The `apps/send_album_art.py` script demonstrates how to stream an album art file and request for it to be displayed by the receiver. -Note: Station logo and album art files must use PNG or JPEG format, and be 200x200 pixels in size. +**Notes:** +* The expiry parameter is optional in TCP commands; if omitted, the default expiry from the GRC block is used +* Station logo and album art files must use PNG or JPEG format, and be 200x200 pixels in size +* Expiry dates are encoded in the LOT header and transmitted to the receiver ### Layer 2 encoder diff --git a/apps/hd_tx_hackrf.py b/apps/hd_tx_hackrf.py old mode 100755 new mode 100644 index c539bb7..82e445c --- a/apps/hd_tx_hackrf.py +++ b/apps/hd_tx_hackrf.py @@ -81,11 +81,45 @@ def __init__(self): self.osmosdr_sink_0.set_bb_gain(20, 0) self.osmosdr_sink_0.set_antenna('', 0) self.osmosdr_sink_0.set_bandwidth(0, 0) - self.nrsc5_sis_encoder_0 = nrsc5.sis_encoder(mode=nrsc5.pids_mode.FM, short_name='ABCD-FM', slogan='This is ABCD-FM', message='Generated by GNU Radio', program_names=['HD1'], program_types=[nrsc5.program_type.NEWS], data_types=[], data_mime_types=[], latitude=40.6892, longitude=(-74.0445), altitude=93.0, country_code='US', fcc_facility_id=0) + # Data channels: port 0x1010 (LOT ID 1010, Text) and port 0x1011 (LOT ID 1011, Text) + # These are advertised in SIG so decoders reassemble LOT on these ports + self.nrsc5_sis_encoder_0 = nrsc5.sis_encoder( + mode=nrsc5.pids_mode.FM, + short_name='ABCD-FM', + slogan='This is ABCD-FM', + message='Generated by GNU Radio', + program_names=['HD1'], + program_types=[nrsc5.program_type.NEWS], + data_types=[], + data_mime_types=[], + latitude=40.6892, + longitude=(-74.0445), + altitude=93.0, + country_code='US', + fcc_facility_id=0, + data_channels=[ + nrsc5.data_channel_config(0x1010, 1010, int(nrsc5.service_data_type.TEXT), int(nrsc5.mime_hash.TEXT)), + nrsc5.data_channel_config(0x1011, 1011, int(nrsc5.service_data_type.TEXT), int(nrsc5.mime_hash.TEXT)), + ], + exciter_manufacturer_id='CS', + importer_manufacturer_id='CS', + exciter_core_version=[1, 0, 0, 0], + exciter_mfr_version=[1, 0, 0, 0], + importer_core_version=[1, 0, 0, 0], + importer_mfr_version=[1, 0, 0, 0], + importer_configuration_number=0) self.nrsc5_psd_encoder_0 = nrsc5.psd_encoder(0, 'Title', 'Artist', 128) self.nrsc5_lot_encoder_0_0 = nrsc5.lot_encoder('album_art.jpg', 1337, 0x1000) self.nrsc5_lot_encoder_0 = nrsc5.lot_encoder('SLABCD$$010000.png', 42, 0x1001) +<<<<<<< Updated upstream self.nrsc5_l2_encoder_0 = nrsc5.l2_encoder(1, 0, 146176, 2000, nrsc5.blend.ENABLE, ccc_width=24) +======= + # Data-only LOT encoders on ports 0x1010 and 0x1011 + # These match the data_channels configured above in the SIS encoder + self.nrsc5_lot_encoder_dc0 = nrsc5.lot_encoder('STRWRI_Richmond_20260203_034254.txt', 1010, 0x1010) + self.nrsc5_lot_encoder_dc1 = nrsc5.lot_encoder('STRWRI_Richmond_20260203_034254.txt', 1011, 0x1011) + self.nrsc5_l2_encoder_0 = nrsc5.l2_encoder(1, 0, 146176, 2000, nrsc5.blend.ENABLE) +>>>>>>> Stashed changes self.nrsc5_l1_fm_encoder_mp1_0 = nrsc5.l1_fm_encoder(1) self.nrsc5_hdc_encoder_0 = nrsc5.hdc_encoder(2, 64000) self.network_socket_pdu_1 = network.socket_pdu('TCP_SERVER', '', '52002', 10000, False) @@ -132,9 +166,13 @@ def __init__(self): self.msg_connect((self.nrsc5_l1_fm_encoder_mp1_0, 'clock'), (self.nrsc5_sis_encoder_0, 'clock')) self.msg_connect((self.nrsc5_l2_encoder_0, 'ready'), (self.nrsc5_lot_encoder_0, 'ready')) self.msg_connect((self.nrsc5_l2_encoder_0, 'ready'), (self.nrsc5_lot_encoder_0_0, 'ready')) + self.msg_connect((self.nrsc5_l2_encoder_0, 'ready'), (self.nrsc5_lot_encoder_dc0, 'ready')) + self.msg_connect((self.nrsc5_l2_encoder_0, 'ready'), (self.nrsc5_lot_encoder_dc1, 'ready')) self.msg_connect((self.nrsc5_l2_encoder_0, 'ready'), (self.nrsc5_sis_encoder_0, 'ready')) self.msg_connect((self.nrsc5_lot_encoder_0, 'aas'), (self.nrsc5_l2_encoder_0, 'aas')) self.msg_connect((self.nrsc5_lot_encoder_0_0, 'aas'), (self.nrsc5_l2_encoder_0, 'aas')) + self.msg_connect((self.nrsc5_lot_encoder_dc0, 'aas'), (self.nrsc5_l2_encoder_0, 'aas')) + self.msg_connect((self.nrsc5_lot_encoder_dc1, 'aas'), (self.nrsc5_l2_encoder_0, 'aas')) self.msg_connect((self.nrsc5_sis_encoder_0, 'aas'), (self.nrsc5_l2_encoder_0, 'aas')) self.connect((self.analog_wfm_tx_0, 0), (self.rational_resampler_xxx_0_0, 0)) self.connect((self.blocks_add_xx_0, 0), (self.blocks_multiply_const_vxx_1, 0)) diff --git a/grc/nrsc5_hdc_encoder.block.yml b/grc/nrsc5_hdc_encoder.block.yml index 520dcbe..79b4b39 100644 --- a/grc/nrsc5_hdc_encoder.block.yml +++ b/grc/nrsc5_hdc_encoder.block.yml @@ -13,6 +13,17 @@ parameters: label: Bitrate dtype: int default: 64000 +- id: use_parametric_stereo + label: Stereo Mode + dtype: enum + default: 'False' + options: ['False', 'True'] + option_labels: ['Regular Stereo (AOT 127)', 'Parametric Stereo (AOT 128)'] + hide: ${ 'all' if channels == 1 else 'none' } +- id: tx_digital_gain + label: TX Digital Audio Gain (dB) + dtype: int + default: 0 inputs: - domain: stream @@ -25,9 +36,10 @@ outputs: asserts: - ${ 1 <= channels <= 2 } - ${ 0 < bitrate } +- ${ -8 <= tx_digital_gain <= 6 } templates: imports: import nrsc5 - make: nrsc5.hdc_encoder(${channels}, ${bitrate}) + make: nrsc5.hdc_encoder(${channels}, ${bitrate}, ${use_parametric_stereo}, ${tx_digital_gain}) file_format: 1 diff --git a/grc/nrsc5_l2_encoder.block.yml b/grc/nrsc5_l2_encoder.block.yml index 1ca283f..a6edfe5 100644 --- a/grc/nrsc5_l2_encoder.block.yml +++ b/grc/nrsc5_l2_encoder.block.yml @@ -36,6 +36,14 @@ parameters: option_labels: ["Disable", "Select", "Enable"] default: nrsc5.blend.ENABLE hide: ${ ('none' if num_progs > 0 and first_prog == 0 else 'all') } +- id: tx_digital_gain + label: TX Digital Audio Gain (dB) + dtype: int + default: 0 +- id: debug_logs + label: Debug Logs + dtype: bool + default: 'False' inputs: - label: hdc @@ -63,9 +71,10 @@ asserts: - ${ 0 <= first_prog <= 7 } - ${ 0 <= num_progs <= 8 - first_prog } - ${ data_bytes <= (size - 22) // 8 - 1 - ccc_width } +- ${ -8 <= tx_digital_gain <= 6 } templates: imports: import nrsc5 - make: nrsc5.l2_encoder(${num_progs}, ${first_prog}, ${size}, ${data_bytes}, ${blend_control}, ccc_width=${ccc_width}) + make: nrsc5.l2_encoder(${num_progs}, ${first_prog}, ${size}, ${data_bytes}, ${blend_control}, ${tx_digital_gain}, ${debug_logs}, ccc_width=${ccc_width}) file_format: 1 diff --git a/grc/nrsc5_lot_encoder.block.yml b/grc/nrsc5_lot_encoder.block.yml index 00f287e..560ae7a 100644 --- a/grc/nrsc5_lot_encoder.block.yml +++ b/grc/nrsc5_lot_encoder.block.yml @@ -4,7 +4,7 @@ category: '[NRSC-5]' templates: imports: import nrsc5 - make: nrsc5.lot_encoder(${filename}, ${lot_id}, ${port}) + make: nrsc5.lot_encoder(${filename}, ${lot_id}, ${port}, ${expiry}) parameters: - id: filename @@ -19,6 +19,10 @@ parameters: label: Port dtype: int default: '0x1001' +- id: expiry + label: Expiry + dtype: string + default: '45 minutes' inputs: - label: file @@ -33,4 +37,22 @@ outputs: domain: message optional: false +documentation: |- + Large Object Transfer (LOT) encoder. Transmits files as AAS PDUs. + + Supported file types (auto-detected by magic bytes or extension): + - PNG (.png) + - JPEG (.jpg, .jpeg) + - GIF (.gif) -- both GIF87a and GIF89a + - Text (.txt) + + For data-only channels (ports not tied to an audio subchannel's album art + or station logo), you must configure a matching data_channel entry in the + SIS & SIG encoder block with the same port and LOT ID. Without that SIG + advertisement the decoder will not know to reassemble the LOT object. + + Expiry supports relative time strings (e.g. "45 minutes", "2 hours", + "1 week") or ISO 8601 datetime with timezone (e.g. + "2026-12-31T23:59:59+00:00"). + file_format: 1 diff --git a/grc/nrsc5_psd_encoder.block.yml b/grc/nrsc5_psd_encoder.block.yml index 699ca2e..21ec3b3 100644 --- a/grc/nrsc5_psd_encoder.block.yml +++ b/grc/nrsc5_psd_encoder.block.yml @@ -12,11 +12,31 @@ parameters: - id: title label: Title dtype: string - default: Title + default: '' - id: artist label: Artist dtype: string - default: Artist + default: '' +- id: album + label: Album + dtype: string + default: '' +- id: genre + label: Genre + dtype: string + default: '' +- id: comment_language + label: Comment Language + dtype: string + default: 'eng' +- id: comment_short_desc + label: Comment Short Description + dtype: string + default: '' +- id: comment_text + label: Comment Text + dtype: string + default: '' - id: bytes_per_frame label: Bytes/frame limit dtype: int @@ -39,9 +59,14 @@ asserts: templates: imports: import nrsc5 - make: nrsc5.psd_encoder(${prog_num}, ${title}, ${artist}, ${bytes_per_frame}) + make: nrsc5.psd_encoder(${prog_num}, ${title}, ${artist}, ${album}, ${genre}, ${comment_language}, ${comment_short_desc}, ${comment_text}, ${bytes_per_frame}) documentation: |- - Accepts messages starting with 'title', 'artist', or 'lot' to set the respective fields. + Accepts messages starting with the following to set the respective fields: + - Basic metadata: 'title', 'artist', 'album', 'genre' + - Comment: 'comment' (text), 'comment_short' (short description), 'comment_language' (ISO 639-2 code) + - Commercial: 'commercial_price', 'commercial_valid', 'commercial_url', 'commercial_received', 'commercial_seller', 'commercial_desc' + - Reference ID: 'ufid_owner', 'ufid_id' + - Album art: 'lot' file_format: 1 diff --git a/grc/nrsc5_sis_encoder.block.yml b/grc/nrsc5_sis_encoder.block.yml index f340ae6..bc92fb8 100644 --- a/grc/nrsc5_sis_encoder.block.yml +++ b/grc/nrsc5_sis_encoder.block.yml @@ -130,6 +130,244 @@ parameters: options: ['False', 'True'] option_labels: ['Off', 'On'] hide: part +- id: num_data_channels + label: Data Channels + dtype: int + default: 0 + options: [0, 1, 2, 3, 4, 5, 6, 7, 8] + hide: part +- id: dc0_port + label: Data Channel 0 Port + dtype: int + default: '0x1010' + hide: ${ ('part' if int(num_data_channels) >= 1 else 'all') } +- id: dc0_lot_id + label: Data Channel 0 LOT ID + dtype: int + default: 1010 + hide: ${ ('part' if int(num_data_channels) >= 1 else 'all') } +- id: dc0_sdt + label: Data Channel 0 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 1 else 'all') } +- id: dc0_mime + label: Data Channel 0 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 1 else 'all') } +- id: dc0_name + label: Data Channel 0 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 1 else 'all') } +- id: dc1_port + label: Data Channel 1 Port + dtype: int + default: '0x1011' + hide: ${ ('part' if int(num_data_channels) >= 2 else 'all') } +- id: dc1_lot_id + label: Data Channel 1 LOT ID + dtype: int + default: 1011 + hide: ${ ('part' if int(num_data_channels) >= 2 else 'all') } +- id: dc1_sdt + label: Data Channel 1 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 2 else 'all') } +- id: dc1_mime + label: Data Channel 1 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 2 else 'all') } +- id: dc1_name + label: Data Channel 1 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 2 else 'all') } +- id: dc2_port + label: Data Channel 2 Port + dtype: int + default: '0x1012' + hide: ${ ('part' if int(num_data_channels) >= 3 else 'all') } +- id: dc2_lot_id + label: Data Channel 2 LOT ID + dtype: int + default: 1012 + hide: ${ ('part' if int(num_data_channels) >= 3 else 'all') } +- id: dc2_sdt + label: Data Channel 2 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 3 else 'all') } +- id: dc2_mime + label: Data Channel 2 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 3 else 'all') } +- id: dc2_name + label: Data Channel 2 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 3 else 'all') } +- id: dc3_port + label: Data Channel 3 Port + dtype: int + default: '0x1013' + hide: ${ ('part' if int(num_data_channels) >= 4 else 'all') } +- id: dc3_lot_id + label: Data Channel 3 LOT ID + dtype: int + default: 1013 + hide: ${ ('part' if int(num_data_channels) >= 4 else 'all') } +- id: dc3_sdt + label: Data Channel 3 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 4 else 'all') } +- id: dc3_mime + label: Data Channel 3 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 4 else 'all') } +- id: dc3_name + label: Data Channel 3 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 4 else 'all') } +- id: dc4_port + label: Data Channel 4 Port + dtype: int + default: '0x1014' + hide: ${ ('part' if int(num_data_channels) >= 5 else 'all') } +- id: dc4_lot_id + label: Data Channel 4 LOT ID + dtype: int + default: 1014 + hide: ${ ('part' if int(num_data_channels) >= 5 else 'all') } +- id: dc4_sdt + label: Data Channel 4 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 5 else 'all') } +- id: dc4_mime + label: Data Channel 4 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 5 else 'all') } +- id: dc4_name + label: Data Channel 4 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 5 else 'all') } +- id: dc5_port + label: Data Channel 5 Port + dtype: int + default: '0x1015' + hide: ${ ('part' if int(num_data_channels) >= 6 else 'all') } +- id: dc5_lot_id + label: Data Channel 5 LOT ID + dtype: int + default: 1015 + hide: ${ ('part' if int(num_data_channels) >= 6 else 'all') } +- id: dc5_sdt + label: Data Channel 5 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 6 else 'all') } +- id: dc5_mime + label: Data Channel 5 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 6 else 'all') } +- id: dc5_name + label: Data Channel 5 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 6 else 'all') } +- id: dc6_port + label: Data Channel 6 Port + dtype: int + default: '0x1016' + hide: ${ ('part' if int(num_data_channels) >= 7 else 'all') } +- id: dc6_lot_id + label: Data Channel 6 LOT ID + dtype: int + default: 1016 + hide: ${ ('part' if int(num_data_channels) >= 7 else 'all') } +- id: dc6_sdt + label: Data Channel 6 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 7 else 'all') } +- id: dc6_mime + label: Data Channel 6 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 7 else 'all') } +- id: dc6_name + label: Data Channel 6 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 7 else 'all') } +- id: dc7_port + label: Data Channel 7 Port + dtype: int + default: '0x1017' + hide: ${ ('part' if int(num_data_channels) >= 8 else 'all') } +- id: dc7_lot_id + label: Data Channel 7 LOT ID + dtype: int + default: 1017 + hide: ${ ('part' if int(num_data_channels) >= 8 else 'all') } +- id: dc7_sdt + label: Data Channel 7 Service Data Type + dtype: enum + options: [nrsc5.service_data_type.NON_SPECIFIC, nrsc5.service_data_type.NEWS, nrsc5.service_data_type.SPORTS, nrsc5.service_data_type.WEATHER, nrsc5.service_data_type.EMERGENCY, nrsc5.service_data_type.TRAFFIC, nrsc5.service_data_type.IMAGE_MAPS, nrsc5.service_data_type.TEXT, nrsc5.service_data_type.ADVERTISING, nrsc5.service_data_type.FINANCIAL, nrsc5.service_data_type.STOCK_TICKER, nrsc5.service_data_type.NAVIGATION, nrsc5.service_data_type.ELECTRONIC_PROGRAM_GUIDE, nrsc5.service_data_type.AUDIO, nrsc5.service_data_type.PRIVATE_DATA_NETWORK, nrsc5.service_data_type.SERVICE_MAINTENANCE, nrsc5.service_data_type.HD_RADIO_SYSTEM_SERVICES, nrsc5.service_data_type.AUDIO_RELATED_DATA] + option_labels: ["Non-Specific", "News", "Sports", "Weather", "Emergency", "Traffic", "Image Maps", "Text", "Advertising", "Financial", "Stock Ticker", "Navigation", "EPG", "Audio", "Private Data", "Service Maintenance", "HD Radio System Services", "Audio Related Data"] + default: nrsc5.service_data_type.TEXT + hide: ${ ('part' if int(num_data_channels) >= 8 else 'all') } +- id: dc7_mime + label: Data Channel 7 MIME Type + dtype: enum + options: [nrsc5.mime_hash.TEXT, nrsc5.mime_hash.PNG, nrsc5.mime_hash.JPEG, nrsc5.mime_hash.GIF, nrsc5.mime_hash.PRIMARY_IMAGE, nrsc5.mime_hash.STATION_LOGO] + option_labels: ["Text (.txt)", "PNG Image", "JPEG Image", "GIF Image", "Primary Image", "Station Logo"] + default: nrsc5.mime_hash.TEXT + hide: ${ ('part' if int(num_data_channels) >= 8 else 'all') } +- id: dc7_name + label: Data Channel 7 Name + dtype: string + default: "" + hide: ${ ('part' if int(num_data_channels) >= 8 else 'all') } - id: latitude label: Latitude dtype: real @@ -155,6 +393,41 @@ parameters: dtype: int default: 0 hide: part +- id: exciter_manufacturer_id + label: Exciter Manufacturer ID + dtype: string + default: CS + hide: part +- id: importer_manufacturer_id + label: Importer Manufacturer ID + dtype: string + default: CS + hide: part +- id: exciter_core_ver + label: Exciter Core Version (L1.L2.L3.L4) + dtype: string + default: "1.0.0.0" + hide: part +- id: exciter_mfr_ver + label: Exciter Mfr Version (L1.L2.L3.L4) + dtype: string + default: "1.0.0.0" + hide: part +- id: importer_core_ver + label: Importer Core Version (L1.L2.L3.L4) + dtype: string + default: "1.0.0.0" + hide: part +- id: importer_mfr_ver + label: Importer Mfr Version (L1.L2.L3.L4) + dtype: string + default: "1.0.0.0" + hide: part +- id: importer_config_number + label: Importer Configuration Number + dtype: int + default: 0 + hide: part inputs: - domain: message @@ -182,9 +455,57 @@ asserts: - ${ -90 <= latitude <= 90 } - ${ -180 <= longitude <= 180 } - ${ len(country_code) == 2 } +- ${ len(exciter_manufacturer_id) == 2 } +- ${ len(importer_manufacturer_id) == 2 } templates: imports: import nrsc5 - make: nrsc5.sis_encoder(mode=${mode}, short_name=${short_name}, slogan=${slogan}, message=${message}, program_names=[${program_name0}${ ', '+program_name1 if int(num_programs) >= 2 else '' }${ ', '+program_name2 if int(num_programs) >= 3 else '' }${ ', '+program_name3 if int(num_programs) >= 4 else '' }${ ', '+program_name4 if int(num_programs) >= 5 else '' }${ ', '+program_name5 if int(num_programs) >= 6 else '' }${ ', '+program_name6 if int(num_programs) >= 7 else '' }${ ', '+program_name7 if int(num_programs) >= 8 else '' }], program_types=[${program_type0}${ ', '+program_type1 if int(num_programs) >= 2 else '' }${ ', '+program_type2 if int(num_programs) >= 3 else '' }${ ', '+program_type3 if int(num_programs) >= 4 else '' }${ ', '+program_type4 if int(num_programs) >= 5 else '' }${ ', '+program_type5 if int(num_programs) >= 6 else '' }${ ', '+program_type6 if int(num_programs) >= 7 else '' }${ ', '+program_type7 if int(num_programs) >= 8 else '' }], data_types=[${'nrsc5.service_data_type.EMERGENCY, ' if emergency_alerts == 'True' else ''}], data_mime_types=[${'0x444, ' if emergency_alerts == 'True' else ''}], latitude=${latitude}, longitude=${longitude}, altitude=${altitude}, country_code=${country_code}, fcc_facility_id=${fcc_facility_id}) + make: | + nrsc5.sis_encoder( + mode=${mode}, + short_name=${short_name}, + slogan=${slogan}, + message=${message}, + program_names=[${program_name0}${ ', '+program_name1 if int(num_programs) >= 2 else '' }${ ', '+program_name2 if int(num_programs) >= 3 else '' }${ ', '+program_name3 if int(num_programs) >= 4 else '' }${ ', '+program_name4 if int(num_programs) >= 5 else '' }${ ', '+program_name5 if int(num_programs) >= 6 else '' }${ ', '+program_name6 if int(num_programs) >= 7 else '' }${ ', '+program_name7 if int(num_programs) >= 8 else '' }], + program_types=[${program_type0}${ ', '+program_type1 if int(num_programs) >= 2 else '' }${ ', '+program_type2 if int(num_programs) >= 3 else '' }${ ', '+program_type3 if int(num_programs) >= 4 else '' }${ ', '+program_type4 if int(num_programs) >= 5 else '' }${ ', '+program_type5 if int(num_programs) >= 6 else '' }${ ', '+program_type6 if int(num_programs) >= 7 else '' }${ ', '+program_type7 if int(num_programs) >= 8 else '' }], + data_types=[${'nrsc5.service_data_type.EMERGENCY, ' if emergency_alerts == 'True' else ''}], + data_mime_types=[${'0x444, ' if emergency_alerts == 'True' else ''}], + latitude=${latitude}, + longitude=${longitude}, + altitude=${altitude}, + country_code=${country_code}, + fcc_facility_id=${fcc_facility_id}, + data_channels=[${ 'nrsc5.data_channel_config('+dc0_port+', '+dc0_lot_id+', int('+dc0_sdt+'), int('+dc0_mime+'), '+dc0_name+'), ' if int(num_data_channels) >= 1 else '' }${ 'nrsc5.data_channel_config('+dc1_port+', '+dc1_lot_id+', int('+dc1_sdt+'), int('+dc1_mime+'), '+dc1_name+'), ' if int(num_data_channels) >= 2 else '' }${ 'nrsc5.data_channel_config('+dc2_port+', '+dc2_lot_id+', int('+dc2_sdt+'), int('+dc2_mime+'), '+dc2_name+'), ' if int(num_data_channels) >= 3 else '' }${ 'nrsc5.data_channel_config('+dc3_port+', '+dc3_lot_id+', int('+dc3_sdt+'), int('+dc3_mime+'), '+dc3_name+'), ' if int(num_data_channels) >= 4 else '' }${ 'nrsc5.data_channel_config('+dc4_port+', '+dc4_lot_id+', int('+dc4_sdt+'), int('+dc4_mime+'), '+dc4_name+'), ' if int(num_data_channels) >= 5 else '' }${ 'nrsc5.data_channel_config('+dc5_port+', '+dc5_lot_id+', int('+dc5_sdt+'), int('+dc5_mime+'), '+dc5_name+'), ' if int(num_data_channels) >= 6 else '' }${ 'nrsc5.data_channel_config('+dc6_port+', '+dc6_lot_id+', int('+dc6_sdt+'), int('+dc6_mime+'), '+dc6_name+'), ' if int(num_data_channels) >= 7 else '' }${ 'nrsc5.data_channel_config('+dc7_port+', '+dc7_lot_id+', int('+dc7_sdt+'), int('+dc7_mime+'), '+dc7_name+'), ' if int(num_data_channels) >= 8 else '' }], + exciter_manufacturer_id=${exciter_manufacturer_id}, + importer_manufacturer_id=${importer_manufacturer_id}, + exciter_core_version=[int(x) for x in ${exciter_core_ver}.split('.')], + exciter_mfr_version=[int(x) for x in ${exciter_mfr_ver}.split('.')], + importer_core_version=[int(x) for x in ${importer_core_ver}.split('.')], + importer_mfr_version=[int(x) for x in ${importer_mfr_ver}.split('.')], + importer_configuration_number=${importer_config_number}) + +documentation: |- + SIS (Station Information Service) and SIG (Station Information Guide) encoder. + + Data Channels: Configure data-only channels for transmitting LOT files + independent of audio subchannels. Each data channel is advertised in SIG + with its port, LOT ID, Service Data Type, and MIME Type Hash so decoders + can properly reassemble the LOT objects. + + SIS Parameters (runtime via TCP command port): + set_param|| + Index 0: Leap Second Offset (pending|current or 0xPPCC) + Index 1: Leap Second ALFN LSB + Index 2: Leap Second ALFN MSB + Index 3: Local Time (utc_offset_minutes[|dst_sched|dst_local|dst_regional]) + Index 4: Exciter Manufacturer ID (2 chars) + Index 5: Exciter Core Version (L1.L2.L3) + Index 6: Exciter Mfr Version (L1.L2.L3) + Index 7: Exciter V4+Status (core4.mfr4.core_status.mfr_status) + Index 8: Importer Manufacturer ID (2 chars) + Index 9: Importer Core Version (L1.L2.L3) + Index 10: Importer Mfr Version (L1.L2.L3) + Index 11: Importer V4+Status (core4.mfr4.core_status.mfr_status) + Index 12: Importer Configuration Number (0-65535) file_format: 1 diff --git a/include/nrsc5/api.h b/include/nrsc5/api.h index 67d3422..90cff34 100644 --- a/include/nrsc5/api.h +++ b/include/nrsc5/api.h @@ -13,6 +13,7 @@ #include #include +#include #ifdef gnuradio_nrsc5_EXPORTS #define NRSC5_API __GR_ATTR_EXPORT @@ -34,6 +35,7 @@ enum class mime_hash : uint32_t { TEXT = 0xBB492AAC, JPEG = 0x1E653E9C, PNG = 0x4F328CA0, + GIF = 0x87A4FD95, TTN_TPEG_1 = 0xB39EBEB2, TTN_TPEG_2 = 0x4EB03469, TTN_TPEG_3 = 0x52103469, @@ -41,6 +43,27 @@ enum class mime_hash : uint32_t { TTN_STM_WEATHER = 0xEF042E96 }; +/* Configuration for a data-only channel advertised via SIG/SIM. + * Each entry causes: + * - A DATA service descriptor in the Service Information Message (SIM) + * - A DATA_COMPONENT entry in the SIG with the correct port, SDT, and MIME hash + * - A DATA_INFO entry so decoders can reassemble LOT objects on that port + * + * port AAS port number the LOT encoder is transmitting on + * lot_id LOT object ID used by the LOT encoder on this port + * sdt Service Data Type (from service_data_type enum) + * mime MIME type hash (from mime_hash enum) -- set to 0 to auto-select + * from the file extension at runtime + * name Service name shown in decoder (e.g., "Traffic", "Weather") + */ +struct data_channel_config { + uint16_t port; + uint16_t lot_id; + unsigned int sdt; // service_data_type value + uint32_t mime; // mime_hash value; 0 = auto + std::string name; +}; + } /* namespace nrsc5 */ } /* namespace gr */ diff --git a/include/nrsc5/hdc_encoder.h b/include/nrsc5/hdc_encoder.h index edcc43b..ca23181 100644 --- a/include/nrsc5/hdc_encoder.h +++ b/include/nrsc5/hdc_encoder.h @@ -31,8 +31,13 @@ class NRSC5_API hdc_encoder : virtual public gr::block * constructor is in a private implementation * class. nrsc5::hdc_encoder::make is the public interface for * creating new instances. + * + * \param channels Number of audio channels (1 or 2) + * \param bitrate Bitrate in bits per second + * \param use_parametric_stereo Use Parametric Stereo (AOT 128) instead of Regular Stereo (AOT 127) + * \param tx_digital_gain TX Digital Audio Gain in dB (-8 to +6) */ - static sptr make(int channels = 2, int bitrate = 64000); + static sptr make(int channels = 2, int bitrate = 64000, bool use_parametric_stereo = false, int tx_digital_gain = 0); }; } // namespace nrsc5 diff --git a/include/nrsc5/l2_encoder.h b/include/nrsc5/l2_encoder.h index dd3599a..eae7deb 100644 --- a/include/nrsc5/l2_encoder.h +++ b/include/nrsc5/l2_encoder.h @@ -33,12 +33,23 @@ class NRSC5_API l2_encoder : virtual public gr::block * constructor is in a private implementation * class. nrsc5::l2_encoder::make is the public interface for * creating new instances. + * + * \param num_progs Number of programs + * \param first_prog First program number + * \param size PDU size in bits + * \param data_bytes Number of data bytes + * \param blend_control Blend control mode + * \param tx_digital_gain TX Digital Audio Gain in dB (range: -8 to +6 dB) + * \param debug_logs Enable debug logging for L2 Lot ID port operations + * \param ccc_width Configuration control channel width (1-30 bytes) */ static sptr make(const int num_progs, const int first_prog, const int size, const int data_bytes = 0, const blend blend_control = blend::ENABLE, + const int tx_digital_gain = 0, + const bool debug_logs = false, const int ccc_width = 24); }; diff --git a/include/nrsc5/psd_encoder.h b/include/nrsc5/psd_encoder.h index d14f311..b89e205 100644 --- a/include/nrsc5/psd_encoder.h +++ b/include/nrsc5/psd_encoder.h @@ -33,8 +33,13 @@ class NRSC5_API psd_encoder : virtual public gr::sync_block * creating new instances. */ static sptr make(const int prog_num, - const std::string& title, - const std::string& artist, + const std::string& title = "", + const std::string& artist = "", + const std::string& album = "", + const std::string& genre = "", + const std::string& comment_language = "", + const std::string& comment_short_desc = "", + const std::string& comment_text = "", const int bytes_per_frame = 0); }; diff --git a/include/nrsc5/sis_encoder.h b/include/nrsc5/sis_encoder.h index 9f376bb..70c24ac 100644 --- a/include/nrsc5/sis_encoder.h +++ b/include/nrsc5/sis_encoder.h @@ -103,7 +103,15 @@ class NRSC5_API sis_encoder : virtual public gr::sync_block const float longitude = -74.0445, const float altitude = 93.0, const std::string& country_code = "US", - const unsigned int fcc_facility_id = 0); + const unsigned int fcc_facility_id = 0, + const std::vector data_channels = {}, + const std::string& exciter_manufacturer_id = "CS", + const std::string& importer_manufacturer_id = "CS", + const std::vector exciter_core_version = { 1, 0, 0, 0 }, + const std::vector exciter_mfr_version = { 1, 0, 0, 0 }, + const std::vector importer_core_version = { 1, 0, 0, 0 }, + const std::vector importer_mfr_version = { 1, 0, 0, 0 }, + const unsigned int importer_configuration_number = 0); }; } // namespace nrsc5 diff --git a/lib/hdc_encoder_impl.cc b/lib/hdc_encoder_impl.cc index 85f27a5..37649e0 100644 --- a/lib/hdc_encoder_impl.cc +++ b/lib/hdc_encoder_impl.cc @@ -15,16 +15,16 @@ namespace gr { namespace nrsc5 { -hdc_encoder::sptr hdc_encoder::make(int channels, int bitrate) +hdc_encoder::sptr hdc_encoder::make(int channels, int bitrate, bool use_parametric_stereo, int tx_digital_gain) { - return gnuradio::get_initial_sptr(new hdc_encoder_impl(channels, bitrate)); + return gnuradio::get_initial_sptr(new hdc_encoder_impl(channels, bitrate, use_parametric_stereo, tx_digital_gain)); } /* * The private constructor */ -hdc_encoder_impl::hdc_encoder_impl(int channels, int bitrate) +hdc_encoder_impl::hdc_encoder_impl(int channels, int bitrate, bool use_parametric_stereo, int tx_digital_gain) : gr::block("hdc_encoder", gr::io_signature::make(1, 2, sizeof(float)), gr::io_signature::make(1, 1, sizeof(unsigned char))) @@ -33,10 +33,36 @@ hdc_encoder_impl::hdc_encoder_impl(int channels, int bitrate) bytes_per_frame = bitrate * SAMPLES_PER_FRAME / HDC_SAMPLE_RATE / 8; set_relative_rate((double)bytes_per_frame / SAMPLES_PER_FRAME); + // Validate and clamp TX Digital Audio Gain to valid range (-8 to +6 dB) + if (tx_digital_gain < -8) { + this->tx_digital_gain = -8; + fprintf(stderr, "Warning: TX Digital Audio Gain clamped to minimum -8 dB\n"); + } else if (tx_digital_gain > 6) { + this->tx_digital_gain = 6; + fprintf(stderr, "Warning: TX Digital Audio Gain clamped to maximum +6 dB\n"); + } else { + this->tx_digital_gain = tx_digital_gain; + } + + // Convert dB to linear multiplier: multiplier = 10^(dB/20) + gain_multiplier = pow(10.0, this->tx_digital_gain / 20.0); + int vbr = 0; int afterburner = 1; CHANNEL_MODE mode; AACENC_InfoStruct info = { 0 }; + + // Determine AOT based on parametric stereo setting + // AOT 127 = HE-AAC v1 (Regular Stereo) + // AOT 128 = HE-AAC v2 with Parametric Stereo (PS) + int aot = use_parametric_stereo ? 128 : 127; + + // Note: Parametric Stereo (AOT 128) is only applicable for stereo + if (use_parametric_stereo && channels != 2) { + fprintf(stderr, "Warning: Parametric Stereo requires 2 channels, using Regular Stereo (AOT 127) instead\n"); + aot = 127; + } + switch (channels) { case 1: mode = MODE_1; @@ -50,7 +76,7 @@ hdc_encoder_impl::hdc_encoder_impl(int channels, int bitrate) if (aacEncOpen(&handle, 0, channels) != AACENC_OK) { throw std::runtime_error("hdc_encoder: Unable to open decoder"); } - if (aacEncoder_SetParam(handle, AACENC_AOT, AOT_HDC) != AACENC_OK) { + if (aacEncoder_SetParam(handle, AACENC_AOT, aot) != AACENC_OK) { throw std::runtime_error("hdc_encoder: Unable to set the AOT"); } if (aacEncoder_SetParam(handle, AACENC_SAMPLERATE, HDC_SAMPLE_RATE) != AACENC_OK) { @@ -164,7 +190,12 @@ int hdc_encoder_impl::general_work(int noutput_items, int convert_off = 0; for (int i = 0; i < frame_length; i++) { for (int channel = 0; channel < channels; channel++) { - convert_buf[convert_off++] = (short)(in[channel][in_off] * 32768); + // Apply TX Digital Audio Gain + double sample = in[channel][in_off] * gain_multiplier; + // Clamp to prevent overflow + if (sample > 1.0) sample = 1.0; + if (sample < -1.0) sample = -1.0; + convert_buf[convert_off++] = (short)(sample * 32768); } in_off++; } diff --git a/lib/hdc_encoder_impl.h b/lib/hdc_encoder_impl.h index 335479b..9f69747 100644 --- a/lib/hdc_encoder_impl.h +++ b/lib/hdc_encoder_impl.h @@ -32,9 +32,11 @@ class hdc_encoder_impl : public hdc_encoder unsigned char* outbuf; int outbuf_off; int outbuf_len; + int tx_digital_gain; + double gain_multiplier; public: - hdc_encoder_impl(int channels, int bitrate); + hdc_encoder_impl(int channels, int bitrate, bool use_parametric_stereo, int tx_digital_gain); ~hdc_encoder_impl(); // Where all the action really happens diff --git a/lib/l1_am_encoder_impl.cc b/lib/l1_am_encoder_impl.cc index 200140e..4b8a0fa 100644 --- a/lib/l1_am_encoder_impl.cc +++ b/lib/l1_am_encoder_impl.cc @@ -173,15 +173,13 @@ int l1_am_encoder_impl::general_work(int noutput_items, -std::conj(qam64[pl_matrix[col][symbol]]); out[out_off + 128 + 57 + col] = qam64[pu_matrix[col][symbol]]; - if (!rdb) { - /* 1012s.pdf table 12-6 */ - out[out_off + 128 + 2 + col] = qpsk_am[t_matrix[col][symbol]]; - out[out_off + 128 + 28 + col] = qam16[s_matrix[col][symbol]]; - out[out_off + 128 - 2 - col] = - -std::conj(qpsk_am[t_matrix[col][symbol]]); - out[out_off + 128 - 28 - col] = - -std::conj(qam16[s_matrix[col][symbol]]); - } + /* 1012s.pdf table 12-6 */ + out[out_off + 128 + 2 + col] = qpsk_am[t_matrix[col][symbol]]; + out[out_off + 128 + 28 + col] = qam16[s_matrix[col][symbol]]; + out[out_off + 128 - 2 - col] = + -std::conj(qpsk_am[t_matrix[col][symbol]]); + out[out_off + 128 - 28 - col] = + -std::conj(qam16[s_matrix[col][symbol]]); break; case 3: /* 1012s.pdf table 12-3 */ @@ -189,12 +187,10 @@ int l1_am_encoder_impl::general_work(int noutput_items, -std::conj(qam64[pl_matrix[col][symbol]]); out[out_off + 128 + 2 + col] = qam64[pu_matrix[col][symbol]]; - if (!rdb) { - /* 1012s.pdf table 12-8 */ - out[out_off + 128 - 28 - col] = - -std::conj(qam64[t_matrix[col][symbol]]); - out[out_off + 128 + 28 + col] = qam64[s_matrix[col][symbol]]; - } + /* 1012s.pdf table 12-8 */ + out[out_off + 128 - 28 - col] = + -std::conj(qam64[t_matrix[col][symbol]]); + out[out_off + 128 + 28 + col] = qam64[s_matrix[col][symbol]]; break; } } @@ -204,11 +200,9 @@ int l1_am_encoder_impl::general_work(int noutput_items, switch (sm) { case 1: /* 1012s.pdf table 12-7 */ - if (!rdb) { - out[out_off + 128 - 27] = -std::conj(pids_point_0); - out[out_off + 128 + 27] = pids_point_0; - } + out[out_off + 128 - 27] = -std::conj(pids_point_0); out[out_off + 128 - 53] = -std::conj(pids_point_1); + out[out_off + 128 + 27] = pids_point_0; out[out_off + 128 + 53] = pids_point_1; break; case 3: @@ -336,10 +330,8 @@ void l1_am_encoder_impl::interleaver_ma1() { memset(pu_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); memset(pl_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - if (!rdb) { - memset(s_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - memset(t_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - } + memset(s_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); + memset(t_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); for (int i = 0; i < 6000; i++) { for (int j = 0; j < 3; j++) { @@ -348,13 +340,11 @@ void l1_am_encoder_impl::interleaver_ma1() bu[DIVERSITY_DELAY + i * 3 + j] = p1_g[i * 12 + bu_delay[j]]; mu[i * 3 + j] = p1_g[i * 12 + mu_delay[j]]; } - if (!rdb) { - for (int j = 0; j < 2; j++) { - el[i * 2 + j] = p3_g[i * 6 + el_delay[j]]; - } - for (int j = 0; j < 4; j++) { - eu[i * 4 + j] = p3_g[i * 6 + eu_delay[j]]; - } + for (int j = 0; j < 2; j++) { + el[i * 2 + j] = p3_g[i * 6 + el_delay[j]]; + } + for (int j = 0; j < 4; j++) { + eu[i * 4 + j] = p3_g[i * 6 + eu_delay[j]]; } } @@ -380,19 +370,17 @@ void l1_am_encoder_impl::interleaver_ma1() p = 3 + (n % 3); bit_map(pu_matrix, b, k, mu[n] << p); } - if (!rdb) { - for (int n = 0; n < 12000; n++) { - b = (3 * n + n / 3000) % 8; - k = (n + (n / 6000)) % 750; - p = n % 2; - bit_map(t_matrix, b, k, el[n] << p); - } - for (int n = 0; n < 24000; n++) { - b = (3 * n + n / 3000 + 2 * (n / 12000)) % 8; - k = (n + (n / 6000)) % 750; - p = n % 4; - bit_map(s_matrix, b, k, eu[n] << p); - } + for (int n = 0; n < 12000; n++) { + b = (3 * n + n / 3000) % 8; + k = (n + (n / 6000)) % 750; + p = n % 2; + bit_map(t_matrix, b, k, el[n] << p); + } + for (int n = 0; n < 24000; n++) { + b = (3 * n + n / 3000 + 2 * (n / 12000)) % 8; + k = (n + (n / 6000)) % 750; + p = n % 4; + bit_map(s_matrix, b, k, eu[n] << p); } /* training symbols */ @@ -400,10 +388,8 @@ void l1_am_encoder_impl::interleaver_ma1() for (int k = 750; k < 800; k++) { bit_map(pu_matrix, block, k, 0b100101); bit_map(pl_matrix, block, k, 0b100101); - if (!rdb) { - bit_map(s_matrix, block, k, 0b1001); - bit_map(t_matrix, block, k, 0b10); - } + bit_map(s_matrix, block, k, 0b1001); + bit_map(t_matrix, block, k, 0b10); } } @@ -415,10 +401,8 @@ void l1_am_encoder_impl::interleaver_ma3() { memset(pu_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); memset(pl_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - if (!rdb) { - memset(s_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - memset(t_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); - } + memset(s_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); + memset(t_matrix, 0, 25 * AM_SYMBOLS_PER_FRAME); for (int i = 0; i < 6000; i++) { for (int j = 0; j < 3; j++) { @@ -427,12 +411,10 @@ void l1_am_encoder_impl::interleaver_ma3() bu[DIVERSITY_DELAY + i * 3 + j] = p1_g[i * 12 + bu_delay[j]]; mu[i * 3 + j] = p1_g[i * 12 + mu_delay[j]]; - if (!rdb) { - ebl[DIVERSITY_DELAY + i * 3 + j] = p3_g[i * 12 + bl_delay[j]]; - eml[i * 3 + j] = p3_g[i * 12 + ml_delay[j]]; - ebu[DIVERSITY_DELAY + i * 3 + j] = p3_g[i * 12 + bu_delay[j]]; - emu[i * 3 + j] = p3_g[i * 12 + mu_delay[j]]; - } + ebl[DIVERSITY_DELAY + i * 3 + j] = p3_g[i * 12 + bl_delay[j]]; + eml[i * 3 + j] = p3_g[i * 12 + ml_delay[j]]; + ebu[DIVERSITY_DELAY + i * 3 + j] = p3_g[i * 12 + bu_delay[j]]; + emu[i * 3 + j] = p3_g[i * 12 + mu_delay[j]]; } } @@ -458,27 +440,25 @@ void l1_am_encoder_impl::interleaver_ma3() p = 3 + (n % 3); bit_map(pu_matrix, b, k, mu[n] << p); - if (!rdb) { - b = (3 * n + 3) % 8; - k = (n + n / 3000 + 3) % 750; - p = n % 3; - bit_map(t_matrix, b, k, ebl[n] << p); - - b = (3 * n + 3) % 8; - k = (n + n / 3000 + 3) % 750; - p = 3 + (n % 3); - bit_map(t_matrix, b, k, eml[n] << p); - - b = (3 * n) % 8; - k = (n + n / 3000 + 2) % 750; - p = n % 3; - bit_map(s_matrix, b, k, ebu[n] << p); - - b = (3 * n) % 8; - k = (n + n / 3000 + 2) % 750; - p = 3 + (n % 3); - bit_map(s_matrix, b, k, emu[n] << p); - } + b = (3 * n + 3) % 8; + k = (n + n / 3000 + 3) % 750; + p = n % 3; + bit_map(t_matrix, b, k, ebl[n] << p); + + b = (3 * n + 3) % 8; + k = (n + n / 3000 + 3) % 750; + p = 3 + (n % 3); + bit_map(t_matrix, b, k, eml[n] << p); + + b = (3 * n) % 8; + k = (n + n / 3000 + 2) % 750; + p = n % 3; + bit_map(s_matrix, b, k, ebu[n] << p); + + b = (3 * n) % 8; + k = (n + n / 3000 + 2) % 750; + p = 3 + (n % 3); + bit_map(s_matrix, b, k, emu[n] << p); } /* training symbols */ @@ -486,19 +466,15 @@ void l1_am_encoder_impl::interleaver_ma3() for (int k = 750; k < 800; k++) { bit_map(pu_matrix, block, k, 0b100101); bit_map(pl_matrix, block, k, 0b100101); - if (!rdb) { - bit_map(s_matrix, block, k, 0b100101); - bit_map(t_matrix, block, k, 0b100101); - } + bit_map(s_matrix, block, k, 0b100101); + bit_map(t_matrix, block, k, 0b100101); } } memmove(bl, bl + 18000, DIVERSITY_DELAY); memmove(bu, bu + 18000, DIVERSITY_DELAY); - if (!rdb) { - memmove(ebl, ebl + 18000, DIVERSITY_DELAY); - memmove(ebu, ebu + 18000, DIVERSITY_DELAY); - } + memmove(ebl, ebl + 18000, DIVERSITY_DELAY); + memmove(ebu, ebu + 18000, DIVERSITY_DELAY); } void l1_am_encoder_impl::interleaver_pids(unsigned char* in, @@ -540,7 +516,7 @@ void l1_am_encoder_impl::interleaver_pids(unsigned char* in, /* 1012s.pdf table 11-1 */ void l1_am_encoder_impl::sc_data_seq( - unsigned char* out, int pli, int hppi, int aabi, int rdbi, int bc, int smi) + unsigned char* out, int pli, int hppi, int abbi, int rdbi, int bc, int smi) { out[0] = 0; // sync out[1] = 1; // sync @@ -557,7 +533,7 @@ void l1_am_encoder_impl::sc_data_seq( out[10] = 0; // reserved out[11] = hppi; // high power pids indicator - out[12] = aabi; // analog audio bandwidth indicator + out[12] = abbi; // analog audio bandwidth indicator out[13] = out[10] ^ out[11] ^ out[12]; // parity out[14] = 0; // sync @@ -592,77 +568,42 @@ void l1_am_encoder_impl::set_channel_power() // Table 4-6 from 1082s.pdf switch (sm) { - case 1: { - float db_pu = -30; - float db_pl = -30; - float db_s = (pl ? -37 : -43); - float db_t[25]; - float db_ref = -26; - float db_pids1 = (pl ? -37 : -43); - float db_pids2_diff; - - for (int col = 0; col < 25; col++) { - if (pl) { - db_t[col] = -44; - } else { - if (col < 12) { - db_t[col] = -44 - (0.5 * col); - } else { - db_t[col] = -50; - } - } - } - - if (rdb || hpp) { - db_pids2_diff = 0; - } else if (pl) { - db_pids2_diff = -7; - } else { - db_pids2_diff = -13; - } - + case 1: for (int col = 0; col < 25; col++) { - channel_power[128 + 57 + col] = db_pu - qam64_power; - channel_power[128 - 57 - col] = db_pl - qam64_power; + channel_power[128 + 57 + col] = -30 - qam64_power; + channel_power[128 - 57 - col] = -30 - qam64_power; - channel_power[128 + 28 + col] = db_s - qam16_power; - channel_power[128 - 28 - col] = db_s - qam16_power; + channel_power[128 + 28 + col] = -43 - qam16_power; + channel_power[128 - 28 - col] = -43 - qam16_power; - channel_power[128 + 2 + col] = db_t[col] - qpsk_power; - channel_power[128 - 2 - col] = db_t[col] - qpsk_power; + channel_power[128 + 2 + col] = (col < 12 ? (-44 - 0.5 * col) : -50) - qpsk_power; + channel_power[128 - 2 - col] = (col < 12 ? (-44 - 0.5 * col) : -50) - qpsk_power; } - channel_power[128 + 1] = db_ref - bpsk_power; - channel_power[128 - 1] = db_ref - bpsk_power; + channel_power[128 + 1] = -26 - bpsk_power; + channel_power[128 - 1] = -26 - bpsk_power; - channel_power[128 + 27] = db_pids1 - qam16_power; - channel_power[128 - 27] = db_pids1 - qam16_power; - channel_power[128 + 53] = db_pu + db_pids2_diff - qam16_power; - channel_power[128 - 53] = db_pl + db_pids2_diff - qam16_power; + channel_power[128 + 27] = -43 - qam16_power; + channel_power[128 - 27] = -43 - qam16_power; + channel_power[128 + 53] = -43 - qam16_power; + channel_power[128 - 53] = -43 - qam16_power; break; - } - case 3: { - float db_p = -15; - float db_e = -30; - float db_ref = -15; - float db_pids_diff = ((rdb || hpp) ? 0 : -15); - + case 3: for (int col = 0; col < 25; col++) { - channel_power[128 + 2 + col] = db_p - qam64_power; - channel_power[128 - 2 - col] = db_p - qam64_power; + channel_power[128 + 2 + col] = -15 - qam64_power; + channel_power[128 - 2 - col] = -15 - qam64_power; - channel_power[128 + 28 + col] = db_e - qam64_power; - channel_power[128 - 28 - col] = db_e - qam64_power; + channel_power[128 + 28 + col] = -30 - qam64_power; + channel_power[128 - 28 - col] = -30 - qam64_power; } - channel_power[128 + 1] = db_ref - bpsk_power; - channel_power[128 - 1] = db_ref - bpsk_power; + channel_power[128 + 1] = -15 - bpsk_power; + channel_power[128 - 1] = -15 - bpsk_power; - channel_power[128 + 27] = db_p + db_pids_diff - qam16_power; - channel_power[128 - 27] = db_p + db_pids_diff - qam16_power; + channel_power[128 + 27] = -30 - qam16_power; + channel_power[128 - 27] = -30 - qam16_power; break; } - } for (int i = 0; i < AM_FFT_SIZE; i++) { channel_power[i] = pow(10, channel_power[i] / 20); diff --git a/lib/l1_fm_encoder_impl.cc b/lib/l1_fm_encoder_impl.cc index 6544530..f11035a 100644 --- a/lib/l1_fm_encoder_impl.cc +++ b/lib/l1_fm_encoder_impl.cc @@ -15,7 +15,7 @@ namespace gr { namespace nrsc5 { -std::vector get_in_sizeofs_fm(const int psm, const int ssm) +std::vector get_in_sizeofs(const int psm, const int ssm) { std::vector in_sizeofs; @@ -67,7 +67,7 @@ l1_fm_encoder::sptr l1_fm_encoder::make(const int psm, const int ssm) */ l1_fm_encoder_impl::l1_fm_encoder_impl(const int psm, const int ssm) : gr::block("l1_fm_encoder", - gr::io_signature::makev(2, 9, get_in_sizeofs_fm(psm, ssm)), + gr::io_signature::makev(2, 9, get_in_sizeofs(psm, ssm)), gr::io_signature::make(1, 1, sizeof(gr_complex) * FM_FFT_SIZE)) { set_output_multiple(FM_SYMBOLS_PER_FRAME); @@ -336,6 +336,11 @@ int l1_fm_encoder_impl::general_work(int noutput_items, px2_matrix + (symbol * 8 * 36), out + out_off, px2_channels, 8); } + // Explicitly suppress DC bin (bin 0) to eliminate carrier spike + // Also suppress Nyquist bin (1024) for proper conjugate symmetry + out[out_off + 0] = gr_complex(0, 0); + out[out_off + 1024] = gr_complex(0, 0); + out_off += FM_FFT_SIZE; } message_port_pub(pmt::intern("clock"), pmt::from_long(1)); @@ -550,7 +555,7 @@ void l1_fm_encoder_impl::primary_sc_data_seq( out[10] = (scid & 0x2) >> 1; out[11] = (scid & 0x1); - out[12] = 0; // ASM1 + out[12] = 0; // ASM1 out[13] = out[10] ^ out[11] ^ out[12]; // parity out[14] = 0; // sync diff --git a/lib/l2_encoder_impl.cc b/lib/l2_encoder_impl.cc index b8d84f2..3602dce 100644 --- a/lib/l2_encoder_impl.cc +++ b/lib/l2_encoder_impl.cc @@ -27,10 +27,12 @@ l2_encoder::sptr l2_encoder::make(const int num_progs, const int size, const int data_bytes, const blend blend_control, + const int tx_digital_gain, + const bool debug_logs, const int ccc_width) { return gnuradio::get_initial_sptr( - new l2_encoder_impl(num_progs, first_prog, size, data_bytes, blend_control, ccc_width)); + new l2_encoder_impl(num_progs, first_prog, size, data_bytes, blend_control, tx_digital_gain, debug_logs, ccc_width)); } @@ -42,6 +44,8 @@ l2_encoder_impl::l2_encoder_impl(const int num_progs, const int size, const int data_bytes, const blend blend_control, + const int tx_digital_gain, + const bool debug_logs, const int ccc_width) : gr::block("l2_encoder", gr::io_signature::make(0, 16, sizeof(unsigned char)), @@ -59,6 +63,18 @@ l2_encoder_impl::l2_encoder_impl(const int num_progs, this->size = size; this->data_bytes = data_bytes; this->blend_control = blend_control; + this->debug_logs = debug_logs; + + // Validate and clamp TX Digital Audio Gain to valid range (-8 to +6 dB) + if (tx_digital_gain < -8) { + this->tx_digital_gain = -8; + fprintf(stderr, "Warning: TX Digital Audio Gain clamped to minimum -8 dB\n"); + } else if (tx_digital_gain > 6) { + this->tx_digital_gain = 6; + fprintf(stderr, "Warning: TX Digital Audio Gain clamped to maximum +6 dB\n"); + } else { + this->tx_digital_gain = tx_digital_gain; + } payload_bytes = (size - 22) / 8; out_buf = (unsigned char*)malloc(payload_bytes); rs_enc = init_rs_char(8, 0x11d, 1, 1, 8); @@ -142,6 +158,7 @@ int l2_encoder_impl::general_work(int noutput_items, for (int p = 0; p < num_progs; p++) { int program_number = first_prog + p; int bytes_left = (out_buf + payload_bytes - total_data_width) - out_program; + int bytes_left_at_start = bytes_left; // Save for error reporting int nop = 0; int off = hdc_off[p]; int audio_length = 0; @@ -189,7 +206,7 @@ int l2_encoder_impl::general_work(int noutput_items, /*stream_id*/ 0, pdu_seq_no, program_number == 0 ? static_cast(blend_control) : 0, - /*digital_gain_or_per_stream_delay*/ 0, + /*digital_gain_or_per_stream_delay*/ tx_gain_db_to_value(tx_digital_gain), /*common_delay*/ program_number == 0 ? 24 : 0, /*latency*/ 4, partial_bytes[p] ? 1 : 0, @@ -247,7 +264,119 @@ int l2_encoder_impl::general_work(int noutput_items, out_program += (end + 1); if (target_seq_no - start_seq_no[p] > 8) { - fprintf(stderr, "Audio bitrate it too high\n"); + // Calculate approximate bitrate based on ADTS frame sizes + int frames_behind = target_seq_no - start_seq_no[p]; + + // Use the space that was available at the START of processing this program + int bytes_available_for_program = bytes_left_at_start; + + // Determine service mode based on size parameter + const char* service_mode; + int max_bitrate_kbps; + switch (size) { + case 146176: + service_mode = "MP1 (Primary)"; + max_bitrate_kbps = 96; + break; + case 109312: + service_mode = "MP5 (Hybrid)"; + max_bitrate_kbps = 72; + break; + case 72448: + service_mode = "MP6 (Hybrid)"; + max_bitrate_kbps = 48; + break; + case 30000: + case 24000: + service_mode = "MP1 (Low bitrate)"; + max_bitrate_kbps = 32; + break; + case 9216: + service_mode = "MP6 (Data)"; + max_bitrate_kbps = 24; + break; + case 4608: + service_mode = "MP3 (Data)"; + max_bitrate_kbps = 12; + break; + case 3750: + service_mode = "MP5 (Data)"; + max_bitrate_kbps = 10; + break; + case 2304: + service_mode = "MP2 (Data)"; + max_bitrate_kbps = 6; + break; + default: + service_mode = "Unknown"; + max_bitrate_kbps = 0; + } + + fprintf(stderr, + "\n" + "================================================================================\n" + "ERROR: Audio bitrate is too high\n" + "================================================================================\n" + "Location: l2_encoder_impl.cc:general_work() - Program %d (index %d/%d)\n" + "Service Mode: %s (PDU size: %d bits)\n" + "Problem: Encoder is %d frames behind (threshold: 8)\n" + " The audio encoder is producing more data than the HD Radio\n" + " channel can transmit.\n" + "\n" + "PDU Space Analysis:\n" + " Total payload: %d bytes\n" + " Data subchannel: %d bytes (fixed data + config control)\n" + " PSD bytes per program: %d bytes\n" + " Space for all programs: %d bytes\n" + " Available for Program %d: %d bytes (before processing)\n" + " Programs in this PDU: %d (numbered %d-%d)\n" + "\n" + "Maximum recommended audio bitrate: %d kbps\n" + "\n" + "How to fix in GRC file:\n" + " OPTION 1 - Reduce Audio Bitrate:\n" + " 1. Locate the 'HDC Encoder' block for Program %d\n" + " 2. Reduce the 'Bitrate' parameter to %d kbps or lower\n" + " 3. Common safe values: 32, 48, 64, or 96 kbps (depending on mode)\n" + "\n", + program_number, p + 1, num_progs, service_mode, size, frames_behind, + payload_bytes, total_data_width, psd_bytes, + payload_bytes - total_data_width, program_number, + bytes_available_for_program, num_progs, first_prog, + first_prog + num_progs - 1, max_bitrate_kbps, + program_number, max_bitrate_kbps); + + if (data_bytes > 0) { + fprintf(stderr, + " OPTION 2 - Reduce Fixed Data Bandwidth:\n" + " 1. Locate the 'L2 Encoder' block\n" + " 2. Current 'Data Bytes' parameter: %d bytes/frame\n" + " 3. Reduce 'Data Bytes' to free up space for audio\n" + " 4. Each byte freed = ~%d bps more audio capacity\n" + "\n", + data_bytes, (int)(data_bytes * 8.0 * 1000 / (size / 8.0))); + } + + if (psd_bytes > 8) { + fprintf(stderr, + " OPTION 3 - Reduce PSD Size (if excessive):\n" + " 1. Current PSD: %d bytes/frame per program\n" + " 2. Check if you're sending excessive metadata\n" + " 3. Standard PSD for data modes: 8 bytes is typical\n" + "\n", + psd_bytes); + } + + fprintf(stderr, + " OPTION 4 - Use Fewer Programs:\n" + " Current: %d program(s) sharing %d bytes\n" + " Reducing program count increases space per program\n" + "\n" + "Note: Lower bitrates may reduce audio quality but are necessary for\n" + " reliable transmission. Balance audio quality, data services, and\n" + " number of programs based on your service mode's capacity.\n" + "================================================================================\n", + num_progs, payload_bytes - total_data_width); } } @@ -290,6 +419,7 @@ int l2_encoder_impl::general_work(int noutput_items, } } + int data_bytes_this_frame = 0; for (int i = payload_bytes - 1 - ccc_width - data_bytes; i < payload_bytes - 1 - ccc_width; i++) { @@ -299,6 +429,7 @@ int l2_encoder_impl::general_work(int noutput_items, if (aas_queue_bytes == 0) { // all queues are empty out_buf[i] = 0x7e; } else { // at least one queue still has data + data_bytes_this_frame++; // advance to the next non-empty queue if necessary while (aas_queues[aas_current_port].empty()) { aas_current_port_index = @@ -315,18 +446,37 @@ int l2_encoder_impl::general_work(int noutput_items, if (out_buf[i] == 0x7e) { // if we emptied the queue, ask for more if (aas_queues[aas_current_port].empty()) { + if (debug_logs) { + fprintf(stderr, "L2: Port %d queue empty, sending ready signal and switching ports\n", aas_current_port); + } message_port_pub(pmt::intern("ready"), pmt::from_long(aas_current_port)); - } - aas_current_port_index = - (aas_current_port_index + 1) % aas_ports.size(); - aas_current_port = aas_ports[aas_current_port_index]; + // Switch to next port immediately if queue is empty + aas_current_port_index = + (aas_current_port_index + 1) % aas_ports.size(); + aas_current_port = aas_ports[aas_current_port_index]; + } else { + // Keep sending from the same port if queue still has data + // This allows bursts of multiple PDUs from same port for faster transmission + if (debug_logs) { + fprintf(stderr, "L2: Port %d still has data (%zu bytes), continuing burst\n", + aas_current_port, aas_queues[aas_current_port].size()); + } + } } } } aas_block_offset = (aas_block_offset + 1) % (255 + 4); } + + if (debug_logs) { + static int frame_counter = 0; + if (++frame_counter % 100 == 0) { + fprintf(stderr, "L2: Transmitted %d data bytes this frame (data_bytes param=%d, overhead=%d)\n", + data_bytes_this_frame, data_bytes, data_bytes - data_bytes_this_frame); + } + } } const unsigned char *pci; @@ -466,6 +616,27 @@ int l2_encoder_impl::adts_length(const unsigned char* header) int l2_encoder_impl::len_locators(int nop) { return ((lc_bits * nop) + 4) / 8; } +/* Convert TX Digital Audio Gain from dB to 5-bit value per NRSC-5 spec Table 5-5 */ +int l2_encoder_impl::tx_gain_db_to_value(int gain_db) +{ + // Table 5-5: TX Digital Audio Gain Control + // Value range: 0b11000 (-8 dB) to 0b00110 (+6 dB) + // 0b00000 = 0 dB (center value) + // Negative gains: 0b11000 to 0b11111 (-8 to -1 dB) + // Positive gains: 0b00001 to 0b00110 (+1 to +6 dB) + + if (gain_db >= -8 && gain_db < 0) { + // Negative gain: map -8..-1 dB to 0b11000..0b11111 (24..31) + return 24 + (gain_db + 8); + } else if (gain_db >= 0 && gain_db <= 6) { + // Zero or positive gain: map 0..+6 dB to 0b00000..0b00110 (0..6) + return gain_db; + } else { + // Invalid value (should not happen due to validation in constructor) + return 0; // Default to 0 dB + } +} + void l2_encoder_impl::handle_aas_pdu(pmt::pmt_t msg) { std::vector pdu_bytes = pmt::u8vector_elements(pmt::cdr(msg)); diff --git a/lib/l2_encoder_impl.h b/lib/l2_encoder_impl.h index a014946..e6cae7b 100644 --- a/lib/l2_encoder_impl.h +++ b/lib/l2_encoder_impl.h @@ -67,6 +67,8 @@ class l2_encoder_impl : public l2_encoder int size; int data_bytes; blend blend_control; + int tx_digital_gain; + bool debug_logs; int payload_bytes; unsigned char rs_buf[255]; void* rs_enc; @@ -110,6 +112,7 @@ class l2_encoder_impl : public l2_encoder header_spread(const unsigned char* in, unsigned char* out, const unsigned char* pci); int adts_length(const unsigned char* header); int len_locators(int nop); + int tx_gain_db_to_value(int gain_db); void handle_aas_pdu(pmt::pmt_t msg); void decode_sig(std::vector& pdu_bytes); @@ -119,6 +122,8 @@ class l2_encoder_impl : public l2_encoder const int size, const int data_bytes = 0, const blend blend_control = blend::ENABLE, + const int tx_digital_gain = 0, + const bool debug_logs = false, const int ccc_width = 24); ~l2_encoder_impl(); diff --git a/lib/psd_encoder_impl.cc b/lib/psd_encoder_impl.cc index 677259c..1bf9999 100644 --- a/lib/psd_encoder_impl.cc +++ b/lib/psd_encoder_impl.cc @@ -20,10 +20,16 @@ namespace nrsc5 { psd_encoder::sptr psd_encoder::make(const int prog_num, const std::string& title, const std::string& artist, + const std::string& album, + const std::string& genre, + const std::string& comment_language, + const std::string& comment_short_desc, + const std::string& comment_text, const int bytes_per_frame) { return gnuradio::get_initial_sptr( - new psd_encoder_impl(prog_num, title, artist, bytes_per_frame)); + new psd_encoder_impl(prog_num, title, artist, album, genre, + comment_language, comment_short_desc, comment_text, bytes_per_frame)); } @@ -33,6 +39,11 @@ psd_encoder::sptr psd_encoder::make(const int prog_num, psd_encoder_impl::psd_encoder_impl(const int prog_num, const std::string& title, const std::string& artist, + const std::string& album, + const std::string& genre, + const std::string& comment_language, + const std::string& comment_short_desc, + const std::string& comment_text, const int bytes_per_frame) : gr::sync_block("psd_encoder", gr::io_signature::make(0, 0, 0), @@ -41,6 +52,19 @@ psd_encoder_impl::psd_encoder_impl(const int prog_num, this->prog_num = prog_num; this->title = title; this->artist = artist; + this->album = album; + this->genre = genre; + this->comment_language = comment_language; + this->comment_short_desc = comment_short_desc; + this->comment_text = comment_text; + this->commercial_price = ""; + this->commercial_valid_until = ""; + this->commercial_contact_url = ""; + this->commercial_received_as = ""; + this->commercial_seller = ""; + this->commercial_description = ""; + this->ufid_owner = ""; + this->ufid_identifier = ""; this->bytes_per_frame = bytes_per_frame; lot = -1; seq_num = 0; @@ -111,8 +135,48 @@ std::string psd_encoder_impl::encode_id3() { std::stringstream out; - std::string payload = encode_text_frame("TIT2", title) + - encode_text_frame("TPE1", artist) + encode_xhdr_frame(); + std::string payload = ""; + + // PSD Type 1: Title (TIT2) - Required + if (!title.empty()) { + payload += encode_text_frame("TIT2", title); + } + + // PSD Type 2: Artist (TPE1) - Required + if (!artist.empty()) { + payload += encode_text_frame("TPE1", artist); + } + + // PSD Type 3: Album (TALB) + if (!album.empty()) { + payload += encode_text_frame("TALB", album); + } + + // PSD Type 4: Genre (TCON) + if (!genre.empty()) { + payload += encode_text_frame("TCON", genre); + } + + // PSD Type 5: Comment (COMM) + if (!comment_text.empty() || !comment_short_desc.empty()) { + payload += encode_comment_frame(comment_language, comment_short_desc, comment_text); + } + + // PSD Type 6: Commercial (COMR) + if (!commercial_description.empty() || !commercial_contact_url.empty()) { + payload += encode_commercial_frame(commercial_price, commercial_valid_until, + commercial_contact_url, commercial_received_as, + commercial_seller, commercial_description); + } + + // PSD Type 7: Reference Identifier (UFID) + if (!ufid_owner.empty() && !ufid_identifier.empty()) { + payload += encode_ufid_frame(ufid_owner, ufid_identifier); + } + + // XHDR frame for album art + payload += encode_xhdr_frame(); + int len = payload.length(); out << "ID3"; @@ -148,6 +212,129 @@ std::string psd_encoder_impl::encode_text_frame(const std::string& id, return out.str(); } +std::string psd_encoder_impl::encode_comment_frame(const std::string& language, + const std::string& short_desc, + const std::string& text) +{ + std::stringstream out; + + // COMM frame structure: + // Text encoding: 1 byte (0x00 for ISO-8859-1, 0x01 for Unicode) + // Language: 3 bytes (ISO 639-2) + // Short description: null-terminated string + // Content: text string (not null-terminated) + + std::string lang = language.empty() ? "eng" : language; + if (lang.length() > 3) { + lang = lang.substr(0, 3); + } + while (lang.length() < 3) { + lang += " "; + } + + int len = 1 + 3 + short_desc.length() + 1 + text.length(); + + out << "COMM"; + out << (char)((len >> 24) & 0xff); + out << (char)((len >> 16) & 0xff); + out << (char)((len >> 8) & 0xff); + out << (char)(len & 0xff); + out << (char)0; // Flags byte 1 + out << (char)0; // Flags byte 2 + out << (char)0; // Text encoding (ISO-8859-1) + out << lang; // 3-byte language code + out << short_desc; + out << (char)0; // Null terminator for short description + out << text; + + return out.str(); +} + +std::string psd_encoder_impl::encode_commercial_frame(const std::string& price, + const std::string& valid_until, + const std::string& contact_url, + const std::string& received_as, + const std::string& seller, + const std::string& description) +{ + std::stringstream out; + + // COMR frame structure: + // Text encoding: 1 byte (0x00 for ISO-8859-1) + // Price: null-terminated string + // Valid until: 8 bytes (YYYYMMDD format as string) + // Contact URL: null-terminated string + // Received as: 1 byte + // Seller name: null-terminated string + // Description: null-terminated string + // Picture MIME type: null-terminated string (empty for PSD - binary pictures not supported) + // Seller logo: null-terminated string (empty for PSD - binary pictures not supported) + + int len = 1 + price.length() + 1 + 8 + contact_url.length() + 1 + 1 + + seller.length() + 1 + description.length() + 1 + 1 + 1; + + out << "COMR"; + out << (char)((len >> 24) & 0xff); + out << (char)((len >> 16) & 0xff); + out << (char)((len >> 8) & 0xff); + out << (char)(len & 0xff); + out << (char)0; // Flags byte 1 + out << (char)0; // Flags byte 2 + out << (char)0; // Text encoding (ISO-8859-1) + out << price; + out << (char)0; // Null terminator for price + + // Valid until date (8 bytes, pad with spaces if needed) + std::string valid = valid_until; + if (valid.length() > 8) { + valid = valid.substr(0, 8); + } + while (valid.length() < 8) { + valid += " "; + } + out << valid; + + out << contact_url; + out << (char)0; // Null terminator for contact URL + + // Received as (1 byte - typically 0x00) + out << (char)(received_as.empty() ? 0 : received_as[0]); + + out << seller; + out << (char)0; // Null terminator for seller name + out << description; + out << (char)0; // Null terminator for description + out << (char)0; // Empty picture MIME type (not supported) + out << (char)0; // Empty seller logo (not supported) + + return out.str(); +} + +std::string psd_encoder_impl::encode_ufid_frame(const std::string& owner, + const std::string& identifier) +{ + std::stringstream out; + + // UFID frame structure: + // Owner identifier: null-terminated string (typically a URL) + // Identifier: binary or text data (up to 64 bytes) + + int len = owner.length() + 1 + identifier.length(); + + out << "UFID"; + out << (char)((len >> 24) & 0xff); + out << (char)((len >> 16) & 0xff); + out << (char)((len >> 8) & 0xff); + out << (char)(len & 0xff); + out << (char)0; // Flags byte 1 + out << (char)0; // Flags byte 2 + out << owner; + out << (char)0; // Null terminator for owner + out << identifier; + + return out.str(); +} + std::string psd_encoder_impl::encode_xhdr_frame() { std::stringstream out; @@ -196,13 +383,70 @@ void psd_encoder_impl::set_meta(const pmt::pmt_t& msg) std::string line = meta_buffer.str(); meta_buffer.str(""); + // PSD Type 1: Title (TIT2) if (line.rfind("title", 0) == 0) { - title = line.substr(5, msg_len - 5); - } else if (line.rfind("artist", 0) == 0) { - artist = line.substr(6, msg_len - 6); - } else if (line.rfind("lot", 0) == 0) { + title = line.substr(5); + } + // PSD Type 2: Artist (TPE1) + else if (line.rfind("artist", 0) == 0) { + artist = line.substr(6); + } + // PSD Type 3: Album (TALB) + else if (line.rfind("album", 0) == 0) { + album = line.substr(5); + } + // PSD Type 4: Genre (TCON) + else if (line.rfind("genre", 0) == 0) { + genre = line.substr(5); + } + // PSD Type 5: Comment (COMM) - Language + else if (line.rfind("comment_language", 0) == 0) { + comment_language = line.substr(16); + } + // PSD Type 5: Comment (COMM) - Short description + else if (line.rfind("comment_short", 0) == 0) { + comment_short_desc = line.substr(13); + } + // PSD Type 5: Comment (COMM) - Content/text + else if (line.rfind("comment", 0) == 0) { + comment_text = line.substr(7); + } + // PSD Type 6: Commercial (COMR) - Price + else if (line.rfind("commercial_price", 0) == 0) { + commercial_price = line.substr(16); + } + // PSD Type 6: Commercial (COMR) - Valid until + else if (line.rfind("commercial_valid", 0) == 0) { + commercial_valid_until = line.substr(16); + } + // PSD Type 6: Commercial (COMR) - Contact URL + else if (line.rfind("commercial_url", 0) == 0) { + commercial_contact_url = line.substr(14); + } + // PSD Type 6: Commercial (COMR) - Received as + else if (line.rfind("commercial_received", 0) == 0) { + commercial_received_as = line.substr(19); + } + // PSD Type 6: Commercial (COMR) - Seller + else if (line.rfind("commercial_seller", 0) == 0) { + commercial_seller = line.substr(17); + } + // PSD Type 6: Commercial (COMR) - Description + else if (line.rfind("commercial_desc", 0) == 0) { + commercial_description = line.substr(15); + } + // PSD Type 7: Reference Identifier (UFID) - Owner + else if (line.rfind("ufid_owner", 0) == 0) { + ufid_owner = line.substr(10); + } + // PSD Type 7: Reference Identifier (UFID) - Identifier + else if (line.rfind("ufid_id", 0) == 0) { + ufid_identifier = line.substr(7); + } + // Album art LOT + else if (line.rfind("lot", 0) == 0) { try { - lot = std::stoi(line.substr(3, msg_len - 3)); + lot = std::stoi(line.substr(3)); } catch (std::invalid_argument& err) { // ignore } diff --git a/lib/psd_encoder_impl.h b/lib/psd_encoder_impl.h index eaaf957..e3e12f1 100644 --- a/lib/psd_encoder_impl.h +++ b/lib/psd_encoder_impl.h @@ -24,6 +24,19 @@ class psd_encoder_impl : public psd_encoder int prog_num; std::string title; std::string artist; + std::string album; + std::string genre; + std::string comment_language; + std::string comment_short_desc; + std::string comment_text; + std::string commercial_price; + std::string commercial_valid_until; + std::string commercial_contact_url; + std::string commercial_received_as; + std::string commercial_seller; + std::string commercial_description; + std::string ufid_owner; + std::string ufid_identifier; int lot; int bytes_per_frame; uint16_t seq_num; @@ -35,6 +48,9 @@ class psd_encoder_impl : public psd_encoder std::string encode_psd_packet(uint8_t dtpf, uint16_t port, uint16_t seq); std::string encode_id3(); std::string encode_text_frame(const std::string& id, const std::string& data); + std::string encode_comment_frame(const std::string& language, const std::string& short_desc, const std::string& text); + std::string encode_commercial_frame(const std::string& price, const std::string& valid_until, const std::string& contact_url, const std::string& received_as, const std::string& seller, const std::string& description); + std::string encode_ufid_frame(const std::string& owner, const std::string& identifier); std::string encode_xhdr_frame(); void handle_clock(pmt::pmt_t msg); @@ -44,6 +60,11 @@ class psd_encoder_impl : public psd_encoder psd_encoder_impl(const int prog_num, const std::string& title, const std::string& artist, + const std::string& album, + const std::string& genre, + const std::string& comment_language, + const std::string& comment_short_desc, + const std::string& comment_text, const int bytes_per_frame = 0); ~psd_encoder_impl(); diff --git a/lib/sis_encoder_impl.cc b/lib/sis_encoder_impl.cc index 36c907c..79d2183 100644 --- a/lib/sis_encoder_impl.cc +++ b/lib/sis_encoder_impl.cc @@ -32,7 +32,15 @@ sis_encoder::sptr sis_encoder::make(const pids_mode mode, float longitude, float altitude, const std::string& country_code, - const unsigned int fcc_facility_id) + const unsigned int fcc_facility_id, + const std::vector data_channels, + const std::string& exciter_manufacturer_id, + const std::string& importer_manufacturer_id, + const std::vector exciter_core_version, + const std::vector exciter_mfr_version, + const std::vector importer_core_version, + const std::vector importer_mfr_version, + const unsigned int importer_configuration_number) { return gnuradio::get_initial_sptr(new sis_encoder_impl(mode, short_name, @@ -46,7 +54,15 @@ sis_encoder::sptr sis_encoder::make(const pids_mode mode, longitude, altitude, country_code, - fcc_facility_id)); + fcc_facility_id, + data_channels, + exciter_manufacturer_id, + importer_manufacturer_id, + exciter_core_version, + exciter_mfr_version, + importer_core_version, + importer_mfr_version, + importer_configuration_number)); } @@ -65,7 +81,15 @@ sis_encoder_impl::sis_encoder_impl(const pids_mode mode, const float longitude, const float altitude, const std::string& country_code, - const unsigned int fcc_facility_id) + const unsigned int fcc_facility_id, + const std::vector data_channels, + const std::string& exciter_manufacturer_id, + const std::string& importer_manufacturer_id, + const std::vector exciter_core_version, + const std::vector exciter_mfr_version, + const std::vector importer_core_version, + const std::vector importer_mfr_version, + const unsigned int importer_configuration_number) : gr::sync_block("sis_encoder", gr::io_signature::make(0, 0, 0), gr::io_signature::make(1, 1, sizeof(unsigned char) * SIS_BITS)) @@ -116,6 +140,7 @@ sis_encoder_impl::sis_encoder_impl(const pids_mode mode, this->program_types = program_types; this->data_types = data_types; this->data_mime_types = data_mime_types; + this->data_channels = data_channels; this->slogan = slogan; this->message = message; this->emergency_alert = ""; @@ -130,17 +155,21 @@ sis_encoder_impl::sis_encoder_impl(const pids_mode mode, dst_sched = dst_schedule::US_CANADA; dst_local = true; dst_regional = true; - exciter_manufacturer_id = "CS"; - exciter_core_version = { 1, 0, 0, 0 }; + this->exciter_manufacturer_id = exciter_manufacturer_id; + this->exciter_core_version = exciter_core_version; + while (this->exciter_core_version.size() < 4) this->exciter_core_version.push_back(0); exciter_core_status = 0; - exciter_manufacturer_version = { 1, 0, 0, 0 }; + this->exciter_manufacturer_version = exciter_mfr_version; + while (this->exciter_manufacturer_version.size() < 4) this->exciter_manufacturer_version.push_back(0); exciter_manufacturer_status = 0; - importer_manufacturer_id = "CS"; - importer_core_version = { 1, 0, 0, 0 }; + this->importer_manufacturer_id = importer_manufacturer_id; + this->importer_core_version = importer_core_version; + while (this->importer_core_version.size() < 4) this->importer_core_version.push_back(0); importer_core_status = 0; - importer_manufacturer_version = { 1, 0, 0, 0 }; + this->importer_manufacturer_version = importer_mfr_version; + while (this->importer_manufacturer_version.size() < 4) this->importer_manufacturer_version.push_back(0); importer_manufacturer_status = 0; - importer_configuration_number = 0; + this->importer_configuration_number = importer_configuration_number; long_name_current_frame = 0; long_name_seq = 0; @@ -482,10 +511,13 @@ void sis_encoder_impl::write_station_location() write_int(static_cast(msg_id::STATION_LOCATION), 4); write_bit(location_high); if (location_high) { - write_int(std::round(latitude * 8192), 22); - write_int(altitude_int >> 4, 4); + write_int(static_cast(std::round(latitude * 8192)), 22); + // Decoder expects bits to reconstruct altitude << 4 + // High message gets upper 4 bits of the 8-bit value (bits 7-4) + write_int((altitude_int >> 4) & 0xf, 4); } else { - write_int(std::round(longitude * 8192), 22); + write_int(static_cast(std::round(longitude * 8192)), 22); + // Low message gets lower 4 bits of the 8-bit value (bits 3-0) write_int(altitude_int & 0xf, 4); } @@ -538,14 +570,18 @@ void sis_encoder_impl::write_service_information_message() { write_int(static_cast(msg_id::SERVICE_INFORMATION_MESSAGE), 4); + unsigned int total_services = program_types.size() + data_types.size() + data_channels.size(); + if (current_service < program_types.size()) { + // Audio service descriptor write_int(static_cast(service_category::AUDIO), 2); write_bit(static_cast(access::PUBLIC)); write_int(current_service, 6); write_int(static_cast(program_types[current_service]), 8); write_int(0, 5); // reserved write_int(static_cast(sound_experience::NONE), 5); - } else { + } else if (current_service < program_types.size() + data_types.size()) { + // Legacy data service descriptor (e.g. emergency alerts) unsigned int data_index = current_service - program_types.size(); write_int(static_cast(service_category::DATA), 2); @@ -553,9 +589,19 @@ void sis_encoder_impl::write_service_information_message() write_int(static_cast(data_types[data_index]), 9); write_int(0, 3); // reserved write_int(data_mime_types[data_index], 12); + } else { + // Data-only channel service descriptor (from data_channels config) + unsigned int dc_index = current_service - program_types.size() - data_types.size(); + const auto& dc = data_channels[dc_index]; + + write_int(static_cast(service_category::DATA), 2); + write_bit(static_cast(access::PUBLIC)); + write_int(dc.sdt, 9); + write_int(0, 3); // reserved + write_int(dc.mime & 0xFFF, 12); // 12 LSBs of MIME hash } - current_service = (current_service + 1) % (program_types.size() + data_types.size()); + current_service = (current_service + 1) % total_services; } void sis_encoder_impl::write_sis_parameter_message() @@ -735,9 +781,9 @@ void sis_encoder_impl::write_emergency_alert() std::string sis_encoder_impl::generate_sig() { std::stringstream out; - unsigned int program_id = 0; unsigned int port = 0x1000; + // Emit AUDIO services for each program (album art + station logo per program) for (unsigned int program_id = 0; program_id < program_names.size(); program_id++) { unsigned int component_id = 0; @@ -762,6 +808,33 @@ std::string sis_encoder_impl::generate_sig() 0x32 + program_id); } + // Emit DATA services for each configured data-only channel. + // Each data channel gets its own DATA service with one LOT data component. + // This is the critical piece that tells the decoder to reassemble LOT + // objects arriving on these ports. + unsigned int data_service_number = program_names.size() + 1; + for (unsigned int i = 0; i < data_channels.size(); i++) { + const auto& dc = data_channels[i]; + unsigned int component_id = 0; + + // Use the custom name if provided, otherwise generate a default name + std::string svc_name = dc.name.empty() ? ("Data" + std::to_string(i + 1)) : dc.name; + + out << generate_sig_service( + sig_service_type::DATA, data_service_number++, svc_name); + + // Determine the MIME hash to use in the SIG component. + // If mime is 0, default to TEXT; otherwise use what was configured. + mime_hash mh = static_cast(dc.mime ? dc.mime : static_cast(mime_hash::TEXT)); + + out << generate_sig_data_component(component_id++, + dc.port, + static_cast(dc.sdt), + data_type::LOT, + mh, + dc.lot_id); + } + return out.str(); } @@ -886,7 +959,217 @@ void sis_encoder_impl::handle_command(pmt::pmt_t msg) auto command = command_line.substr(0, command_line.find('|')); - if (command == "clear_alert") { + if (command == "set_param") { + // set_param|| + // Sets SIS Parameter Message index directly. + // Index 0-12 per Table 4-14 of SY_IDD_1020s. + // Value is interpreted per index: + // 0: Leap Second Offset -- value = pending<<8|current (hex or dec) + // 1: Leap Second ALFN LSB -- value = 16-bit LSB + // 2: Leap Second ALFN MSB -- value = 16-bit MSB + // 3: Local Time Data -- value = utc_offset (signed minutes from UTC) + // 4: Exciter Manufacturer ID -- value = two ISO 8859-1 chars (e.g. "CS") + // 5: Exciter Core Version 1.2.3 -- value = "L1.L2.L3" + // 6: Exciter Mfr Version 1.2.3 -- value = "L1.L2.L3" + // 7: Exciter Version 4 & Status -- value = "core4.mfr4.core_status.mfr_status" + // 8: Importer Manufacturer ID -- value = two ISO 8859-1 chars + // 9: Importer Core Version 1.2.3 -- value = "L1.L2.L3" + // 10: Importer Mfr Version 1.2.3 -- value = "L1.L2.L3" + // 11: Importer Version 4 & Status -- value = "core4.mfr4.core_status.mfr_status" + // 12: Importer Configuration Number -- value = 0-65535 + auto rest = command_line.substr(10); // after "set_param|" + auto sep1 = rest.find('|'); + if (sep1 != std::string::npos) { + int idx = std::stoi(rest.substr(0, sep1)); + std::string val = rest.substr(sep1 + 1); + // Remove trailing newline if present + if (!val.empty() && val.back() == '\n') val.pop_back(); + + switch (idx) { + case 0: { + // Leap Second Offset: pending|current or single hex value + auto pipe = val.find('|'); + if (pipe != std::string::npos) { + pending_leap_second_offset = std::stoi(val.substr(0, pipe)); + current_leap_second_offset = std::stoi(val.substr(pipe + 1)); + } else { + unsigned int v = std::stoul(val, nullptr, 0); + pending_leap_second_offset = (v >> 8) & 0xFF; + current_leap_second_offset = v & 0xFF; + } + d_logger->info("set leap second offset: pending=" + + std::to_string(pending_leap_second_offset) + + " current=" + std::to_string(current_leap_second_offset)); + break; + } + case 1: { + // Leap Second ALFN LSB + unsigned int lsb = std::stoul(val, nullptr, 0); + leap_second_alfn = (leap_second_alfn & 0xFFFF0000) | (lsb & 0xFFFF); + d_logger->info("set leap second ALFN LSB: " + std::to_string(lsb)); + break; + } + case 2: { + // Leap Second ALFN MSB + unsigned int msb = std::stoul(val, nullptr, 0); + leap_second_alfn = (leap_second_alfn & 0x0000FFFF) | ((msb & 0xFFFF) << 16); + d_logger->info("set leap second ALFN MSB: " + std::to_string(msb)); + break; + } + case 3: { + // Local Time Data: utc_offset in minutes (signed) + // Optionally: utc_offset|dst_sched|dst_local|dst_regional + auto parts_str = val; + std::vector parts_vec; + size_t pos2 = 0; + while ((pos2 = parts_str.find('|')) != std::string::npos) { + parts_vec.push_back(parts_str.substr(0, pos2)); + parts_str.erase(0, pos2 + 1); + } + parts_vec.push_back(parts_str); + + utc_offset = std::stoi(parts_vec[0]); + if (parts_vec.size() > 1) { + int ds = std::stoi(parts_vec[1]); + dst_sched = static_cast(ds); + } + if (parts_vec.size() > 2) dst_local = (parts_vec[2] == "1"); + if (parts_vec.size() > 3) dst_regional = (parts_vec[3] == "1"); + d_logger->info("set local time: utc_offset=" + std::to_string(utc_offset)); + break; + } + case 4: { + // Exciter Manufacturer ID (2 chars) + if (val.length() >= 2) { + exciter_manufacturer_id = val.substr(0, 2); + d_logger->info("set exciter manufacturer ID: " + exciter_manufacturer_id); + } else { + d_logger->error("exciter manufacturer ID must be 2 characters"); + } + break; + } + case 5: { + // Exciter Core Version L1.L2.L3 + std::vector levels; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + levels.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + levels.push_back(tmp); + for (int lv = 0; lv < 3 && lv < (int)levels.size(); lv++) { + exciter_core_version[lv] = std::stoi(levels[lv]); + } + d_logger->info("set exciter core version"); + break; + } + case 6: { + // Exciter Mfr Version L1.L2.L3 + std::vector levels; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + levels.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + levels.push_back(tmp); + for (int lv = 0; lv < 3 && lv < (int)levels.size(); lv++) { + exciter_manufacturer_version[lv] = std::stoi(levels[lv]); + } + d_logger->info("set exciter manufacturer version"); + break; + } + case 7: { + // Exciter Version 4 & Status: core4.mfr4.core_status.mfr_status + std::vector parts_v; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + parts_v.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + parts_v.push_back(tmp); + if (parts_v.size() > 0) exciter_core_version[3] = std::stoi(parts_v[0]); + if (parts_v.size() > 1) exciter_manufacturer_version[3] = std::stoi(parts_v[1]); + if (parts_v.size() > 2) exciter_core_status = std::stoi(parts_v[2]); + if (parts_v.size() > 3) exciter_manufacturer_status = std::stoi(parts_v[3]); + d_logger->info("set exciter version 4 and status"); + break; + } + case 8: { + // Importer Manufacturer ID (2 chars) + if (val.length() >= 2) { + importer_manufacturer_id = val.substr(0, 2); + d_logger->info("set importer manufacturer ID: " + importer_manufacturer_id); + } else { + d_logger->error("importer manufacturer ID must be 2 characters"); + } + break; + } + case 9: { + // Importer Core Version L1.L2.L3 + std::vector levels; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + levels.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + levels.push_back(tmp); + for (int lv = 0; lv < 3 && lv < (int)levels.size(); lv++) { + importer_core_version[lv] = std::stoi(levels[lv]); + } + d_logger->info("set importer core version"); + break; + } + case 10: { + // Importer Mfr Version L1.L2.L3 + std::vector levels; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + levels.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + levels.push_back(tmp); + for (int lv = 0; lv < 3 && lv < (int)levels.size(); lv++) { + importer_manufacturer_version[lv] = std::stoi(levels[lv]); + } + d_logger->info("set importer manufacturer version"); + break; + } + case 11: { + // Importer Version 4 & Status: core4.mfr4.core_status.mfr_status + std::vector parts_v; + size_t pos2 = 0; + std::string tmp = val; + while ((pos2 = tmp.find('.')) != std::string::npos) { + parts_v.push_back(tmp.substr(0, pos2)); + tmp.erase(0, pos2 + 1); + } + parts_v.push_back(tmp); + if (parts_v.size() > 0) importer_core_version[3] = std::stoi(parts_v[0]); + if (parts_v.size() > 1) importer_manufacturer_version[3] = std::stoi(parts_v[1]); + if (parts_v.size() > 2) importer_core_status = std::stoi(parts_v[2]); + if (parts_v.size() > 3) importer_manufacturer_status = std::stoi(parts_v[3]); + d_logger->info("set importer version 4 and status"); + break; + } + case 12: { + // Importer Configuration Number + importer_configuration_number = std::stoul(val, nullptr, 0); + d_logger->info("set importer config number: " + std::to_string(importer_configuration_number)); + break; + } + default: + d_logger->error("set_param: invalid index " + std::to_string(idx) + " (valid: 0-12)"); + break; + } + } else { + d_logger->error("set_param: missing index or value"); + } + } else if (command == "clear_alert") { this->emergency_alert = ""; this->emergency_alert_cnt_len = 0; d_logger->info("clearing emergency alert"); diff --git a/lib/sis_encoder_impl.h b/lib/sis_encoder_impl.h index c96c292..80bc7e8 100644 --- a/lib/sis_encoder_impl.h +++ b/lib/sis_encoder_impl.h @@ -287,6 +287,7 @@ class sis_encoder_impl : public sis_encoder std::vector program_types; std::vector data_types; std::vector data_mime_types; + std::vector data_channels; unsigned int current_service; unsigned int current_parameter; @@ -346,7 +347,15 @@ class sis_encoder_impl : public sis_encoder const float longitude = -74.0445, const float altitude = 93.0, const std::string& country_code = "US", - const unsigned int fcc_facility_id = 0); + const unsigned int fcc_facility_id = 0, + const std::vector data_channels = {}, + const std::string& exciter_manufacturer_id = "CS", + const std::string& importer_manufacturer_id = "CS", + const std::vector exciter_core_version = { 1, 0, 0, 0 }, + const std::vector exciter_mfr_version = { 1, 0, 0, 0 }, + const std::vector importer_core_version = { 1, 0, 0, 0 }, + const std::vector importer_mfr_version = { 1, 0, 0, 0 }, + const unsigned int importer_configuration_number = 0); ~sis_encoder_impl(); // Where all the action really happens diff --git a/python/bindings/am_pulse_shaper_python.cc b/python/bindings/am_pulse_shaper_python.cc index 855faee..c914643 100644 --- a/python/bindings/am_pulse_shaper_python.cc +++ b/python/bindings/am_pulse_shaper_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(1) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(am_pulse_shaper.h) */ -/* BINDTOOL_HEADER_FILE_HASH(63976a7ef63bf73db3b965a0f491ce28) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include diff --git a/python/bindings/hdc_encoder_python.cc b/python/bindings/hdc_encoder_python.cc index 9a3625a..8bcd1b4 100644 --- a/python/bindings/hdc_encoder_python.cc +++ b/python/bindings/hdc_encoder_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(hdc_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(189e984318a00d48a752b2e0010ef257) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include @@ -39,6 +39,8 @@ void bind_hdc_encoder(py::module& m) .def(py::init(&hdc_encoder::make), py::arg("channels") = 2, py::arg("bitrate") = 64000, + py::arg("use_parametric_stereo") = false, + py::arg("tx_digital_gain") = 0, D(hdc_encoder,make) ) diff --git a/python/bindings/l1_am_encoder_python.cc b/python/bindings/l1_am_encoder_python.cc index e025af7..519ccc6 100644 --- a/python/bindings/l1_am_encoder_python.cc +++ b/python/bindings/l1_am_encoder_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(l1_am_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(96b80cbeab3f9da9b4a52abeeb3986fb) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include diff --git a/python/bindings/l1_fm_encoder_python.cc b/python/bindings/l1_fm_encoder_python.cc index e436299..8975f7e 100644 --- a/python/bindings/l1_fm_encoder_python.cc +++ b/python/bindings/l1_fm_encoder_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(l1_fm_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(28b4c2c78049aedef3c35cd91abd7142) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include diff --git a/python/bindings/l2_encoder_python.cc b/python/bindings/l2_encoder_python.cc index 29c978e..76a5d80 100644 --- a/python/bindings/l2_encoder_python.cc +++ b/python/bindings/l2_encoder_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(l2_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(e09b3f444566cbfeb2a2798367382fa7) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include @@ -47,6 +47,8 @@ void bind_l2_encoder(py::module& m) py::arg("size"), py::arg("data_bytes") = 0, py::arg("blend_control") = ::gr::nrsc5::blend::ENABLE, + py::arg("tx_digital_gain") = 0, + py::arg("debug_logs") = false, py::arg("ccc_width") = 24, D(l2_encoder,make) ) diff --git a/python/bindings/psd_encoder_python.cc b/python/bindings/psd_encoder_python.cc index bef2b96..f7bf08d 100644 --- a/python/bindings/psd_encoder_python.cc +++ b/python/bindings/psd_encoder_python.cc @@ -14,7 +14,7 @@ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(psd_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(aa3c914cdda9eeb1f25aa5201cb775bd) */ +/* BINDTOOL_HEADER_FILE_HASH(0) */ /***********************************************************************************/ #include @@ -38,8 +38,13 @@ void bind_psd_encoder(py::module& m) .def(py::init(&psd_encoder::make), py::arg("prog_num"), - py::arg("title"), - py::arg("artist"), + py::arg("title") = "", + py::arg("artist") = "", + py::arg("album") = "", + py::arg("genre") = "", + py::arg("comment_language") = "", + py::arg("comment_short_desc") = "", + py::arg("comment_text") = "", py::arg("bytes_per_frame") = 0, D(psd_encoder,make) ) diff --git a/python/bindings/sis_encoder_python.cc b/python/bindings/sis_encoder_python.cc index a1cb91a..3522f09 100644 --- a/python/bindings/sis_encoder_python.cc +++ b/python/bindings/sis_encoder_python.cc @@ -7,15 +7,15 @@ * */ -/***********************************************************************************/ +/***/ /* This file is automatically generated using bindtool and can be manually edited */ /* The following lines can be configured to regenerate this file during cmake */ /* If manual edits are made, the following tags should be modified accordingly. */ /* BINDTOOL_GEN_AUTOMATIC(0) */ /* BINDTOOL_USE_PYGCCXML(0) */ /* BINDTOOL_HEADER_FILE(sis_encoder.h) */ -/* BINDTOOL_HEADER_FILE_HASH(38686eef3cff9a3dab3147ef3ec3001d) */ -/***********************************************************************************/ +/* BINDTOOL_HEADER_FILE_HASH(0) */ +/***/ #include #include @@ -93,6 +93,26 @@ void bind_sis_encoder(py::module& m) .value("AUDIO_RELATED_DATA", ::gr::nrsc5::service_data_type::AUDIO_RELATED_DATA) .export_values(); + py::enum_<::gr::nrsc5::mime_hash>(m, "mime_hash") + .value("PRIMARY_IMAGE", ::gr::nrsc5::mime_hash::PRIMARY_IMAGE) + .value("STATION_LOGO", ::gr::nrsc5::mime_hash::STATION_LOGO) + .value("HDC", ::gr::nrsc5::mime_hash::HDC) + .value("TEXT", ::gr::nrsc5::mime_hash::TEXT) + .value("JPEG", ::gr::nrsc5::mime_hash::JPEG) + .value("PNG", ::gr::nrsc5::mime_hash::PNG) + .value("GIF", ::gr::nrsc5::mime_hash::GIF) + .export_values(); + + py::class_<::gr::nrsc5::data_channel_config>(m, "data_channel_config") + .def(py::init<>()) + .def(py::init(), + py::arg("port"), py::arg("lot_id"), py::arg("sdt"), py::arg("mime"), py::arg("name") = "") + .def_readwrite("port", &::gr::nrsc5::data_channel_config::port) + .def_readwrite("lot_id", &::gr::nrsc5::data_channel_config::lot_id) + .def_readwrite("sdt", &::gr::nrsc5::data_channel_config::sdt) + .def_readwrite("mime", &::gr::nrsc5::data_channel_config::mime) + .def_readwrite("name", &::gr::nrsc5::data_channel_config::name); + py::class_>(m, "sis_encoder", D(sis_encoder)) @@ -110,9 +130,17 @@ void bind_sis_encoder(py::module& m) py::arg("altitude") = 93.0, py::arg("country_code") = "US", py::arg("fcc_facility_id") = 0, + py::arg("data_channels") = std::vector<::gr::nrsc5::data_channel_config>({}), + py::arg("exciter_manufacturer_id") = "CS", + py::arg("importer_manufacturer_id") = "CS", + py::arg("exciter_core_version") = std::vector({ 1, 0, 0, 0 }), + py::arg("exciter_mfr_version") = std::vector({ 1, 0, 0, 0 }), + py::arg("importer_core_version") = std::vector({ 1, 0, 0, 0 }), + py::arg("importer_mfr_version") = std::vector({ 1, 0, 0, 0 }), + py::arg("importer_configuration_number") = 0, D(sis_encoder,make) ) - + @@ -126,7 +154,3 @@ void bind_sis_encoder(py::module& m) - - - - diff --git a/python/lot_encoder.py b/python/lot_encoder.py index dd05f82..c01d3ad 100644 --- a/python/lot_encoder.py +++ b/python/lot_encoder.py @@ -9,6 +9,7 @@ import os import pmt import struct +import re from datetime import datetime, timedelta, timezone from gnuradio import gr @@ -18,12 +19,15 @@ class lot_encoder(gr.basic_block): PNG_START = bytes.fromhex("89504E470D0A1A0A") JPEG_START = bytes.fromhex("FFD8") JPEG_END = bytes.fromhex("FFD9") + GIF87A_START = bytes.fromhex("474946383761") # "GIF87a" + GIF89A_START = bytes.fromhex("474946383961") # "GIF89a" MIMEHASH_PNG = 0x4F328CA0 MIMEHASH_JPEG = 0x1E653E9C MIMEHASH_TEXT = 0xBB492AAC + MIMEHASH_GIF = 0x87A4FD95 - def __init__(self, filename="", lot_id=0, port=0x1001): + def __init__(self, filename="", lot_id=0, port=0x1001, expiry="45 minutes"): gr.sync_block.__init__( self, name='LOT encoder', @@ -39,13 +43,93 @@ def __init__(self, filename="", lot_id=0, port=0x1001): self.set_msg_handler(pmt.intern("ready"), self.handle_notify) self.port = port - with open(filename, "rb") as f: - self.prepare_file(os.path.basename(filename), f.read(), lot_id) - + self.default_expiry = expiry + self.current_part_index = 0 self.command_buffer = bytes() + # Initialize logger first before using it self.my_log = gr.logger(self.alias()) + # Now prepare the file (which may use the logger) + with open(filename, "rb") as f: + self.prepare_file(os.path.basename(filename), f.read(), lot_id, expiry) + + def parse_relative_time(self, time_str): + """ + Parse relative time strings like '15 minutes', '2 weeks', '1 year', etc. + Returns a timedelta object. + """ + time_str = time_str.strip().lower() + + # Pattern: number + optional space + unit + pattern = r'^(\d+(?:\.\d+)?)\s*(ms|mil|milliseconds?|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|w|wk|weeks?|mn|month|months?|y|yr|year|years?)$' + match = re.match(pattern, time_str) + + if not match: + raise ValueError(f"Invalid relative time format: {time_str}") + + value = float(match.group(1)) + unit = match.group(2) + + # Convert to timedelta + if unit in ('ms', 'mil', 'millisecond', 'milliseconds'): + return timedelta(milliseconds=value) + elif unit in ('s', 'sec', 'second', 'seconds'): + return timedelta(seconds=value) + elif unit in ('m', 'min', 'minute', 'minutes'): + return timedelta(minutes=value) + elif unit in ('h', 'hr', 'hour', 'hours'): + return timedelta(hours=value) + elif unit in ('d', 'day', 'days'): + return timedelta(days=value) + elif unit in ('w', 'wk', 'week', 'weeks'): + return timedelta(weeks=value) + elif unit in ('mn', 'month', 'months'): + return timedelta(days=value * 30.44) # Average month length + elif unit in ('y', 'yr', 'year', 'years'): + return timedelta(days=value * 365.25) # Account for leap years + else: + raise ValueError(f"Unknown time unit: {unit}") + + def parse_expiry(self, expiry_str): + """ + Parse expiry date/time. Supports: + - TZ format: ISO 8601 datetime with timezone (e.g., '2026-12-31T23:59:59+00:00') + - Relative time: '15 minutes', '2 weeks', '1 year', etc. + Returns a datetime object in UTC. + """ + if not expiry_str or expiry_str.strip() == "": + # Default to 45 minutes + return datetime.now(timezone.utc) + timedelta(minutes=45) + + expiry_str = expiry_str.strip() + + # Try parsing as ISO 8601 / TZ format first + try: + # Try various ISO 8601 formats + for fmt in ['%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S.%f%z', + '%Y-%m-%d %H:%M:%S%z', '%Y-%m-%dT%H:%M:%SZ']: + try: + if fmt.endswith('Z'): + # Handle 'Z' suffix for UTC + dt = datetime.strptime(expiry_str, fmt).replace(tzinfo=timezone.utc) + else: + dt = datetime.strptime(expiry_str, fmt) + return dt.astimezone(timezone.utc) + except ValueError: + continue + except Exception: + pass + + # Try parsing as relative time + try: + delta = self.parse_relative_time(expiry_str) + return datetime.now(timezone.utc) + delta + except ValueError as e: + self.my_log.error(f"Failed to parse expiry time '{expiry_str}': {e}") + # Default to 45 minutes + return datetime.now(timezone.utc) + timedelta(minutes=45) + def handle_new_file(self, msg): data = bytes(pmt.to_python(msg)[1]) self.command_buffer += data @@ -55,19 +139,21 @@ def handle_new_file(self, msg): command = self.command_buffer[:command_end] parts = command.split(b"|") - if (parts[0] == b"streamfile") and (len(parts) == 4): + if (parts[0] == b"streamfile") and (len(parts) >= 4): lot_id = int(parts[1]) size = int(parts[2]) filename = parts[3] + expiry = parts[4].decode('utf-8') if len(parts) >= 5 else self.default_expiry if len(self.command_buffer) >= command_end + 1 + size: filedata = self.command_buffer[command_end + 1:command_end + 1 + size] - self.prepare_file(filename, filedata, lot_id) + self.prepare_file(filename, filedata, lot_id, expiry) self.command_buffer = self.command_buffer[command_end + 1 + size:] - elif (parts[0] == b"file") and (len(parts) == 3): + elif (parts[0] == b"file") and (len(parts) >= 3): lot_id = int(parts[1]) filename = parts[2] + expiry = parts[3].decode('utf-8') if len(parts) >= 4 else self.default_expiry with open(filename, "rb") as f: - self.prepare_file(os.path.basename(filename), f.read(), lot_id) + self.prepare_file(os.path.basename(filename), f.read(), lot_id, expiry) self.command_buffer = self.command_buffer[command_end + 1:] else: self.my_log.warn(f"Invalid command: {command}") @@ -78,10 +164,23 @@ def handle_notify(self, msg): if port == self.port: self.send() - def prepare_file(self, filename, data, lot_id): + def prepare_file(self, filename, data, lot_id, expiry_str=None): if isinstance(filename, str): filename = filename.encode() + # Parse expiry time + if expiry_str is None: + expiry_str = self.default_expiry + + dt = self.parse_expiry(expiry_str) + + # Encode expiry in HD Radio format: YYYYMMMMDDDDDHHHHHHMMMMMM (26 bits) + # Year: 12 bits (0-4095), Month: 4 bits (1-12), Day: 5 bits (1-31), + # Hour: 5 bits (0-23), Minute: 6 bits (0-59), Second: not included + expiry_encoded = (dt.year << 20) | (dt.month << 16) | (dt.day << 11) | (dt.hour << 6) | dt.minute + + self.my_log.info(f"LOT {lot_id}: Expiry set to {dt.isoformat()} (encoded: 0x{expiry_encoded:08x})") + parts = [] for offset in range(0, len(data), 256): @@ -99,40 +198,58 @@ def prepare_file(self, filename, data, lot_id): if seq == 0: version = 1 - dt = datetime.now(timezone.utc) + timedelta(days=365) - expiry = (dt.year << 20) | (dt.month << 16) | (dt.day << 11) | (dt.hour << 6) | dt.minute - size = len(data) if data.startswith(self.PNG_START): mime = self.MIMEHASH_PNG elif data.startswith(self.JPEG_START) and data.endswith(self.JPEG_END): mime = self.MIMEHASH_JPEG + elif data.startswith(self.GIF87A_START) or data.startswith(self.GIF89A_START): + mime = self.MIMEHASH_GIF elif filename.lower().endswith(b".png"): mime = self.MIMEHASH_PNG elif filename.lower().endswith(b".jpg") or filename.lower().endswith(b".jpeg"): mime = self.MIMEHASH_JPEG + elif filename.lower().endswith(b".gif"): + mime = self.MIMEHASH_GIF elif filename.lower().endswith(b".txt"): mime = self.MIMEHASH_TEXT else: - raise ValueError("Unsupported file type. Supported types: PNG, JPG, TXT.") + raise ValueError("Unsupported file type. Supported types: PNG, JPG, GIF, TXT.") - header += struct.pack("= len(self.parts): + self.my_log.info(f"Completed sending LOT {self.lot_id}: sent {packets_sent} packets in this burst") + self.current_part_index = 0