From b143821fd5ebeb069a6740ba5e1f3b50018ba3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oona=20R=C3=A4is=C3=A4nen?= Date: Mon, 24 Jun 2024 22:55:00 +0300 Subject: [PATCH] More tests * Test error detection and correction etc. * Run tests on all CI platforms --- .github/workflows/build.yml | 27 ++++- CONTRIBUTING.md | 16 ++- README.md | 39 +++---- debian/install | 2 +- src/redsea.cc | 2 +- test/test_helpers.h | 67 +++++++---- test/unit.cc | 216 ++++++++++++++++++++++++++++++------ 7 files changed, 280 insertions(+), 89 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 884c06b..4c0912a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,8 @@ name: build on: push: - branches: [ master ] + branches: [ master, dev ] + tags: [ 'v*' ] pull_request: branches: [ master ] @@ -20,8 +21,24 @@ jobs: run: meson setup -Dwerror=true build - name: compile run: cd build && meson compile + - name: test + run: cd build && meson test + + build-deb: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install Debian packaging tools + run: sudo apt install build-essential devscripts debhelper equivs + - name: Install Build-Depends + run: sudo mk-build-deps --install --tool 'apt-get --yes' + - name: Build .deb + run: debuild -us -uc + - name: Install .deb + run: sudo dpkg -i redsea_*.deb - macos-build-and-test: + macos-build: runs-on: macos-latest steps: @@ -66,6 +83,9 @@ jobs: shell: msys2 {0} run: | meson setup -Dwerror=true build && cd build && meson compile + - name: test + shell: msys2 {0} + run: cd build && meson test - name: Package into distrib shell: msys2 {0} run: >- @@ -118,3 +138,6 @@ jobs: - name: Build redsea shell: C:\cygwin\bin\bash.exe -eo pipefail '{0}' run: meson setup -Dwerror=true build && cd build && meson compile + - name: test + shell: C:\cygwin\bin\bash.exe -eo pipefail '{0}' + run: cd build && meson test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 247e5c4..41d662f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ # Contributing guidelines -## Editing the wiki +## Edit the wiki It would be nice to have accessible, up-to-date documentation in the [wiki](https://github.com/windytan/redsea/wiki). It can be edited by -anyone, and I invite you to improve it. +anyone, and you're invited to improve it. -## Creating an issue +## Create an issue [Bug reports](https://github.com/windytan/redsea/issues) are very useful for the development of redsea. @@ -21,11 +21,17 @@ Some guidelines for making good bug reports: * Did you compile the libraries (liquid-dsp, sndfile) yourself or are they from package repositories? * If there is an error message, please include the error message verbatim in - the bug report. If it is very long, consider putting it in a file or gist - instead. + the bug report. If it is very long, consider putting it in a file or + [gist](https://gist.github.com/) instead. * Be sure to check back with GitHub afterwards to see if I've asked any clarifying questions. I may not have access to an environment similar to yours and can't necessarily reproduce the bug myself, and that's why I may have many questions at first. * If you fixed the problem yourself, it would be helpful to hear your solution! It's possible that others will also run into similar problems. +* Please be patient with it; Redsea is a single-maintainer hobby project. + +## Join the discussion + +We have a [discussion section](https://github.com/windytan/redsea/discussions) +on GitHub for questions and brainstorming. diff --git a/README.md b/README.md index d732158..b93469d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ redsea is a lightweight command-line FM-RDS decoder that supports many [RDS feat [![release](https://img.shields.io/github/release/windytan/redsea.svg)](https://github.com/windytan/redsea/releases/latest) ![build](https://github.com/windytan/redsea/workflows/build/badge.svg) -Its terminal output is [line-delimited JSON](https://jsonlines.org/) where +It prints [newline-delimited JSON](https://jsonlines.org/) where each line corresponds to one RDS group. It can also print "raw" undecoded hex blocks (`--output hex`). Please refer to the wiki for [input data formats][Wiki: Input]. @@ -45,28 +45,33 @@ Example output: These commands should be run in the terminal. Don't type the `$` in the beginning. -1. Install the prerequisites. On Ubuntu: +### Install dependencies - $ sudo apt install git ninja-build build-essential python3-pip libsndfile1-dev libliquid-dev - $ pip3 install --user meson +On Ubuntu: + + $ sudo apt install git build-essential meson libsndfile1-dev libliquid-dev Or on macOS using Homebrew: $ brew install meson libsndfile liquid-dsp nlohmann-json $ xcode-select --install -Meson will download nlohmann-json for you if it can't be found in the package repositories. +Meson will later download nlohmann-json for you if it can't be found in the package repositories. + +### Get redsea -2. Clone the repository (unless you downloaded a release zip file): +If you wish to have the latest snapshot you can clone this git repository. The +snapshot might be more work-in-progress than the releases, but we attempt to +keep the main branch stable. $ git clone https://github.com/windytan/redsea.git $ cd redsea -3. Compile redsea: +### Compile redsea $ meson setup build && cd build && meson compile -How to later get the latest updates and recompile: +To later get the latest updates and recompile: $ git pull $ cd build && meson compile @@ -82,7 +87,7 @@ an .exe with MSYS2/MinGW; Instructions are in [the wiki][Wiki: Windows build]. By default, an MPX signal is expected via stdin (raw 16-bit signed-integer PCM). -The simplest way to view RDS groups using `rtl_fm` is: +This command listens to 87.9 MHz using `rtl_fm` and displays the RDS groups: rtl_fm -M fm -l 0 -A std -p 0 -s 171k -g 20 -F 9 -f 87.9M | redsea -r 171k @@ -166,17 +171,6 @@ redsea -f WAVEFILE -x, --output-hex Same as --output hex (for backwards compatibility). ``` -### Formatting and filtering the JSON output - -You can get tidier json output using `jq`: - - $ rtl_fm ... | redsea | jq - -It's also useful for extracting only certain fields, for instance the program -type: - - $ rtl_fm ... | redsea | jq '.prog_type' - ## Requirements @@ -184,7 +178,6 @@ type: * Linux/macOS/Windows * For realtime decoding, a Raspberry Pi 1 or faster -* ~8 MB of free memory (~128 MB for RDS-TMC) * libiconv 1.16 * libsndfile 1.0.31 * [liquid-dsp][liquid-dsp] release 1.3.2 @@ -220,8 +213,8 @@ Try running this in the terminal: ## Contributing -Bug reports are welcome. See [CONTRIBUTING](CONTRIBUTING.md) for more -information. +We welcome bug reports and documentation contributions. See +[CONTRIBUTING](CONTRIBUTING.md) for more information. Also, if a station in your area is transmitting an interesting RDS feature that should be implemented in redsea, I would be happy to see a minute or diff --git a/debian/install b/debian/install index 26ed2f5..62b9c4f 100644 --- a/debian/install +++ b/debian/install @@ -1 +1 @@ -build/redsea /usr/bin +redsea /usr/bin diff --git a/src/redsea.cc b/src/redsea.cc index 4fba0a3..58d859f 100644 --- a/src/redsea.cc +++ b/src/redsea.cc @@ -18,7 +18,7 @@ #include #include -#include "build/config.h" +#include "config.h" #include "src/channel.h" #include "src/common.h" #include "src/dsp/subcarrier.h" diff --git a/test/test_helpers.h b/test/test_helpers.h index 2cbdb87..d22bb9c 100644 --- a/test/test_helpers.h +++ b/test/test_helpers.h @@ -15,7 +15,7 @@ using HexData = std::vector; using BinaryData = std::vector; // Convert synchronized hex data into groups. Error correction is omitted and ignored. -inline std::vector makeGroupsFromHex(const HexData& hexdata) { +inline std::vector hex2groups(const HexData& hexdata) { std::vector groups; groups.reserve(hexdata.size()); @@ -34,37 +34,53 @@ inline std::vector makeGroupsFromHex(const HexData& hexdata) { return groups; } -inline std::vector decodeBinary(const BinaryData& bindata, - const redsea::Options& options) { +// Convert string of unsynchronized ASCII bits into JSON. +inline std::vector asciibin2json(const std::string& bindata, + const redsea::Options& options) { std::vector result; std::stringstream json_stream; redsea::Channel channel(options, 0, json_stream); - for (const auto& word : bindata) { - constexpr auto wordsize_bits{sizeof(word) * 8}; + for (const auto& ascii_bit : bindata) { + const int bit{ascii_bit == '1' ? 1 : 0}; - for (size_t nbit{}; nbit < wordsize_bits; nbit++) { - int bit = (word >> (wordsize_bits - 1 - nbit)) & 0b1; - channel.processBit(bit); - if (!json_stream.str().empty()) { - nlohmann::ordered_json jsonroot; - json_stream >> jsonroot; - result.push_back(jsonroot); + channel.processBit(bit); + if (!json_stream.str().empty()) { + nlohmann::ordered_json jsonroot; + json_stream >> jsonroot; + result.push_back(jsonroot); + + json_stream.str(""); + json_stream.clear(); + } + } - json_stream.str(""); - json_stream.clear(); - } + return result; +} + +// Convert string of unsynchronized ASCII bits into groups. +inline std::vector asciibin2groups(const std::string& bindata, + const redsea::Options& options) { + std::vector result; + redsea::BlockStream block_stream(options); + + for (const auto& ascii_bit : bindata) { + const int bit{ascii_bit == '1' ? 1 : 0}; + + block_stream.pushBit(bit); + if (block_stream.hasGroupReady()) { + result.push_back(block_stream.popGroup()); } } return result; } -// Run redsea's decoder and convert the ascii json output into json objects. -inline std::vector decodeGroups(const std::vector& data, - const redsea::Options& options, - uint16_t pi) { +// Run redsea's full decoder and convert the ASCII JSON output back into JSON objects. +inline std::vector groups2json(const std::vector& data, + const redsea::Options& options, + uint16_t pi) { std::vector result; std::stringstream json_stream; @@ -83,10 +99,10 @@ inline std::vector decodeGroups(const std::vector decodeGroups(const HexData& hexdata, - const redsea::Options& options, - uint16_t pi) { - return decodeGroups(makeGroupsFromHex(hexdata), options, pi); +// Convert synchronized hex data (without offset words) into JSON. +inline std::vector hex2json(const HexData& hexdata, + const redsea::Options& options, uint16_t pi) { + return groups2json(hex2groups(hexdata), options, pi); } template @@ -105,4 +121,9 @@ bool listEquals(nlohmann::ordered_json json, std::initializer_list list) { return true; } +// Flip a bit in a string of ASCII bits. +void flipAsciiBit(std::string& str, size_t bit_index) { + str[bit_index] = str[bit_index] == '0' ? '1' : '0'; +} + #endif // TEST_HELPERS_H_ diff --git a/test/unit.cc b/test/unit.cc index 7c23547..5a6954d 100644 --- a/test/unit.cc +++ b/test/unit.cc @@ -15,7 +15,7 @@ TEST_CASE("Decodes basic info") { redsea::Options options; // YLE X3M (fi) 2016-09-15 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0x6204'0130'966B'594C, 0x6204'0131'93CD'4520, 0x6204'0132'E472'5833, @@ -44,13 +44,35 @@ TEST_CASE("Decodes basic info") { CHECK(json_lines[3]["ps"] == "YLE X3M "); } +TEST_CASE("Decodes callsign") { + redsea::Options options; + + SECTION("RBDS station") { + options.rbds = true; + + const auto json_lines{hex2json({ + 0x5521'2000'0D00'0000 + }, options, 0x5521)}; + + CHECK(json_lines.back()["callsign"] == "WAER"); + } + + SECTION("No callsign for non-RBDS station") { + const auto json_lines{hex2json({ + 0x5521'2000'0D00'0000 + }, options, 0x5521)}; + + CHECK_FALSE(json_lines.back().contains("callsign")); + } +} + // https://github.com/windytan/redsea/wiki/Some-RadioText-research TEST_CASE("Decodes radiotext") { redsea::Options options; SECTION("String length method A: Terminated using 0x0D") { // JACK 96.9 (ca) 2019-05-05 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xC954'24F0'4A41'434B, // "JACK" 0xC954'24F1'2039'362E, // " 96." 0xC954'24F2'390D'0000 // "9\r " @@ -62,7 +84,7 @@ TEST_CASE("Decodes radiotext") { SECTION("String length method B: Padded to 64 characters") { // Radio Grün-Weiß (at) 2021-07-18 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xA959'2410'4641'4E43, // "FANC" 0xA959'2411'5920'2D20, // "Y - " 0xA959'2412'426F'6C65, // "Bole" @@ -82,7 +104,7 @@ TEST_CASE("Decodes radiotext") { SECTION("String length method C: Random-length string with no terminator") { // Antenne Kärnten (at) 2021-07-26 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xA540'2540'526F'6262, // "Robb" // REPEAT 1 0xA540'2541'6965'2057, // "ie W" 0xA540'2542'696C'6C69, // "illi" @@ -104,7 +126,7 @@ TEST_CASE("Decodes radiotext") { SECTION("Non-ascii character") { // YLE Vega (fi) 2016-09-15 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0x6205'2440'5665'6761, // "Vega" 0x6205'2441'204B'7691, // " kvä" 0x6205'2442'6C6C'2020, // "ll " @@ -121,6 +143,57 @@ TEST_CASE("Decodes radiotext") { REQUIRE(json_lines.size() == 16); CHECK(json_lines.back()["radiotext"] == "Vega Kväll"); } + + SECTION("Partial") { + options.show_partial = true; + + // Antenne Kärnten (at) 2021-07-26 + const auto json_lines{hex2json({ + 0xA540'2540'526F'6262, // "Robb" + 0xA540'2541'6965'2057, // "ie W" + 0xA540'2542'696C'6C69, // "illi" + 0xA540'2543'616D'7320, // "ams " + 0xA540'2544'2D20'4665 // "- Fe" + }, options, 0xA540)}; + + REQUIRE(json_lines.size() == 5); + REQUIRE(json_lines.back().contains("partial_radiotext")); + CHECK(json_lines.back()["partial_radiotext"] == "Robbie Williams - Fe" + " "); + } +} + +TEST_CASE("Decodes Long PS") { + redsea::Options options; + + SECTION("Space-padded") { + // The Breeze Gold Coast 100.6 (au) 2024-05-17 + const auto json_lines{hex2json({ + 0x49B1'F180'4272'6565, + 0x49B1'F181'7A65'2031, + 0x49B1'F182'3030'2E36, + 0x49B1'F183'2047'6F6C, + 0x49B1'F184'6420'436F, + 0x49B1'F185'6173'7400, + 0x49B1'F186'0000'0000, + 0x49B1'F187'0000'0000 + }, options, 0x49B1)}; + + REQUIRE(json_lines.back().contains("long_ps")); + CHECK(json_lines.back()["long_ps"] == "Breeze 100.6 Gold Coast"); + } + + SECTION("String-terminated, non-ascii character") { + // Järviradio (fi) + const auto json_lines{hex2json({ + 0x6255'F520'4AC3'A452, + 0x6255'F521'5649'5241, + 0x6255'F522'4449'4F0D + }, options, 0x6255)}; + + REQUIRE(json_lines.back().contains("long_ps")); + CHECK(json_lines.back()["long_ps"] == "JäRVIRADIO"); // sic + } } TEST_CASE("Decodes RadioText Plus") { @@ -128,7 +201,7 @@ TEST_CASE("Decodes RadioText Plus") { SECTION("Containing non-ascii characters") { // Antenne 2016-09-17 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ // RT+ ODA identifier 0xD318'3558'0000'4BD7, // RT+ (we need two of these to confirm) @@ -159,7 +232,7 @@ TEST_CASE("Decodes alternative frequencies") { SECTION("Method A") { // YLE Yksi (fi) 2016-09-15 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0x6201'00F7'E704'5349, 0x6201'00F0'2217'594C, 0x6201'00F1'1139'4520, @@ -174,7 +247,7 @@ TEST_CASE("Decodes alternative frequencies") { SECTION("Method B") { // YLE Helsinki (fi) 2016-09-15 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0x6403'0447'F741'4920, 0x6403'0440'415F'594C, 0x6403'0441'4441'4520, @@ -204,7 +277,7 @@ TEST_CASE("Decodes clock-time and date") { SECTION("During DST") { // BR-KLASS (de) 2017-04-04 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xD314'41C1'C3EF'5AC4 }, options, 0xD314)}; @@ -216,7 +289,7 @@ TEST_CASE("Decodes clock-time and date") { SECTION("Outside of DST") { // 104.6RTL (de) 2018-11-01 // walczakp/rds-spy-logs/Germany/D42A - 2018-11-01 14-17-16 DE BER RTL104_6.rds - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xD42A'4541'C86E'D482 }, options, 0xD42A)}; @@ -228,7 +301,7 @@ TEST_CASE("Decodes clock-time and date") { SECTION("With a negative UTC offset") { // 98.5 KFOX (KUFX) (us) 2020-08-19 // walczakp/rds-spy-logs/USA/4569 - 2020-08-19 20-45-06.spy - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0x4569'40DD'CD92'3BAE }, options, 0x4569)}; @@ -239,7 +312,7 @@ TEST_CASE("Decodes clock-time and date") { SECTION("Across local midnight") { // https://github.com/windytan/redsea/issues/83 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xF201'441D'D299'5EC4, 0xF201'441D'D299'6004 }, options, 0xF201)}; @@ -253,7 +326,7 @@ TEST_CASE("Decodes clock-time and date") { SECTION("Across UTC midnight") { // https://github.com/windytan/redsea/issues/83 - const auto json_lines{decodeGroups({ + const auto json_lines{hex2json({ 0xF201'441D'D299'7EC4, 0xF201'441D'D29A'0004 }, options, 0xF201)}; @@ -270,34 +343,109 @@ TEST_CASE("PI search") { redsea::Options options; SECTION("Accepts new PI from three repeats") { - const auto json_lines{decodeBinary({ - 0b00111101101101110100111000101010, 0b01000010100001110000010001000101, - 0b11000010111001100001001011000001, 0b11100111110001000000110010110110, - 0b10011011010010010000001101111100, 0b01000101110000101110011000000010, - 0b11000100100111000001010011010110, 0b01111101001010101001101001100010, - 0b10101010010001011100001011100110, 0b00010010110000100101010100001110, - 0b01101100001010000011001100001000, 0b01101011100011100100000000000000 + // Vikerraadio (ee) + const auto json_lines{asciibin2json({ + "001" + "1110110110111010011100010101001000010100001110000010" + "0010001011100001011100110000100101100000111100111110" + "0010000001100101101101001101101001001000000110111110" + "0010001011100001011100110000000101100010010011100000" + "1010011010110011111010010101010011010011000101010101" + "0010001011100001011100110000100101100001001010101000" + "0111001101100001010000011001100001000011010111000111" + "001000" }, options)}; REQUIRE(json_lines.size() == 1); CHECK(json_lines[0]["pi"] == "0x22E1"); } - SECTION("Ignores data-mimicking PI repeat") { - // Noise that looks like two repeats of PI 0x40AF - // TODO: Shouldn't even sync (no hex groups produced) - const auto json_lines{decodeBinary({ - 0b11000010010000111101101100101010, 0b10011101101100110001010011111011, - 0b11100010010000011001010000111111, 0b10101011001100100011010111001100, - 0b01000100010011100011010010010000, 0b00011011001010100000001011110001, - 0b11001100010100110000101110101010, 0b00101000001001000101100110000110, - 0b00010000001010111110001000010001, 0b10111101011000010110000010011101, - 0b00101110100011010010100110111001, 0b00000011000101010000101100101010, - 0b01001001100001011100000101011010, 0b11011100000100100010010010110100, - 0b00010100101000100101000000101011, 0b01100010011100001000101111110011, - 0b00010010001001001111101000001001, 0b10110011110110000111010100000000 + SECTION("Ignores phantom sync caused by data-mimicking") { + // Noise that shouldn't even sync + // It also happens to look like two repeats of PI 0x40AF + const auto groups{asciibin2groups({ + "1100001001000011110110110010101010011101101100110001010011111011" + "1110001001000001100101000011111110101011001100100011010111001100" + "0100010001001110001101001001000000011011001010100000001011110001" + "1100110001010011000010111010101000101000001001000101100110000110" + "0001000000101011111000100001000110111101011000010110000010011101" + "0010111010001101001010011011100100000011000101010000101100101010" + "0100100110000101110000010101101011011100000100100010010010110100" + "0001010010100010010100000010101101100010011100001000101111110011" + "0001001000100100111110100000100110110011110110000111010100000000" }, options)}; - CHECK(json_lines.empty()); + CHECK(groups.empty()); + } +} + +TEST_CASE("Error detection and correction") { + redsea::Options options; + const std::string correct_group{ + "00100010111000010111001100" + "00100101100000111100111110" + "00100000011001011011010011" + "01101001001000000110111110"}; + + SECTION("Detects error-free group") { + const std::string test_data{correct_group + correct_group}; + const auto groups{asciibin2groups(test_data, options)}; + + CHECK(groups.back().getNumErrors() == 0); + } + + SECTION("Detects long error burst") { + std::string broken_group{correct_group}; + flipAsciiBit(broken_group, 1); + flipAsciiBit(broken_group, 2); + flipAsciiBit(broken_group, 9); + flipAsciiBit(broken_group, 10); + + const std::string test_data{correct_group + correct_group + broken_group}; + const auto groups{asciibin2groups(test_data, options)}; + + CHECK(groups.back().getNumErrors() == 1); + } + + SECTION("Corrects double bit flip") { + std::string broken_group{correct_group}; + flipAsciiBit(broken_group, 1); + flipAsciiBit(broken_group, 2); + + const std::string test_data{correct_group + correct_group + broken_group}; + const auto groups{asciibin2groups(test_data, options)}; + + CHECK(groups.back().getNumErrors() == 1); + CHECK(groups.back().has(redsea::BLOCK1)); + CHECK(groups.back().get(redsea::BLOCK1) == 0x22E1); + } + + SECTION("Rejects triple bit flip") { + std::string broken_group{correct_group}; + flipAsciiBit(broken_group, 1); + flipAsciiBit(broken_group, 2); + flipAsciiBit(broken_group, 3); + + const std::string test_data{correct_group + correct_group + broken_group}; + const auto groups{asciibin2groups(test_data, options)}; + + CHECK(groups.back().getNumErrors() == 1); + CHECK_FALSE(groups.back().has(redsea::BLOCK1)); + CHECK(groups.back().get(redsea::BLOCK1) == 0x0000); // "----" + } + + SECTION("FEC can be disabled") { + options.use_fec = false; + + std::string broken_group{correct_group}; + flipAsciiBit(broken_group, 1); + flipAsciiBit(broken_group, 2); + + const std::string test_data{correct_group + correct_group + broken_group}; + const auto groups{asciibin2groups(test_data, options)}; + + CHECK(groups.back().getNumErrors() == 1); + CHECK_FALSE(groups.back().has(redsea::BLOCK1)); + CHECK(groups.back().get(redsea::BLOCK1) == 0x0000); // "----" } }