├── .gitignore ├── DejaVuSansMono.ttf ├── README.md ├── build_map.py ├── logo.png ├── main_form.glade ├── map.png ├── map_form.glade ├── nrsc5.py ├── nrsc5_gui.py ├── radar_key.png ├── radar_key.svg ├── screenshots ├── album_art_tab.png ├── bookmarks_tab.png ├── info_tab.png ├── map_tab.png └── settings_tab.png └── weather.png /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /aas/ 3 | /map/ 4 | /config.json 5 | /station_logos.json 6 | -------------------------------------------------------------------------------- /DejaVuSansMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/DejaVuSansMono.ttf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | NRSC5-GUI is a graphical interface for [nrsc5](https://github.com/theori-io/nrsc5). 2 | It makes it easy to play your favorite FM HD radio stations using an RTL-SDR dongle. 3 | It will also display weather radar and traffic maps if the radio station provides them. 4 | 5 | # Dependencies 6 | 7 | The folowing programs are required to run NRSC5-GUI 8 | 9 | * [Python 3](https://www.python.org/downloads/release) 10 | * [PyGObject](https://pygobject.readthedocs.io/en/latest/) 11 | * [Pillow](https://pillow.readthedocs.io/en/stable/) 12 | * [PyAudio](https://people.csail.mit.edu/hubert/pyaudio/) 13 | * [nrsc5](https://github.com/theori-io/nrsc5) 14 | 15 | 16 | # Setup 17 | 1. Install the latest version of Python 3, PyGObject, Pillow and PyAudio. 18 | 2. Compile and install nrsc5. 19 | 3. Install nrsc5-gui files in a directory where you have write permissions. 20 | 21 | The configuration files will be created in the same directory as nrsc5-gui.py. 22 | An aas directory will be created for downloaded files and a map directory will be created to 23 | store weather & traffic maps in. 24 | 25 | # Usage 26 | Open the Settings tab and enter the frequency in MHz of the station you want to play. 27 | Select the stream (1 is the main stream, some stations have additional streams). 28 | Set the gain to Auto (you can specify the RF gain in dB in case auto doesn't work for your station). 29 | You can enter a PPM correction value if your RTL-SDR dongle has an offset. 30 | If you have more than one RTL-SDR dongle, you can enter the device number for the one you want to use. 31 | 32 | After setting your station, click the play button to start playing the station. 33 | It will take about 10 seconds to begin playing if the signal strength is good. 34 | Note: The settings cannot be changed while playing. 35 | 36 | ## Album Art & Track Info 37 | Some stations will send album art and station logos. These will be displayed in the Album Art tab if available. 38 | Most stations will send the song title, artist, and album. These are displayed in the Track Info pane if available. 39 | 40 | ## Bookmarks 41 | When a station is playing, you can click the Bookmark Station button to add it to the bookmarks list. 42 | You can click on the name in the bookmarks list to edit it. 43 | Double click the station to switch to it. 44 | Click the Delete Bookmark button to delete it. 45 | 46 | ## Station Info 47 | The station name and slogan is displayed in the Info tab. 48 | The current audio bit rate is displayed in the Info tab. The bit rate is also shown on the status bar. 49 | 50 | ### Signal Strength 51 | The Modulation Error Ratio for the lower and upper sidebands is displayed in the Info tab. 52 | High MER values for both sidebands indicates a strong signal. 53 | The Bit Error Rate is shown in the Info tab. High BER values will cause the audio to glitch or drop out. 54 | The average BER is also shown on the status bar. 55 | 56 | ## Maps 57 | When listening to radio stations operated by [iHeartMedia](https://iheartmedia.com/iheartmedia/stations), 58 | you can view live traffic maps and weather radar. The maps are typically sent every few minutes and 59 | will be displayed once loaded. 60 | Clicking the Map Viewer button on the toolbar will open a larger window to view the maps at full size. 61 | The weather radar information from the last 12 hours will be stored and can be played back by 62 | selecting the Animate Radar option. The delay between frames (in seconds) can be adjusted by changing 63 | the Animation Speed value. 64 | 65 | ### Map Customization 66 | The default map used for the weather radar comes from [OpenStreetMap](https://www.openstreetmap.org). 67 | You can replace the map.png image with a map from any website that will let you export map tiles. 68 | The tiles used are (35,84) to (81,110) at zoom level 8. The image is 12032x6912 pixels. 69 | The portion of the map used for your area is cached in the map directory. 70 | If you change the map image, you will have to delete the base_map images in the map directory so 71 | they will be recreated with the new map. 72 | 73 | ## Screenshots 74 | ![album art tab](https://raw.githubusercontent.com/cmnybo/nrsc5-gui/master/screenshots/album_art_tab.png "Album Art Tab") 75 | ![info tab](https://raw.githubusercontent.com/cmnybo/nrsc5-gui/master/screenshots/info_tab.png "Info Tab") 76 | ![settings tab](https://raw.githubusercontent.com/cmnybo/nrsc5-gui/master/screenshots/settings_tab.png "Settings Tab") 77 | 78 | ![bookmarks tab](https://raw.githubusercontent.com/cmnybo/nrsc5-gui/master/screenshots/bookmarks_tab.png "Bookmarks Tab") 79 | ![map tab](https://raw.githubusercontent.com/cmnybo/nrsc5-gui/master/screenshots/map_tab.png "Map Tab") 80 | 81 | ## Version History 82 | 1.0.0 Initial Release 83 | 1.0.1 Fixed compatibility with display scaling 84 | 1.1.0 Added weather radar and traffic map viewer 85 | 2.0.0 Updated to use the nrsc5 API 86 | -------------------------------------------------------------------------------- /build_map.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """This program generates the base map for weather radar images. 4 | It fetches map tiles from OpenStreetMap and assembles them into a PNG file.""" 5 | 6 | import io 7 | import urllib.request 8 | from PIL import Image 9 | 10 | START_X, START_Y = 35, 84 11 | END_X, END_Y = 81, 110 12 | ZOOM_LEVEL = 8 13 | TILE_SERVER = "https://a.tile.openstreetmap.org" 14 | 15 | WIDTH = END_X - START_X + 1 16 | HEIGHT = END_Y - START_Y + 1 17 | BASE_MAP = Image.new("RGB", (WIDTH*256, HEIGHT*256), "white") 18 | 19 | for x in range(WIDTH): 20 | for y in range(HEIGHT): 21 | tile_url = "{}/{}/{}/{}.png".format(TILE_SERVER, ZOOM_LEVEL, START_X + x, START_Y + y) 22 | print(tile_url) 23 | with urllib.request.urlopen(tile_url) as response: 24 | tile_png = response.read() 25 | BASE_MAP.paste(Image.open(io.BytesIO(tile_png)), (x*256, y*256)) 26 | 27 | BASE_MAP.save("map.png") 28 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/logo.png -------------------------------------------------------------------------------- /main_form.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 88.099999999999994 7 | 107.90000000000001 8 | 88.099999999999994 9 | 0.20000000000000001 10 | 1 11 | 12 | 13 | 49.600000000000001 14 | 0.10000000000000001 15 | 1 16 | 17 | 18 | -1000 19 | 1000 20 | 1 21 | 10 22 | 23 | 24 | 255 25 | 1 26 | 16 27 | 28 | 29 | 1 30 | 4 31 | 1 32 | 1 33 | 10 34 | 35 | 36 | True 37 | False 38 | weather.png 39 | 40 | 41 | False 42 | NRSC5 GUI 43 | logo.png 44 | 45 | 46 | 47 | 48 | 49 | True 50 | False 51 | 52 | 53 | True 54 | False 55 | 56 | 57 | False 58 | True 59 | False 60 | Play 61 | Play 62 | True 63 | gtk-media-play 64 | 65 | 66 | 67 | False 68 | True 69 | 70 | 71 | 72 | 73 | False 74 | True 75 | False 76 | False 77 | Stop 78 | Stop 79 | True 80 | gtk-media-stop 81 | 82 | 83 | 84 | False 85 | True 86 | 87 | 88 | 89 | 90 | False 91 | True 92 | False 93 | 94 | 95 | False 96 | True 97 | 98 | 99 | 100 | 101 | False 102 | True 103 | False 104 | False 105 | Bookmark Station 106 | Add Bookmark 107 | True 108 | bookmark-new 109 | 110 | 111 | 112 | False 113 | True 114 | 115 | 116 | 117 | 118 | False 119 | True 120 | False 121 | False 122 | Delete Bookmark 123 | Delete Bookmark 124 | True 125 | gtk-delete 126 | 127 | 128 | 129 | False 130 | True 131 | 132 | 133 | 134 | 135 | False 136 | True 137 | False 138 | 139 | 140 | False 141 | True 142 | 143 | 144 | 145 | 146 | False 147 | True 148 | False 149 | Weather and Traffic Map Viewer 150 | Weather & Traffic Maps 151 | True 152 | image1 153 | 154 | 155 | 156 | False 157 | True 158 | 159 | 160 | 161 | 162 | False 163 | True 164 | False 165 | About 166 | About 167 | True 168 | gtk-about 169 | 170 | 171 | 172 | False 173 | True 174 | 175 | 176 | 177 | 178 | False 179 | True 180 | 0 181 | 182 | 183 | 184 | 185 | True 186 | False 187 | 0 188 | 0 189 | 190 | 191 | True 192 | False 193 | 4 194 | 195 | 196 | True 197 | False 198 | 10 199 | Frequency 200 | 0 201 | 202 | 203 | GTK_FILL 204 | GTK_FILL 205 | 206 | 207 | 208 | 209 | True 210 | True 211 | 212 | 6 213 | False 214 | False 215 | adjFreq 216 | 1 217 | True 218 | True 219 | True 220 | 221 | 222 | 1 223 | 2 224 | GTK_FILL 225 | GTK_FILL 226 | 227 | 228 | 229 | 230 | True 231 | True 232 | 1 233 | 234 | False 235 | False 236 | adjStream 237 | True 238 | True 239 | 240 | 241 | 242 | 3 243 | 4 244 | 245 | GTK_FILL 246 | 247 | 248 | 249 | 250 | True 251 | False 252 | 10 253 | Stream 254 | 1 255 | 256 | 257 | 2 258 | 3 259 | GTK_FILL 260 | 261 | 262 | 263 | 264 | 265 | 266 | False 267 | False 268 | 3 269 | 1 270 | 271 | 272 | 273 | 274 | True 275 | True 276 | 277 | 278 | 279 | True 280 | False 281 | 0 282 | in 283 | 284 | 285 | True 286 | False 287 | 6 288 | 6 289 | 6 290 | 6 291 | 292 | 293 | 200 294 | 200 295 | True 296 | False 297 | 298 | 299 | 300 | 301 | 302 | 303 | True 304 | False 305 | <b>Album Art</b> 306 | True 307 | 308 | 309 | 310 | 311 | 312 | 313 | True 314 | False 315 | Album Art 316 | 317 | 318 | False 319 | 320 | 321 | 322 | 323 | True 324 | False 325 | 0 326 | in 327 | 328 | 329 | True 330 | False 331 | 6 332 | 6 333 | 6 334 | 6 335 | 336 | 337 | True 338 | False 339 | 7 340 | 3 341 | 342 | 343 | True 344 | False 345 | 5 346 | Name: 347 | 0 348 | 349 | 350 | GTK_FILL 351 | GTK_FILL 352 | 353 | 354 | 355 | 356 | True 357 | False 358 | 5 359 | Slogan: 360 | 0 361 | 362 | 363 | 1 364 | 2 365 | GTK_FILL 366 | GTK_FILL 367 | 368 | 369 | 370 | 371 | True 372 | False 373 | 5 374 | MER: 375 | 0 376 | 377 | 378 | 4 379 | 5 380 | GTK_FILL 381 | GTK_FILL 382 | 383 | 384 | 385 | 386 | True 387 | False 388 | 5 389 | BER: 390 | 0 391 | 392 | 393 | 5 394 | 7 395 | GTK_FILL 396 | GTK_FILL 397 | 398 | 399 | 400 | 401 | True 402 | False 403 | Modulation Error Ratio (Lower) 404 | 5 405 | 406 | 407 | 1 408 | 2 409 | 4 410 | 5 411 | GTK_FILL 412 | 413 | 414 | 415 | 416 | True 417 | False 418 | Modulation Error Ratio (Upper) 419 | 5 420 | 421 | 422 | 2 423 | 3 424 | 4 425 | 5 426 | GTK_FILL 427 | 428 | 429 | 430 | 431 | True 432 | False 433 | Bit Error Rate 434 | 5 435 | 436 | 437 | 1 438 | 2 439 | 5 440 | 6 441 | GTK_FILL 442 | 443 | 444 | 445 | 446 | True 447 | False 448 | Bit Error Rate (Average) 449 | 5 450 | 451 | 452 | 2 453 | 3 454 | 5 455 | 6 456 | GTK_FILL 457 | 458 | 459 | 460 | 461 | True 462 | False 463 | Bit Error Rate (Minimum) 464 | 5 465 | 466 | 467 | 1 468 | 2 469 | 6 470 | 7 471 | GTK_FILL 472 | 473 | 474 | 475 | 476 | True 477 | False 478 | Bit Error Rate (Maximum) 479 | 5 480 | 481 | 482 | 2 483 | 3 484 | 6 485 | 7 486 | GTK_FILL 487 | 488 | 489 | 490 | 491 | True 492 | False 493 | 5 494 | 5 495 | 0 496 | 497 | 498 | 1 499 | 3 500 | GTK_FILL 501 | 502 | 503 | 504 | 505 | True 506 | False 507 | 5 508 | 5 509 | end 510 | 0 511 | 512 | 513 | 1 514 | 3 515 | 1 516 | 2 517 | GTK_FILL 518 | GTK_FILL 519 | 520 | 521 | 522 | 523 | True 524 | False 525 | 5 526 | Bit Rate: 527 | 0 528 | 529 | 530 | 2 531 | 3 532 | GTK_FILL 533 | GTK_FILL 534 | 535 | 536 | 537 | 538 | True 539 | False 540 | Audio Bit Rate 541 | 5 542 | 5 543 | 0 544 | 545 | 546 | 1 547 | 3 548 | 2 549 | 3 550 | GTK_FILL 551 | 552 | 553 | 554 | 555 | True 556 | False 557 | 558 | 559 | 3 560 | 3 561 | 4 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | True 571 | False 572 | <b>Station Info</b> 573 | True 574 | 575 | 576 | 577 | 578 | 1 579 | 580 | 581 | 582 | 583 | True 584 | False 585 | Info 586 | 587 | 588 | 1 589 | False 590 | 591 | 592 | 593 | 594 | True 595 | False 596 | 0 597 | in 598 | 599 | 600 | True 601 | False 602 | 6 603 | 6 604 | 6 605 | 6 606 | 607 | 608 | True 609 | False 610 | 4 611 | 3 612 | 10 613 | 3 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | True 626 | False 627 | 10 628 | Gain 629 | 0 630 | 631 | 632 | GTK_FILL 633 | GTK_FILL 634 | 635 | 636 | 637 | 638 | True 639 | True 640 | RF Gain (dBm) 641 | 642 | 5 643 | False 644 | False 645 | adjGain 646 | 1 647 | True 648 | True 649 | 650 | 651 | 1 652 | 2 653 | GTK_FILL 654 | GTK_FILL 655 | 656 | 657 | 658 | 659 | Auto 660 | False 661 | True 662 | True 663 | False 664 | Automatic Gain 665 | True 666 | True 667 | 668 | 669 | 670 | 2 671 | 3 672 | GTK_FILL 673 | GTK_FILL 674 | 675 | 676 | 677 | 678 | True 679 | False 680 | 10 681 | PPM Error 682 | 0 683 | 684 | 685 | 1 686 | 2 687 | GTK_FILL 688 | GTK_FILL 689 | 690 | 691 | 692 | 693 | True 694 | True 695 | Crystal Error Correction (PPM) 696 | 697 | 5 698 | False 699 | False 700 | adjPPM 701 | True 702 | True 703 | 704 | 705 | 1 706 | 2 707 | 1 708 | 2 709 | GTK_FILL 710 | GTK_FILL 711 | 712 | 713 | 714 | 715 | True 716 | False 717 | 10 718 | PPM 719 | 0 720 | 721 | 722 | 2 723 | 3 724 | 1 725 | 2 726 | GTK_FILL 727 | GTK_FILL 728 | 729 | 730 | 731 | 732 | True 733 | False 734 | 10 735 | RTL Device 736 | 0 737 | 738 | 739 | 2 740 | 3 741 | GTK_FILL 742 | GTK_FILL 743 | 744 | 745 | 746 | 747 | True 748 | True 749 | RTL-SDR Device Number (Default = 0) 750 | 751 | 5 752 | False 753 | False 754 | adjRTL 755 | True 756 | True 757 | 758 | 759 | 1 760 | 2 761 | 2 762 | 3 763 | GTK_FILL 764 | GTK_FILL 765 | 766 | 767 | 768 | 769 | True 770 | False 771 | 772 | 773 | 2 774 | 3 775 | 2 776 | 3 777 | GTK_FILL 778 | GTK_FILL 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | True 788 | False 789 | <b>Settings</b> 790 | True 791 | 792 | 793 | 794 | 795 | 2 796 | 797 | 798 | 799 | 800 | True 801 | False 802 | Settings 803 | 804 | 805 | 2 806 | False 807 | 808 | 809 | 810 | 811 | True 812 | True 813 | 814 | 815 | True 816 | True 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 3 826 | 827 | 828 | 829 | 830 | True 831 | False 832 | Bookmarks 833 | 834 | 835 | 3 836 | False 837 | 838 | 839 | 840 | 841 | True 842 | False 843 | 0 844 | in 845 | 846 | 847 | True 848 | False 849 | 6 850 | 6 851 | 6 852 | 6 853 | 854 | 855 | True 856 | False 857 | 2 858 | 2 859 | 860 | 861 | 200 862 | 200 863 | True 864 | False 865 | 866 | 867 | 2 868 | 869 | 870 | 871 | 872 | Traffic Map 873 | False 874 | True 875 | True 876 | False 877 | True 878 | 879 | 880 | 881 | 1 882 | 2 883 | GTK_EXPAND 884 | GTK_FILL 885 | 886 | 887 | 888 | 889 | Weather Radar 890 | False 891 | True 892 | True 893 | False 894 | True 895 | rad_map_traffic 896 | 897 | 898 | 899 | 1 900 | 2 901 | 1 902 | 2 903 | GTK_EXPAND 904 | GTK_FILL 905 | 906 | 907 | 908 | 909 | 910 | 911 | 912 | 913 | True 914 | False 915 | <b>Traffic &amp; Weather Maps</b> 916 | True 917 | 918 | 919 | 920 | 921 | 4 922 | 923 | 924 | 925 | 926 | True 927 | False 928 | Maps 929 | 930 | 931 | 4 932 | False 933 | 934 | 935 | 936 | 937 | True 938 | True 939 | 2 940 | 941 | 942 | 943 | 944 | True 945 | False 946 | 0 947 | in 948 | 949 | 950 | True 951 | False 952 | 6 953 | 6 954 | 6 955 | 6 956 | 957 | 958 | True 959 | False 960 | 3 961 | 2 962 | 963 | 964 | True 965 | False 966 | 10 967 | Title 968 | 0 969 | 970 | 971 | GTK_FILL 972 | GTK_FILL 973 | 974 | 975 | 976 | 977 | True 978 | False 979 | 10 980 | Artist 981 | 0 982 | 983 | 984 | 1 985 | 2 986 | GTK_FILL 987 | GTK_FILL 988 | 989 | 990 | 991 | 992 | True 993 | False 994 | 10 995 | Album 996 | 0 997 | 998 | 999 | 2 1000 | 3 1001 | GTK_FILL 1002 | GTK_FILL 1003 | 1004 | 1005 | 1006 | 1007 | True 1008 | True 1009 | False 1010 | 1011 | False 1012 | False 1013 | 1014 | 1015 | 1 1016 | 2 1017 | GTK_FILL 1018 | 1019 | 1020 | 1021 | 1022 | True 1023 | True 1024 | False 1025 | 1026 | False 1027 | False 1028 | 1029 | 1030 | 1 1031 | 2 1032 | 1 1033 | 2 1034 | GTK_FILL 1035 | 1036 | 1037 | 1038 | 1039 | True 1040 | True 1041 | False 1042 | 1043 | False 1044 | False 1045 | 1046 | 1047 | 1 1048 | 2 1049 | 2 1050 | 3 1051 | GTK_FILL 1052 | 1053 | 1054 | 1055 | 1056 | 1057 | 1058 | 1059 | 1060 | True 1061 | False 1062 | <b>Track Info</b> 1063 | True 1064 | 1065 | 1066 | 1067 | 1068 | False 1069 | True 1070 | 3 1071 | 1072 | 1073 | 1074 | 1075 | True 1076 | False 1077 | 4 1078 | 1079 | 1080 | 25 1081 | True 1082 | False 1083 | Station Callsign 1084 | 7 1085 | 0 1086 | 1087 | 1088 | GTK_FILL 1089 | 1090 | 1091 | 1092 | 1093 | 25 1094 | True 1095 | False 1096 | Current Bitrate 1097 | 8 1098 | 1099 | 1100 | 1 1101 | 2 1102 | GTK_FILL 1103 | 1104 | 1105 | 1106 | 1107 | 0 1108 | True 1109 | False 1110 | Average Error Rate 1111 | 9 1112 | 1 1113 | 1114 | 1115 | 3 1116 | 4 1117 | GTK_FILL 1118 | 1119 | 1120 | 1121 | 1122 | 25 1123 | True 1124 | False 1125 | Automatic Gain 1126 | 8 1127 | 1128 | 1129 | 2 1130 | 3 1131 | GTK_FILL 1132 | 1133 | 1134 | 1135 | 1136 | False 1137 | True 1138 | 4 1139 | 1140 | 1141 | 1142 | 1143 | 1144 | 1145 | -------------------------------------------------------------------------------- /map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/map.png -------------------------------------------------------------------------------- /map_form.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0.10000000000000001 7 | 2 8 | 0.5 9 | 0.050000000000000003 10 | 0.25 11 | 12 | 13 | False 14 | Map Viewer 15 | True 16 | 17 | 18 | 19 | 20 | 21 | True 22 | False 23 | 2 24 | 5 25 | 26 | 27 | True 28 | False 29 | 0 30 | in 31 | 32 | 33 | True 34 | False 35 | 6 36 | 6 37 | 6 38 | 6 39 | 40 | 41 | True 42 | True 43 | 44 | 45 | True 46 | False 47 | 48 | 49 | True 50 | False 51 | gtk-missing-image 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | True 63 | False 64 | <b>Map Viewer</b> 65 | True 66 | 67 | 68 | 69 | 70 | 71 | 72 | True 73 | False 74 | 0 75 | in 76 | 77 | 78 | True 79 | False 80 | 6 81 | 6 82 | 6 83 | 6 84 | 85 | 86 | True 87 | False 88 | 7 89 | 5 90 | 91 | 92 | Weather Radar 93 | False 94 | True 95 | True 96 | False 97 | Display Weather Radar 98 | True 99 | True 100 | 101 | 102 | 103 | GTK_FILL 104 | GTK_FILL 105 | 10 106 | 107 | 108 | 109 | 110 | Traffic Map 111 | False 112 | True 113 | True 114 | False 115 | Display Traffic Map 116 | True 117 | rad_map_weather 118 | 119 | 120 | 121 | 1 122 | 2 123 | GTK_FILL 124 | GTK_FILL 125 | 10 126 | 127 | 128 | 129 | 130 | Animate Radar 131 | False 132 | True 133 | True 134 | False 135 | Play the animated radar 136 | True 137 | 138 | 139 | 140 | 3 141 | 4 142 | GTK_FILL 143 | GTK_FILL 144 | 10 145 | 146 | 147 | 148 | 149 | Scale Radar 150 | False 151 | True 152 | True 153 | False 154 | Scale radar to 600x600 px 155 | True 156 | True 157 | 158 | 159 | 160 | 2 161 | 3 162 | GTK_FILL 163 | GTK_FILL 164 | 10 165 | 166 | 167 | 168 | 169 | True 170 | True 171 | Time between frames (seconds) 172 | 173 | False 174 | False 175 | adj_speed 176 | 2 177 | 178 | 179 | 180 | 6 181 | 7 182 | GTK_FILL 183 | GTK_FILL 184 | 185 | 186 | 187 | 188 | True 189 | False 190 | 5 191 | Animation Speed 192 | 0 193 | 194 | 195 | 7 196 | 6 197 | GTK_FILL 198 | GTK_FILL 199 | 200 | 201 | 202 | 203 | True 204 | False 205 | 206 | 207 | 4 208 | 5 209 | GTK_FILL 210 | GTK_FILL 211 | 212 | 213 | 214 | 215 | True 216 | False 217 | 1 218 | radar_key.png 219 | 220 | 221 | 7 222 | 8 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | True 232 | False 233 | <b>Settings</b> 234 | True 235 | 236 | 237 | 238 | 239 | 1 240 | 2 241 | GTK_FILL 242 | 243 | 244 | 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /nrsc5.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import ctypes 3 | import enum 4 | import math 5 | import platform 6 | import socket 7 | 8 | 9 | class EventType(enum.Enum): 10 | LOST_DEVICE = 0 11 | IQ = 1 12 | SYNC = 2 13 | LOST_SYNC = 3 14 | MER = 4 15 | BER = 5 16 | HDC = 6 17 | AUDIO = 7 18 | ID3 = 8 19 | SIG = 9 20 | LOT = 10 21 | SIS = 11 22 | 23 | 24 | class ServiceType(enum.Enum): 25 | AUDIO = 0 26 | DATA = 1 27 | 28 | 29 | class ComponentType(enum.Enum): 30 | AUDIO = 0 31 | DATA = 1 32 | 33 | 34 | class MIMEType(enum.Enum): 35 | PRIMARY_IMAGE = 0xBE4B7536 36 | STATION_LOGO = 0xD9C72536 37 | NAVTEQ = 0x2D42AC3E 38 | HERE_TPEG = 0x82F03DFC 39 | HERE_IMAGE = 0xB7F03DFC 40 | HD_TMC = 0xEECB55B6 41 | HDC = 0x4DC66C5A 42 | TEXT = 0xBB492AAC 43 | JPEG = 0x1E653E9C 44 | PNG = 0x4F328CA0 45 | TTN_TPEG_1 = 0xB39EBEB2 46 | TTN_TPEG_2 = 0x4EB03469 47 | TTN_TPEG_3 = 0x52103469 48 | TTN_STM_TRAFFIC = 0xFF8422D7 49 | TTN_STM_WEATHER = 0xEF042E96 50 | 51 | 52 | class Access(enum.Enum): 53 | PUBLIC = 0 54 | RESTRICTED = 1 55 | 56 | 57 | class ServiceDataType(enum.Enum): 58 | NON_SPECIFIC = 0 59 | NEWS = 1 60 | SPORTS = 3 61 | WEATHER = 29 62 | EMERGENCY = 31 63 | TRAFFIC = 65 64 | IMAGE_MAPS = 66 65 | TEXT = 80 66 | ADVERTISING = 256 67 | FINANCIAL = 257 68 | STOCK_TICKER = 258 69 | NAVIGATION = 259 70 | ELECTRONIC_PROGRAM_GUIDE = 260 71 | AUDIO = 261 72 | PRIVATE_DATA_NETWORK = 262 73 | SERVICE_MAINTENANCE = 263 74 | HD_RADIO_SYSTEM_SERVICES = 264 75 | AUDIO_RELATED_DATA = 265 76 | 77 | 78 | class ProgramType(enum.Enum): 79 | UNDEFINED = 0 80 | NEWS = 1 81 | INFORMATION = 2 82 | SPORTS = 3 83 | TALK = 4 84 | ROCK = 5 85 | CLASSIC_ROCK = 6 86 | ADULT_HITS = 7 87 | SOFT_ROCK = 8 88 | TOP_40 = 9 89 | COUNTRY = 10 90 | OLDIES = 11 91 | SOFT = 12 92 | NOSTALGIA = 13 93 | JAZZ = 14 94 | CLASSICAL = 15 95 | RHYTHM_AND_BLUES = 16 96 | SOFT_RHYTHM_AND_BLUES = 17 97 | FOREIGN_LANGUAGE = 18 98 | RELIGIOUS_MUSIC = 19 99 | RELIGIOUS_TALK = 20 100 | PERSONALITY = 21 101 | PUBLIC = 22 102 | COLLEGE = 23 103 | SPANISH_TALK = 24 104 | SPANISH_MUSIC = 25 105 | HIP_HOP = 26 106 | WEATHER = 29 107 | EMERGENCY_TEST = 30 108 | EMERGENCY = 31 109 | TRAFFIC = 65 110 | SPECIAL_READING_SERVICES = 76 111 | 112 | 113 | IQ = collections.namedtuple("IQ", ["data"]) 114 | MER = collections.namedtuple("MER", ["lower", "upper"]) 115 | BER = collections.namedtuple("BER", ["cber"]) 116 | HDC = collections.namedtuple("HDC", ["program", "data"]) 117 | Audio = collections.namedtuple("Audio", ["program", "data"]) 118 | UFID = collections.namedtuple("UFID", ["owner", "id"]) 119 | XHDR = collections.namedtuple("XHDR", ["mime", "param", "lot"]) 120 | ID3 = collections.namedtuple("ID3", ["program", "title", "artist", "album", "genre", "ufid", "xhdr"]) 121 | SIGAudioComponent = collections.namedtuple("SIGAudioComponent", ["port", "type", "mime"]) 122 | SIGDataComponent = collections.namedtuple("SIGDataComponent", ["port", "service_data_type", "type", "mime"]) 123 | SIGComponent = collections.namedtuple("SIGComponent", ["type", "id", "audio", "data"]) 124 | SIGService = collections.namedtuple("SIGService", ["type", "number", "name", "components"]) 125 | SIG = collections.namedtuple("SIG", ["services"]) 126 | LOT = collections.namedtuple("LOT", ["port", "lot", "mime", "name", "data"]) 127 | SISAudioService = collections.namedtuple("SISAudioService", ["program", "access", "type", "sound_exp"]) 128 | SISDataService = collections.namedtuple("SISDataService", ["access", "type", "mime_type"]) 129 | SIS = collections.namedtuple("SIS", ["country_code", "fcc_facility_id", "name", "slogan", "message", "alert", 130 | "latitude", "longitude", "altitude", "audio_services", "data_services"]) 131 | 132 | 133 | class _IQ(ctypes.Structure): 134 | _fields_ = [ 135 | ("data", ctypes.POINTER(ctypes.c_char)), 136 | ("count", ctypes.c_size_t), 137 | ] 138 | 139 | 140 | class _MER(ctypes.Structure): 141 | _fields_ = [ 142 | ("lower", ctypes.c_float), 143 | ("upper", ctypes.c_float), 144 | ] 145 | 146 | 147 | class _BER(ctypes.Structure): 148 | _fields_ = [ 149 | ("cber", ctypes.c_float), 150 | ] 151 | 152 | 153 | class _HDC(ctypes.Structure): 154 | _fields_ = [ 155 | ("program", ctypes.c_uint), 156 | ("data", ctypes.POINTER(ctypes.c_char)), 157 | ("count", ctypes.c_size_t), 158 | ] 159 | 160 | 161 | class _Audio(ctypes.Structure): 162 | _fields_ = [ 163 | ("program", ctypes.c_uint), 164 | ("data", ctypes.POINTER(ctypes.c_char)), 165 | ("count", ctypes.c_size_t), 166 | ] 167 | 168 | 169 | class _UFID(ctypes.Structure): 170 | _fields_ = [ 171 | ("owner", ctypes.c_char_p), 172 | ("id", ctypes.c_char_p), 173 | ] 174 | 175 | 176 | class _XHDR(ctypes.Structure): 177 | _fields_ = [ 178 | ("mime", ctypes.c_uint32), 179 | ("param", ctypes.c_int), 180 | ("lot", ctypes.c_int), 181 | ] 182 | 183 | 184 | class _ID3(ctypes.Structure): 185 | _fields_ = [ 186 | ("program", ctypes.c_uint), 187 | ("title", ctypes.c_char_p), 188 | ("artist", ctypes.c_char_p), 189 | ("album", ctypes.c_char_p), 190 | ("genre", ctypes.c_char_p), 191 | ("ufid", _UFID), 192 | ("xhdr", _XHDR), 193 | ] 194 | 195 | 196 | class _SIGData(ctypes.Structure): 197 | _fields_ = [ 198 | ("port", ctypes.c_uint16), 199 | ("service_data_type", ctypes.c_uint16), 200 | ("type", ctypes.c_uint8), 201 | ("mime", ctypes.c_uint32), 202 | ] 203 | 204 | 205 | class _SIGAudio(ctypes.Structure): 206 | _fields_ = [ 207 | ("port", ctypes.c_uint8), 208 | ("type", ctypes.c_uint8), 209 | ("mime", ctypes.c_uint32), 210 | ] 211 | 212 | 213 | class _SIGUnion(ctypes.Union): 214 | _fields_ = [ 215 | ("audio", _SIGAudio), 216 | ("data", _SIGData), 217 | ] 218 | 219 | 220 | class _SIGComponent(ctypes.Structure): 221 | pass 222 | 223 | 224 | _SIGComponent._fields_ = [ 225 | ("next", ctypes.POINTER(_SIGComponent)), 226 | ("type", ctypes.c_uint8), 227 | ("id", ctypes.c_uint8), 228 | ("u", _SIGUnion), 229 | ] 230 | 231 | 232 | class _SIGService(ctypes.Structure): 233 | pass 234 | 235 | 236 | _SIGService._fields_ = [ 237 | ("next", ctypes.POINTER(_SIGService)), 238 | ("type", ctypes.c_uint8), 239 | ("number", ctypes.c_uint16), 240 | ("name", ctypes.c_char_p), 241 | ("components", ctypes.POINTER(_SIGComponent)), 242 | ] 243 | 244 | 245 | class _SIG(ctypes.Structure): 246 | _fields_ = [ 247 | ("services", ctypes.POINTER(_SIGService)), 248 | ] 249 | 250 | 251 | class _LOT(ctypes.Structure): 252 | _fields_ = [ 253 | ("port", ctypes.c_uint16), 254 | ("lot", ctypes.c_uint), 255 | ("size", ctypes.c_uint), 256 | ("mime", ctypes.c_uint32), 257 | ("name", ctypes.c_char_p), 258 | ("data", ctypes.POINTER(ctypes.c_char)), 259 | ] 260 | 261 | 262 | class _SISAudioService(ctypes.Structure): 263 | pass 264 | 265 | 266 | _SISAudioService._fields_ = [ 267 | ("next", ctypes.POINTER(_SISAudioService)), 268 | ("program", ctypes.c_uint), 269 | ("access", ctypes.c_uint), 270 | ("type", ctypes.c_uint), 271 | ("sound_exp", ctypes.c_uint), 272 | ] 273 | 274 | 275 | class _SISDataService(ctypes.Structure): 276 | pass 277 | 278 | 279 | _SISDataService._fields_ = [ 280 | ("next", ctypes.POINTER(_SISDataService)), 281 | ("access", ctypes.c_uint), 282 | ("type", ctypes.c_uint), 283 | ("mime_type", ctypes.c_uint32), 284 | ] 285 | 286 | 287 | class _SIS(ctypes.Structure): 288 | _fields_ = [ 289 | ("country_code", ctypes.c_char_p), 290 | ("fcc_facility_id", ctypes.c_int), 291 | ("name", ctypes.c_char_p), 292 | ("slogan", ctypes.c_char_p), 293 | ("message", ctypes.c_char_p), 294 | ("alert", ctypes.c_char_p), 295 | ("latitude", ctypes.c_float), 296 | ("longitude", ctypes.c_float), 297 | ("altitude", ctypes.c_int), 298 | ("audio_services", ctypes.POINTER(_SISAudioService)), 299 | ("data_services", ctypes.POINTER(_SISDataService)), 300 | ] 301 | 302 | 303 | class _EventUnion(ctypes.Union): 304 | _fields_ = [ 305 | ("iq", _IQ), 306 | ("mer", _MER), 307 | ("ber", _BER), 308 | ("hdc", _HDC), 309 | ("audio", _Audio), 310 | ("id3", _ID3), 311 | ("sig", _SIG), 312 | ("lot", _LOT), 313 | ("sis", _SIS), 314 | ] 315 | 316 | 317 | class _Event(ctypes.Structure): 318 | _fields_ = [ 319 | ("event", ctypes.c_uint), 320 | ("u", _EventUnion), 321 | ] 322 | 323 | 324 | class NRSC5Error(Exception): 325 | pass 326 | 327 | 328 | class NRSC5: 329 | libnrsc5 = None 330 | 331 | def _load_library(self): 332 | if NRSC5.libnrsc5 is None: 333 | if platform.system() == "Windows": 334 | lib_name = "libnrsc5.dll" 335 | elif platform.system() == "Linux": 336 | lib_name = "libnrsc5.so" 337 | elif platform.system() == "Darwin": 338 | lib_name = "libnrsc5.dylib" 339 | else: 340 | raise NRSC5Error("Unsupported platform: " + platform.system()) 341 | NRSC5.libnrsc5 = ctypes.cdll.LoadLibrary(lib_name) 342 | self.radio = ctypes.c_void_p() 343 | 344 | @staticmethod 345 | def _decode(string): 346 | if string is None: 347 | return string 348 | return string.decode() 349 | 350 | def _callback_wrapper(self, c_evt): 351 | c_evt = c_evt.contents 352 | evt = None 353 | 354 | try: 355 | evt_type = EventType(c_evt.event) 356 | except ValueError: 357 | return 358 | 359 | if evt_type == EventType.IQ: 360 | iq = c_evt.u.iq 361 | evt = IQ(iq.data[:iq.count]) 362 | elif evt_type == EventType.MER: 363 | mer = c_evt.u.mer 364 | evt = MER(mer.lower, mer.upper) 365 | elif evt_type == EventType.BER: 366 | ber = c_evt.u.ber 367 | evt = BER(ber.cber) 368 | elif evt_type == EventType.HDC: 369 | hdc = c_evt.u.hdc 370 | evt = HDC(hdc.program, hdc.data[:hdc.count]) 371 | elif evt_type == EventType.AUDIO: 372 | audio = c_evt.u.audio 373 | evt = Audio(audio.program, audio.data[:audio.count * 2]) 374 | elif evt_type == EventType.ID3: 375 | id3 = c_evt.u.id3 376 | 377 | ufid = None 378 | if id3.ufid.owner or id3.ufid.id: 379 | ufid = UFID(self._decode(id3.ufid.owner), self._decode(id3.ufid.id)) 380 | 381 | xhdr = None 382 | if id3.xhdr.mime != 0 or id3.xhdr.param != -1 or id3.xhdr.lot != -1: 383 | xhdr = XHDR(None if id3.xhdr.mime == 0 else MIMEType(id3.xhdr.mime), 384 | None if id3.xhdr.param == -1 else id3.xhdr.param, 385 | None if id3.xhdr.lot == -1 else id3.xhdr.lot) 386 | 387 | evt = ID3(id3.program, self._decode(id3.title), self._decode(id3.artist), 388 | self._decode(id3.album), self._decode(id3.genre), ufid, xhdr) 389 | elif evt_type == EventType.SIG: 390 | evt = [] 391 | service_ptr = c_evt.u.sig.services 392 | while service_ptr: 393 | service = service_ptr.contents 394 | components = [] 395 | component_ptr = service.components 396 | while component_ptr: 397 | component = component_ptr.contents 398 | component_type = ComponentType(component.type) 399 | if component_type == ComponentType.AUDIO: 400 | audio = SIGAudioComponent(component.u.audio.port, ProgramType(component.u.audio.type), 401 | MIMEType(component.u.audio.mime)) 402 | components.append(SIGComponent(component_type, component.id, audio, None)) 403 | if component_type == ComponentType.DATA: 404 | data = SIGDataComponent(component.u.data.port, 405 | ServiceDataType(component.u.data.service_data_type), 406 | component.u.data.type, MIMEType(component.u.data.mime)) 407 | components.append(SIGComponent(component_type, component.id, None, data)) 408 | component_ptr = component.next 409 | evt.append(SIGService(ServiceType(service.type), service.number, 410 | self._decode(service.name), components)) 411 | service_ptr = service.next 412 | elif evt_type == EventType.LOT: 413 | lot = c_evt.u.lot 414 | evt = LOT(lot.port, lot.lot, MIMEType(lot.mime), self._decode(lot.name), lot.data[:lot.size]) 415 | elif evt_type == EventType.SIS: 416 | sis = c_evt.u.sis 417 | 418 | latitude, longitude, altitude = None, None, None 419 | if not math.isnan(sis.latitude): 420 | latitude, longitude, altitude = sis.latitude, sis.longitude, sis.altitude 421 | 422 | audio_services = [] 423 | audio_service_ptr = sis.audio_services 424 | while audio_service_ptr: 425 | asd = audio_service_ptr.contents 426 | audio_services.append(SISAudioService(asd.program, Access(asd.access), 427 | ProgramType(asd.type), asd.sound_exp)) 428 | audio_service_ptr = asd.next 429 | 430 | data_services = [] 431 | data_service_ptr = sis.data_services 432 | while data_service_ptr: 433 | dsd = data_service_ptr.contents 434 | data_services.append(SISDataService(Access(dsd.access), ServiceDataType(dsd.type), dsd.mime_type)) 435 | data_service_ptr = dsd.next 436 | 437 | evt = SIS(self._decode(sis.country_code), sis.fcc_facility_id, self._decode(sis.name), 438 | self._decode(sis.slogan), self._decode(sis.message), self._decode(sis.alert), 439 | latitude, longitude, altitude, audio_services, data_services) 440 | self.callback(evt_type, evt) 441 | 442 | def __init__(self, callback): 443 | self._load_library() 444 | self.radio = ctypes.c_void_p() 445 | self.callback = callback 446 | 447 | @staticmethod 448 | def get_version(): 449 | version = ctypes.c_char_p() 450 | NRSC5.libnrsc5.nrsc5_get_version(ctypes.byref(version)) 451 | return version.value.decode() 452 | 453 | @staticmethod 454 | def service_data_type_name(type): 455 | name = ctypes.c_char_p() 456 | NRSC5.libnrsc5.nrsc5_service_data_type_name(type.value, ctypes.byref(name)) 457 | return name.value.decode() 458 | 459 | @staticmethod 460 | def program_type_name(type): 461 | name = ctypes.c_char_p() 462 | NRSC5.libnrsc5.nrsc5_program_type_name(type.value, ctypes.byref(name)) 463 | return name.value.decode() 464 | 465 | def open(self, device_index): 466 | result = NRSC5.libnrsc5.nrsc5_open(ctypes.byref(self.radio), device_index) 467 | if result != 0: 468 | raise NRSC5Error("Failed to open RTL-SDR.") 469 | self._set_callback() 470 | 471 | def open_pipe(self): 472 | result = NRSC5.libnrsc5.nrsc5_open_pipe(ctypes.byref(self.radio)) 473 | if result != 0: 474 | raise NRSC5Error("Failed to open pipe.") 475 | self._set_callback() 476 | 477 | def open_rtltcp(self, host, port): 478 | s = socket.create_connection((host, port)) 479 | result = NRSC5.libnrsc5.nrsc5_open_rtltcp(ctypes.byref(self.radio), s.detach()) 480 | if result != 0: 481 | raise NRSC5Error("Failed to open rtl_tcp.") 482 | self._set_callback() 483 | 484 | def close(self): 485 | NRSC5.libnrsc5.nrsc5_close(self.radio) 486 | 487 | def start(self): 488 | NRSC5.libnrsc5.nrsc5_start(self.radio) 489 | 490 | def stop(self): 491 | NRSC5.libnrsc5.nrsc5_stop(self.radio) 492 | 493 | def set_freq_correction(self, ppm_error): 494 | result = NRSC5.libnrsc5.nrsc5_set_freq_correction(self.radio, ppm_error) 495 | if result != 0: 496 | raise NRSC5Error("Failed to set frequency correction.") 497 | 498 | def get_frequency(self): 499 | frequency = ctypes.c_float() 500 | NRSC5.libnrsc5.nrsc5_get_frequency(self.radio, ctypes.byref(frequency)) 501 | return frequency.value 502 | 503 | def set_frequency(self, freq): 504 | result = NRSC5.libnrsc5.nrsc5_set_frequency(self.radio, ctypes.c_float(freq)) 505 | if result != 0: 506 | raise NRSC5Error("Failed to set frequency.") 507 | 508 | def get_gain(self): 509 | gain = ctypes.c_float() 510 | NRSC5.libnrsc5.nrsc5_get_gain(self.radio, ctypes.byref(gain)) 511 | return gain.value 512 | 513 | def set_gain(self, gain): 514 | result = NRSC5.libnrsc5.nrsc5_set_gain(self.radio, ctypes.c_float(gain)) 515 | if result != 0: 516 | raise NRSC5Error("Failed to set gain.") 517 | 518 | def set_auto_gain(self, enabled): 519 | NRSC5.libnrsc5.nrsc5_set_auto_gain(self.radio, int(enabled)) 520 | 521 | def _set_callback(self): 522 | def callback_closure(evt, opaque): 523 | self._callback_wrapper(evt) 524 | 525 | self.callback_func = ctypes.CFUNCTYPE(None, ctypes.POINTER(_Event), ctypes.c_void_p)(callback_closure) 526 | NRSC5.libnrsc5.nrsc5_set_callback(self.radio, self.callback_func, None) 527 | 528 | def pipe_samples_cu8(self, samples): 529 | if len(samples) % 4 != 0: 530 | raise NRSC5Error("len(samples) must be a multiple of 4.") 531 | result = NRSC5.libnrsc5.nrsc5_pipe_samples_cu8(self.radio, samples, len(samples)) 532 | if result != 0: 533 | raise NRSC5Error("Failed to pipe samples.") 534 | 535 | def pipe_samples_cs16(self, samples): 536 | if len(samples) % 4 != 0: 537 | raise NRSC5Error("len(samples) must be a multiple of 4.") 538 | result = NRSC5.libnrsc5.nrsc5_pipe_samples_cs16(self.radio, samples, len(samples) // 2) 539 | if result != 0: 540 | raise NRSC5Error("Failed to pipe samples.") 541 | -------------------------------------------------------------------------------- /nrsc5_gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # NRSC5 GUI - A graphical interface for nrsc5 4 | # Copyright (C) 2017-2019 Cody Nybo & Clayton Smith 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import glob 20 | import io 21 | import json 22 | import logging 23 | import math 24 | import os 25 | import queue 26 | import re 27 | import sys 28 | import threading 29 | import time 30 | from datetime import datetime, timezone 31 | from PIL import Image, ImageFont, ImageDraw 32 | import pyaudio 33 | 34 | import gi 35 | gi.require_version("Gtk", "3.0") 36 | from gi.repository import Gtk, GObject, Gdk, GdkPixbuf, GLib 37 | 38 | import nrsc5 39 | 40 | 41 | class NRSC5GUI(object): 42 | AUDIO_SAMPLE_RATE = 44100 43 | AUDIO_SAMPLES_PER_FRAME = 2048 44 | MAP_FILE = "map.png" 45 | VERSION = "2.0.0" 46 | 47 | log_level = 20 # decrease to 10 to enable debug logs 48 | 49 | def __init__(self): 50 | logging.basicConfig(level=self.log_level, 51 | format="%(asctime)s %(levelname)-5s %(filename)s:%(lineno)d: %(message)s", 52 | datefmt="%H:%M:%S") 53 | 54 | GObject.threads_init() 55 | 56 | self.get_controls() # get controls and windows 57 | self.init_stream_info() # initilize stream info and clear status widgets 58 | 59 | self.radio = None 60 | self.audio_queue = queue.Queue(maxsize=64) 61 | self.audio_thread = threading.Thread(target=self.audio_worker) 62 | self.playing = False 63 | self.status_timer = None 64 | self.image_changed = False 65 | self.xhdr_changed = False 66 | self.last_image = "" 67 | self.last_xhdr = "" 68 | self.station_str = "" # current station frequency (string) 69 | self.stream_num = 0 70 | self.bookmarks = [] 71 | self.station_logos = {} 72 | self.bookmarked = False 73 | self.map_viewer = None 74 | self.weather_maps = [] # list of current weathermaps sorted by time 75 | self.traffic_map = Image.new("RGB", (600, 600), "white") 76 | self.map_tiles = [[0, 0, 0], [0, 0, 0], [0, 0, 0]] 77 | self.map_data = { 78 | "map_mode": 1, 79 | "weather_time": 0, 80 | "weather_pos": [0, 0, 0, 0], 81 | "weather_now": "", 82 | "weather_id": "", 83 | "viewer_config": { 84 | "mode": 1, 85 | "animate": False, 86 | "scale": True, 87 | "window_pos": (0, 0), 88 | "window_size": (782, 632), 89 | "animation_speed": 0.5 90 | } 91 | } 92 | 93 | # setup bookmarks listview 94 | name_renderer = Gtk.CellRendererText() 95 | name_renderer.set_property("editable", True) 96 | name_renderer.connect("edited", self.on_bookmark_name_edited) 97 | 98 | col_station = Gtk.TreeViewColumn("Station", Gtk.CellRendererText(), text=0) 99 | col_name = Gtk.TreeViewColumn("Name", name_renderer, text=1) 100 | 101 | col_station.set_resizable(True) 102 | col_station.set_sort_column_id(2) 103 | col_name.set_resizable(True) 104 | col_name.set_sort_column_id(1) 105 | 106 | self.lv_bookmarks.append_column(col_station) 107 | self.lv_bookmarks.append_column(col_name) 108 | 109 | self.load_settings() 110 | self.process_weather_maps() 111 | 112 | self.audio_thread.start() 113 | 114 | def display_logo(self): 115 | if self.station_str in self.station_logos: 116 | # show station logo if it's cached 117 | logo = os.path.join(self.aas_dir, self.station_logos[self.station_str][self.stream_num]) 118 | if os.path.isfile(logo): 119 | self.stream_info["logo"] = self.station_logos[self.station_str][self.stream_num] 120 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo) 121 | pixbuf = pixbuf.scale_simple(200, 200, GdkPixbuf.InterpType.HYPER) 122 | self.img_cover.set_from_pixbuf(pixbuf) 123 | else: 124 | # add entry in database for the station if it doesn't exist 125 | self.station_logos[self.station_str] = ["", "", "", ""] 126 | 127 | def on_btn_play_clicked(self, _btn): 128 | """start playback""" 129 | if not self.playing: 130 | 131 | # update all of the spin buttons to prevent the text from sticking 132 | self.spin_freq.update() 133 | self.spin_stream.update() 134 | self.spin_gain.update() 135 | self.spin_ppm.update() 136 | self.spin_rtl.update() 137 | 138 | # start the timer 139 | self.status_timer = threading.Timer(1, self.check_status) 140 | self.status_timer.start() 141 | 142 | # disable the controls 143 | self.spin_freq.set_sensitive(False) 144 | self.spin_gain.set_sensitive(False) 145 | self.spin_ppm.set_sensitive(False) 146 | self.spin_rtl.set_sensitive(False) 147 | self.btn_play.set_sensitive(False) 148 | self.btn_stop.set_sensitive(True) 149 | self.cb_auto_gain.set_sensitive(False) 150 | self.playing = True 151 | self.last_xhdr = "" 152 | 153 | self.play() 154 | 155 | self.station_str = str(self.spin_freq.get_value()) 156 | self.stream_num = int(self.spin_stream.get_value())-1 157 | 158 | self.display_logo() 159 | 160 | # check if station is bookmarked 161 | self.bookmarked = False 162 | freq = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value()) 163 | for bookmark in self.bookmarks: 164 | if bookmark[2] == freq: 165 | self.bookmarked = True 166 | break 167 | 168 | self.btn_bookmark.set_sensitive(not self.bookmarked) 169 | if self.notebook_main.get_current_page() != 3: 170 | self.btn_delete.set_sensitive(self.bookmarked) 171 | 172 | def on_btn_stop_clicked(self, _btn): 173 | """stop playback""" 174 | if self.playing: 175 | self.playing = False 176 | 177 | # shutdown nrsc5 178 | if self.radio: 179 | self.radio.stop() 180 | self.radio.close() 181 | self.radio = None 182 | 183 | # stop timer 184 | self.status_timer.cancel() 185 | self.status_timer = None 186 | 187 | # enable controls 188 | if not self.cb_auto_gain.get_active(): 189 | self.spin_gain.set_sensitive(True) 190 | self.spin_freq.set_sensitive(True) 191 | self.spin_ppm.set_sensitive(True) 192 | self.spin_rtl.set_sensitive(True) 193 | self.btn_play.set_sensitive(True) 194 | self.btn_stop.set_sensitive(False) 195 | self.btn_bookmark.set_sensitive(False) 196 | self.cb_auto_gain.set_sensitive(True) 197 | 198 | # clear stream info 199 | self.init_stream_info() 200 | 201 | self.btn_bookmark.set_sensitive(False) 202 | if self.notebook_main.get_current_page() != 3: 203 | self.btn_delete.set_sensitive(False) 204 | 205 | def on_btn_bookmark_clicked(self, _btn): 206 | # pack frequency and channel number into one int 207 | freq = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value()) 208 | 209 | # create bookmark 210 | bookmark = [ 211 | "{:4.1f}-{:1.0f}".format(self.spin_freq.get_value(), self.spin_stream.get_value()), 212 | self.stream_info["callsign"], 213 | freq 214 | ] 215 | self.bookmarked = True 216 | self.bookmarks.append(bookmark) 217 | self.ls_bookmarks.append(bookmark) 218 | self.btn_bookmark.set_sensitive(False) 219 | 220 | if self.notebook_main.get_current_page() != 3: 221 | self.btn_delete.set_sensitive(True) 222 | 223 | def on_btn_delete_clicked(self, _btn): 224 | # select current station if not on bookmarks page 225 | if self.notebook_main.get_current_page() != 3: 226 | station = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value()) 227 | for i in range(len(self.ls_bookmarks)): 228 | if self.ls_bookmarks[i][2] == station: 229 | self.lv_bookmarks.set_cursor(i) 230 | break 231 | 232 | # get station of selected row 233 | model, tree_iter = self.lv_bookmarks.get_selection().get_selected() 234 | station = model.get_value(tree_iter, 2) 235 | 236 | # remove row 237 | model.remove(tree_iter) 238 | 239 | # remove bookmark 240 | for i in range(len(self.bookmarks)): 241 | if self.bookmarks[i][2] == station: 242 | self.bookmarks.pop(i) 243 | break 244 | 245 | if self.notebook_main.get_current_page() != 3 and self.playing: 246 | self.btn_bookmark.set_sensitive(True) 247 | self.bookmarked = False 248 | 249 | def on_btn_about_activate(self, _btn): 250 | """sets up and displays about dialog""" 251 | if self.about_dialog: 252 | self.about_dialog.present() 253 | return 254 | 255 | authors = [ 256 | "Cody Nybo ", 257 | "Clayton Smith ", 258 | ] 259 | 260 | nrsc5_gui_license = """ 261 | NRSC5 GUI - A graphical interface for nrsc5 262 | Copyright (C) 2017-2019 Cody Nybo & Clayton Smith 263 | 264 | This program is free software: you can redistribute it and/or modify 265 | it under the terms of the GNU General Public License as published by 266 | the Free Software Foundation, either version 3 of the License, or 267 | (at your option) any later version. 268 | 269 | This program is distributed in the hope that it will be useful, 270 | but WITHOUT ANY WARRANTY; without even the implied warranty of 271 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 272 | GNU General Public License for more details. 273 | 274 | You should have received a copy of the GNU General Public License 275 | along with this program. If not, see .""" 276 | 277 | about_dialog = Gtk.AboutDialog() 278 | about_dialog.set_transient_for(self.main_window) 279 | about_dialog.set_destroy_with_parent(True) 280 | about_dialog.set_name("NRSC5 GUI") 281 | about_dialog.set_version(self.VERSION) 282 | about_dialog.set_copyright("Copyright © 2017-2019 Cody Nybo & Clayton Smith") 283 | about_dialog.set_website("https://github.com/cmnybo/nrsc5-gui") 284 | about_dialog.set_comments("A graphical interface for nrsc5.") 285 | about_dialog.set_authors(authors) 286 | about_dialog.set_license(nrsc5_gui_license) 287 | about_dialog.set_logo(GdkPixbuf.Pixbuf.new_from_file("logo.png")) 288 | 289 | # callbacks for destroying the dialog 290 | def close(dialog, _response, editor): 291 | editor.about_dialog = None 292 | dialog.destroy() 293 | 294 | def delete_event(_dialog, _event, editor): 295 | editor.about_dialog = None 296 | return True 297 | 298 | about_dialog.connect("response", close, self) 299 | about_dialog.connect("delete-event", delete_event, self) 300 | 301 | self.about_dialog = about_dialog 302 | about_dialog.show() 303 | 304 | def on_spin_stream_value_changed(self, _spin): 305 | self.last_xhdr = "" 306 | self.stream_info["title"] = "" 307 | self.stream_info["album"] = "" 308 | self.stream_info["artist"] = "" 309 | self.stream_info["cover"] = "" 310 | self.stream_info["logo"] = "" 311 | self.stream_info["bitrate"] = 0 312 | self.stream_num = int(self.spin_stream.get_value())-1 313 | if self.playing: 314 | self.display_logo() 315 | 316 | def on_cb_auto_gain_toggled(self, btn): 317 | self.spin_gain.set_sensitive(not btn.get_active()) 318 | self.lbl_gain.set_visible(btn.get_active()) 319 | 320 | def on_lv_bookmarks_row_activated(self, treeview, path, _view_column): 321 | if path: 322 | # get station from bookmark row 323 | tree_iter = treeview.get_model().get_iter(path[0]) 324 | station = treeview.get_model().get_value(tree_iter, 2) 325 | 326 | # set frequency and stream 327 | self.spin_freq.set_value(float(int(station/10)/10.0)) 328 | self.spin_stream.set_value(station % 10) 329 | 330 | # stop playback if playing 331 | if self.playing: 332 | self.on_btn_stop_clicked(None) 333 | 334 | # play bookmarked station 335 | self.on_btn_play_clicked(None) 336 | 337 | def on_lv_bookmarks_sel_changed(self, _tree_selection): 338 | # enable delete button if bookmark is selected 339 | _, pathlist = self.lv_bookmarks.get_selection().get_selected_rows() 340 | self.btn_delete.set_sensitive(len(pathlist) != 0) 341 | 342 | def on_bookmark_name_edited(self, _cell, path, text, _data=None): 343 | # update name in listview 344 | tree_iter = self.ls_bookmarks.get_iter(path) 345 | self.ls_bookmarks.set(tree_iter, 1, text) 346 | 347 | # update name in bookmarks array 348 | for bookmark in self.bookmarks: 349 | if bookmark[2] == self.ls_bookmarks[path][2]: 350 | bookmark[1] = text 351 | break 352 | 353 | def on_notebook_main_switch_page(self, _notebook, _page, page_num): 354 | # disable delete button if not on bookmarks page and station is not bookmarked 355 | if page_num != 3 and (not self.bookmarked or not self.playing): 356 | self.btn_delete.set_sensitive(False) 357 | # enable delete button if not on bookmarks page and station is bookmarked 358 | elif page_num != 3 and self.bookmarked: 359 | self.btn_delete.set_sensitive(True) 360 | # enable delete button if on bookmarks page and a bookmark is selected 361 | else: 362 | _, tree_iter = self.lv_bookmarks.get_selection().get_selected() 363 | self.btn_delete.set_sensitive(tree_iter is not None) 364 | 365 | def on_rad_map_toggled(self, btn): 366 | if btn.get_active(): 367 | if btn == self.rad_map_traffic: 368 | self.map_data["map_mode"] = 0 369 | map_file = os.path.join("map", "traffic_map.png") 370 | if os.path.isfile(map_file): 371 | map_img = Image.open(map_file).resize((200, 200), Image.LANCZOS) 372 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img)) 373 | else: 374 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) 375 | 376 | elif btn == self.rad_map_weather: 377 | self.map_data["map_mode"] = 1 378 | if os.path.isfile(self.map_data["weather_now"]): 379 | map_img = Image.open(self.map_data["weather_now"]).resize((200, 200), Image.LANCZOS) 380 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img)) 381 | else: 382 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) 383 | 384 | def on_btn_map_clicked(self, _btn): 385 | """open map viewer window""" 386 | if self.map_viewer is None: 387 | self.map_viewer = NRSC5Map(self, self.map_viewer_callback, self.map_data) 388 | self.map_viewer.map_window.show() 389 | 390 | def map_viewer_callback(self): 391 | """delete the map viewer""" 392 | self.map_viewer = None 393 | 394 | def play(self): 395 | self.radio = nrsc5.NRSC5(lambda type, evt: self.callback(type, evt)) 396 | self.radio.open(int(self.spin_rtl.get_value())) 397 | self.radio.set_auto_gain(self.cb_auto_gain.get_active()) 398 | self.radio.set_freq_correction(int(self.spin_ppm.get_value())) 399 | 400 | # set gain if auto gain is not selected 401 | if not self.cb_auto_gain.get_active(): 402 | self.stream_info["gain"] = self.spin_gain.get_value() 403 | self.radio.set_gain(self.stream_info["gain"]) 404 | 405 | self.radio.set_frequency(self.spin_freq.get_value() * 1e6) 406 | self.radio.start() 407 | 408 | def check_status(self): 409 | """update status information""" 410 | def update(): 411 | Gdk.threads_enter() 412 | try: 413 | image_path = "" 414 | image = "" 415 | ber = [self.stream_info["ber"][i]*100 for i in range(4)] 416 | self.txt_title.set_text(self.stream_info["title"]) 417 | self.txt_artist.set_text(self.stream_info["artist"]) 418 | self.txt_album.set_text(self.stream_info["album"]) 419 | self.lbl_bitrate.set_label("{:3.1f} kbps".format(self.stream_info["bitrate"])) 420 | self.lbl_bitrate2.set_label("{:3.1f} kbps".format(self.stream_info["bitrate"])) 421 | self.lbl_error.set_label("{:2.2f}% Error ".format(ber[1])) 422 | self.lbl_callsign.set_label(" " + self.stream_info["callsign"]) 423 | self.lbl_name.set_label(self.stream_info["callsign"]) 424 | self.lbl_slogan.set_label(self.stream_info["slogan"]) 425 | self.lbl_slogan.set_tooltip_text(self.stream_info["slogan"]) 426 | self.lbl_mer_lower.set_label("{:1.2f} dB".format(self.stream_info["mer"][0])) 427 | self.lbl_mer_upper.set_label("{:1.2f} dB".format(self.stream_info["mer"][1])) 428 | self.lbl_ber_now.set_label("{:1.3f}% (Now)".format(ber[0])) 429 | self.lbl_ber_avg.set_label("{:1.3f}% (Avg)".format(ber[1])) 430 | self.lbl_ber_min.set_label("{:1.3f}% (Min)".format(ber[2])) 431 | self.lbl_ber_max.set_label("{:1.3f}% (Max)".format(ber[3])) 432 | 433 | if self.cb_auto_gain.get_active(): 434 | self.spin_gain.set_value(self.stream_info["gain"]) 435 | self.lbl_gain.set_label("{:2.1f}dB".format(self.stream_info["gain"])) 436 | 437 | if self.last_xhdr == 0: 438 | image_path = os.path.join(self.aas_dir, self.stream_info["cover"]) 439 | image = self.stream_info["cover"] 440 | elif self.last_xhdr == 1: 441 | image_path = os.path.join(self.aas_dir, self.stream_info["logo"]) 442 | image = self.stream_info["logo"] 443 | if not os.path.isfile(image_path): 444 | self.img_cover.clear() 445 | 446 | # resize and display image if it changed and exists 447 | if self.xhdr_changed and self.last_image != image and os.path.isfile(image_path): 448 | self.xhdr_changed = False 449 | self.last_image = image 450 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path) 451 | pixbuf = pixbuf.scale_simple(200, 200, GdkPixbuf.InterpType.HYPER) 452 | self.img_cover.set_from_pixbuf(pixbuf) 453 | logging.debug("Image changed") 454 | finally: 455 | Gdk.threads_leave() 456 | 457 | if self.playing: 458 | GObject.idle_add(update) 459 | self.status_timer = threading.Timer(1, self.check_status) 460 | self.status_timer.start() 461 | 462 | def process_traffic_map(self, filename, data): 463 | regex = re.compile(r"^TMT_.*_([1-3])_([1-3])_(\d{8}_\d{4}).*$") 464 | match = regex.match(filename) 465 | 466 | if match: 467 | tile_x = int(match.group(1))-1 468 | tile_y = int(match.group(2))-1 469 | utc_time = datetime.strptime(match.group(3), "%Y%m%d_%H%M").replace(tzinfo=timezone.utc) 470 | timestamp = int(utc_time.timestamp()) 471 | 472 | # check if the tile has already been loaded 473 | if self.map_tiles[tile_x][tile_y] == timestamp: 474 | return # no need to recreate the map if it hasn't changed 475 | 476 | logging.debug("Got traffic map tile: %s, %s", tile_x, tile_y) 477 | 478 | self.map_tiles[tile_x][tile_y] = timestamp 479 | self.traffic_map.paste(Image.open(io.BytesIO(data)), (tile_y*200, tile_x*200)) 480 | 481 | # check if all of the tiles are loaded 482 | if self.check_tiles(timestamp): 483 | logging.debug("Got complete traffic map") 484 | self.traffic_map.save(os.path.join("map", "traffic_map.png")) 485 | 486 | # display on map page 487 | if self.rad_map_traffic.get_active(): 488 | img_map = self.traffic_map.resize((200, 200), Image.LANCZOS) 489 | self.img_map.set_from_pixbuf(img_to_pixbuf(img_map)) 490 | 491 | if self.map_viewer is not None: 492 | self.map_viewer.updated() 493 | 494 | def process_weather_overlay(self, filename, data): 495 | regex = re.compile(r"^DWRO_(.*)_.*_(\d{8}_\d{4}).*$") 496 | match = regex.match(filename) 497 | 498 | if match: 499 | utc_time = datetime.strptime(match.group(2), "%Y%m%d_%H%M").replace(tzinfo=timezone.utc) 500 | timestamp = int(utc_time.timestamp()) 501 | map_id = self.map_data["weather_id"] 502 | 503 | if match.group(1) != map_id: 504 | logging.error("Received weather overlay with the wrong ID: %s", match.group(1)) 505 | return 506 | 507 | if self.map_data["weather_time"] == timestamp: 508 | return # no need to recreate the map if it hasn't changed 509 | 510 | logging.debug("Got weather overlay") 511 | 512 | self.map_data["weather_time"] = timestamp 513 | weather_map_path = os.path.join("map", "weather_map_{}_{}.png".format(map_id, timestamp)) 514 | 515 | # create weather map 516 | try: 517 | map_path = os.path.join("map", "base_map_" + map_id + ".png") 518 | if not os.path.isfile(map_path): 519 | self.make_base_map(self.map_data["weather_id"], self.map_data["weather_pos"]) 520 | 521 | img_map = Image.open(map_path).convert("RGBA") 522 | timestamp_pos = (img_map.size[0]-235, img_map.size[1]-29) 523 | img_ts = self.make_timestamp(utc_time.astimezone(), img_map.size, timestamp_pos) 524 | img_radar = Image.open(io.BytesIO(data)).convert("RGBA") 525 | img_radar = img_radar.resize(img_map.size, Image.LANCZOS) 526 | img_map = Image.alpha_composite(img_map, img_radar) 527 | img_map = Image.alpha_composite(img_map, img_ts) 528 | img_map.save(weather_map_path) 529 | self.map_data["weather_now"] = weather_map_path 530 | 531 | # display on map page 532 | if self.rad_map_weather.get_active(): 533 | img_map = img_map.resize((200, 200), Image.LANCZOS) 534 | self.img_map.set_from_pixbuf(img_to_pixbuf(img_map)) 535 | 536 | self.process_weather_maps() # get rid of old maps and add new ones to the list 537 | if self.map_viewer is not None: 538 | self.map_viewer.updated() 539 | 540 | except OSError: 541 | logging.error("Error creating weather map") 542 | self.map_data["weather_time"] = 0 543 | 544 | def process_weather_info(self, data): 545 | weather_id = None 546 | weather_pos = None 547 | 548 | for line in data.decode().split("\n"): 549 | if "DWR_Area_ID=" in line: 550 | regex = re.compile("^DWR_Area_ID=\"(.+)\"$") 551 | match = regex.match(line) 552 | weather_id = match.group(1) 553 | 554 | elif "Coordinates=" in line: 555 | regex = re.compile(r"^Coordinates=.*\((.*),(.*)\).*\((.*),(.*)\).*$") 556 | match = regex.match(line) 557 | weather_pos = [float(match.group(i)) for i in range(1, 5)] 558 | 559 | if weather_id is not None and weather_pos is not None: 560 | if self.map_data["weather_id"] != weather_id or self.map_data["weather_pos"] != weather_pos: 561 | logging.debug("Got position: (%n, %n) (%n, %n)", *weather_pos) 562 | self.map_data["weather_id"] = weather_id 563 | self.map_data["weather_pos"] = weather_pos 564 | 565 | self.make_base_map(weather_id, weather_pos) 566 | self.weather_maps = [] 567 | self.process_weather_maps() 568 | 569 | def process_weather_maps(self): 570 | number_of_maps = 0 571 | regex = re.compile("^map.weather_map_([a-zA-Z0-9]+)_([0-9]+).png") 572 | now = time.time() 573 | files = glob.glob(os.path.join("map", "weather_map_") + "*.png") 574 | files.sort() 575 | for file in files: 576 | match = regex.match(file) 577 | if match: 578 | map_id = match.group(1) 579 | timestamp = int(match.group(2)) 580 | 581 | # remove weather maps older than 12 hours 582 | if now - timestamp > 60*60*12: 583 | try: 584 | if file in self.weather_maps: 585 | self.weather_maps.pop(self.weather_maps.index(file)) 586 | os.remove(file) 587 | logging.debug("Deleted old weather map: %s", file) 588 | except OSError: 589 | logging.error("Failed to delete old weather map: %s", file) 590 | 591 | # skip if not the correct location 592 | elif map_id == self.map_data["weather_id"]: 593 | if file not in self.weather_maps: 594 | self.weather_maps.append(file) 595 | number_of_maps += 1 596 | 597 | logging.debug("Found %s weather maps", number_of_maps) 598 | 599 | @staticmethod 600 | def map_image_coordinates(lat_degrees, lon_degrees): 601 | """convert latitude & longitude to x & y cooordinates in the map""" 602 | first_tile_x, first_tile_y = 35, 84 603 | zoom_level = 8 604 | tile_size = 256 605 | 606 | map_x = (1 + math.radians(lon_degrees) / math.pi) / 2 607 | map_y = (1 - math.asinh(math.tan(math.radians(lat_degrees))) / math.pi) / 2 608 | tile_x = map_x * (2**zoom_level) - first_tile_x 609 | tile_y = map_y * (2**zoom_level) - first_tile_y 610 | return int(round(tile_x * tile_size)), int(round(tile_y * tile_size)) 611 | 612 | def make_base_map(self, map_id, pos): 613 | """crop the map to the area needed for weather radar""" 614 | map_path = os.path.join("map", "base_map_" + map_id + ".png") 615 | if os.path.isfile(self.MAP_FILE): 616 | if not os.path.isfile(map_path): 617 | logging.debug("Creating new map: %s", map_path) 618 | map_upper_left = self.map_image_coordinates(pos[0], pos[1]) 619 | map_lower_right = self.map_image_coordinates(pos[2], pos[3]) 620 | map_img = Image.open(self.MAP_FILE).crop(map_upper_left + map_lower_right) 621 | map_img.save(map_path) 622 | logging.debug("Finished creating map") 623 | else: 624 | logging.error("Map file not found: %s", self.MAP_FILE) 625 | map_img = Image.new("RGBA", (pos[2]-pos[1], pos[3]-pos[1]), "white") 626 | map_img.save(map_path) 627 | 628 | def check_tiles(self, timestamp): 629 | """check if all the tiles have been received""" 630 | for i in range(3): 631 | for j in range(3): 632 | if self.map_tiles[i][j] != timestamp: 633 | return False 634 | return True 635 | 636 | @staticmethod 637 | def make_timestamp(local_time, size, pos): 638 | """create a timestamp image to overlay on the weathermap""" 639 | pos_x, pos_y = pos 640 | text = datetime.strftime(local_time, "%Y-%m-%d %H:%M") 641 | img_ts = Image.new("RGBA", size, (0, 0, 0, 0)) 642 | draw = ImageDraw.Draw(img_ts) 643 | font = ImageFont.truetype("DejaVuSansMono.ttf", 24) 644 | draw.rectangle((pos_x, pos_y, pos_x+231, pos_y+25), outline="black", fill=(128, 128, 128, 96)) 645 | draw.text((pos_x+3, pos_y), text, fill="black", font=font) 646 | return img_ts 647 | 648 | def audio_worker(self): 649 | audio = pyaudio.PyAudio() 650 | try: 651 | index = audio.get_default_output_device_info()["index"] 652 | stream = audio.open(format=pyaudio.paInt16, 653 | channels=2, 654 | rate=self.AUDIO_SAMPLE_RATE, 655 | output_device_index=index, 656 | output=True) 657 | except OSError: 658 | logging.warning("No audio output device available") 659 | stream = None 660 | 661 | while True: 662 | samples = self.audio_queue.get() 663 | if samples is None: 664 | break 665 | if stream: 666 | stream.write(samples) 667 | self.audio_queue.task_done() 668 | 669 | if stream: 670 | stream.stop_stream() 671 | stream.close() 672 | audio.terminate() 673 | 674 | def update_bitrate(self, bits): 675 | kbps = bits * self.AUDIO_SAMPLE_RATE / self.AUDIO_SAMPLES_PER_FRAME / 1000 676 | if self.stream_info["bitrate"] == 0: 677 | self.stream_info["bitrate"] = kbps 678 | else: 679 | self.stream_info["bitrate"] = 0.99 * self.stream_info["bitrate"] + 0.01 * kbps 680 | 681 | def update_ber(self, cber): 682 | ber = self.stream_info["ber"] 683 | if ber[0] == ber[1] == ber[2] == ber[3] == 0: 684 | ber[0] = cber 685 | ber[1] = cber 686 | ber[2] = cber 687 | ber[3] = cber 688 | else: 689 | ber[0] = cber 690 | ber[1] = 0.9 * ber[1] + 0.1 * cber 691 | if cber < ber[2]: 692 | ber[2] = cber 693 | if cber > ber[3]: 694 | ber[3] = cber 695 | 696 | def callback(self, evt_type, evt): 697 | if evt_type == nrsc5.EventType.LOST_DEVICE: 698 | pass # TODO: update the GUI? 699 | elif evt_type == nrsc5.EventType.SYNC: 700 | self.stream_info["gain"] = self.radio.get_gain() 701 | # TODO: update the GUI? 702 | elif evt_type == nrsc5.EventType.LOST_SYNC: 703 | pass # TODO: update the GUI? 704 | elif evt_type == nrsc5.EventType.MER: 705 | self.stream_info["mer"] = [evt.lower, evt.upper] 706 | elif evt_type == nrsc5.EventType.BER: 707 | self.update_ber(evt.cber) 708 | elif evt_type == nrsc5.EventType.HDC: 709 | if evt.program == self.stream_num: 710 | self.update_bitrate(len(evt.data) * 8) 711 | elif evt_type == nrsc5.EventType.AUDIO: 712 | if evt.program == self.stream_num: 713 | self.audio_queue.put(evt.data) 714 | elif evt_type == nrsc5.EventType.ID3: 715 | if evt.program == self.stream_num: 716 | if evt.title: 717 | self.stream_info["title"] = evt.title 718 | if evt.artist: 719 | self.stream_info["artist"] = evt.artist 720 | if evt.album: 721 | self.stream_info["album"] = evt.album 722 | if evt.xhdr: 723 | if evt.xhdr.param != self.last_xhdr: 724 | self.last_xhdr = evt.xhdr.param 725 | self.xhdr_changed = True 726 | logging.debug("XHDR changed: %s", evt.xhdr.param) 727 | elif evt_type == nrsc5.EventType.SIG: 728 | for service in evt: 729 | if service.type == nrsc5.ServiceType.AUDIO: 730 | for component in service.components: 731 | if component.type == nrsc5.ComponentType.DATA: 732 | if component.data.mime == nrsc5.MIMEType.PRIMARY_IMAGE: 733 | self.streams[service.number-1]["image"] = component.data.port 734 | elif component.data.mime == nrsc5.MIMEType.STATION_LOGO: 735 | self.streams[service.number-1]["logo"] = component.data.port 736 | elif service.type == nrsc5.ServiceType.DATA: 737 | for component in service.components: 738 | if component.type == nrsc5.ComponentType.DATA: 739 | if component.data.mime == nrsc5.MIMEType.TTN_STM_TRAFFIC: 740 | self.traffic_port = component.data.port 741 | elif component.data.mime == nrsc5.MIMEType.TTN_STM_WEATHER: 742 | self.weather_port = component.data.port 743 | elif evt_type == nrsc5.EventType.LOT: 744 | logging.debug("LOT port=%s", evt.port) 745 | 746 | if self.map_dir is not None: 747 | if evt.port == self.traffic_port: 748 | if evt.name.startswith("TMT_"): 749 | self.process_traffic_map(evt.name, evt.data) 750 | elif evt.port == self.weather_port: 751 | if evt.name.startswith("DWRO_"): 752 | self.process_weather_overlay(evt.name, evt.data) 753 | elif evt.name.startswith("DWRI_"): 754 | self.process_weather_info(evt.data) 755 | 756 | if self.aas_dir is not None: 757 | path = os.path.join(self.aas_dir, evt.name) 758 | for i, stream in enumerate(self.streams): 759 | if evt.port == stream.get("image"): 760 | logging.debug("Got album cover: %s", evt.name) 761 | with open(path, "wb") as file: 762 | file.write(evt.data) 763 | if i == self.stream_num: 764 | self.stream_info["cover"] = evt.name 765 | elif evt.port == stream.get("logo"): 766 | logging.debug("Got station logo: %s", evt.name) 767 | with open(path, "wb") as file: 768 | file.write(evt.data) 769 | self.station_logos[self.station_str][i] = evt.name 770 | if i == self.stream_num: 771 | self.stream_info["logo"] = evt.name 772 | 773 | elif evt_type == nrsc5.EventType.SIS: 774 | if evt.name: 775 | self.stream_info["callsign"] = evt.name 776 | if evt.slogan: 777 | self.stream_info["slogan"] = evt.slogan 778 | 779 | def get_controls(self): 780 | # setup gui 781 | builder = Gtk.Builder() 782 | builder.add_from_file("main_form.glade") 783 | builder.connect_signals(self) 784 | 785 | # Windows 786 | self.main_window = builder.get_object("main_window") 787 | self.main_window.connect("delete-event", self.shutdown) 788 | self.main_window.connect("destroy", Gtk.main_quit) 789 | self.about_dialog = None 790 | 791 | # get controls 792 | self.notebook_main = builder.get_object("notebook_main") 793 | self.img_cover = builder.get_object("img_cover") 794 | self.img_map = builder.get_object("img_map") 795 | self.spin_freq = builder.get_object("spin_freq") 796 | self.spin_stream = builder.get_object("spin_stream") 797 | self.spin_gain = builder.get_object("spin_gain") 798 | self.spin_ppm = builder.get_object("spin_ppm") 799 | self.spin_rtl = builder.get_object("spin_rtl") 800 | self.cb_auto_gain = builder.get_object("cb_auto_gain") 801 | self.btn_play = builder.get_object("btn_play") 802 | self.btn_stop = builder.get_object("btn_stop") 803 | self.btn_bookmark = builder.get_object("btn_bookmark") 804 | self.btn_delete = builder.get_object("btn_delete") 805 | self.rad_map_traffic = builder.get_object("rad_map_traffic") 806 | self.rad_map_weather = builder.get_object("rad_map_weather") 807 | self.txt_title = builder.get_object("txt_title") 808 | self.txt_artist = builder.get_object("txt_artist") 809 | self.txt_album = builder.get_object("txt_album") 810 | self.lbl_name = builder.get_object("lbl_name") 811 | self.lbl_slogan = builder.get_object("lbl_slogan") 812 | self.lbl_callsign = builder.get_object("lbl_callsign") 813 | self.lbl_gain = builder.get_object("lbl_gain") 814 | self.lbl_bitrate = builder.get_object("lbl_bitrate") 815 | self.lbl_bitrate2 = builder.get_object("lbl_bitrate2") 816 | self.lbl_error = builder.get_object("lbl_error") 817 | self.lbl_mer_lower = builder.get_object("lbl_mer_lower") 818 | self.lbl_mer_upper = builder.get_object("lbl_mer_upper") 819 | self.lbl_ber_now = builder.get_object("lbl_ber_now") 820 | self.lbl_ber_avg = builder.get_object("lbl_ber_avg") 821 | self.lbl_ber_min = builder.get_object("lbl_ber_min") 822 | self.lbl_ber_max = builder.get_object("lbl_ber_max") 823 | self.lv_bookmarks = builder.get_object("lv_bookmarks") 824 | self.ls_bookmarks = Gtk.ListStore(str, str, int) 825 | 826 | self.lv_bookmarks.set_model(self.ls_bookmarks) 827 | self.lv_bookmarks.get_selection().connect("changed", self.on_lv_bookmarks_sel_changed) 828 | 829 | def init_stream_info(self): 830 | self.stream_info = { 831 | "callsign": "", 832 | "slogan": "", 833 | "title": "", 834 | "album": "", 835 | "artist": "", 836 | "cover": "", 837 | "logo": "", 838 | "bitrate": 0, 839 | "mer": [0, 0], 840 | "ber": [0, 0, 0, 0], 841 | "gain": 0 842 | } 843 | 844 | self.streams = [{}, {}, {}, {}] 845 | self.traffic_port = -1 846 | self.weather_port = -1 847 | 848 | # clear status info 849 | self.lbl_callsign.set_label("") 850 | self.lbl_bitrate.set_label("") 851 | self.lbl_bitrate2.set_label("") 852 | self.lbl_error.set_label("") 853 | self.lbl_gain.set_label("") 854 | self.txt_title.set_text("") 855 | self.txt_artist.set_text("") 856 | self.txt_album.set_text("") 857 | self.img_cover.clear() 858 | self.lbl_name.set_label("") 859 | self.lbl_slogan.set_label("") 860 | self.lbl_slogan.set_tooltip_text("") 861 | self.lbl_mer_lower.set_label("") 862 | self.lbl_mer_upper.set_label("") 863 | self.lbl_ber_now.set_label("") 864 | self.lbl_ber_avg.set_label("") 865 | self.lbl_ber_min.set_label("") 866 | self.lbl_ber_max.set_label("") 867 | 868 | def load_settings(self): 869 | try: 870 | with open("station_logos.json", mode="r") as file: 871 | self.station_logos = json.load(file) 872 | except (OSError, json.decoder.JSONDecodeError): 873 | logging.warning("Unable to load station logo database") 874 | 875 | # load settings 876 | try: 877 | with open("config.json", mode="r") as file: 878 | config = json.load(file) 879 | 880 | if "map_data" in config: 881 | self.map_data = config["map_data"] 882 | if self.map_data["map_mode"] == 0: 883 | self.rad_map_traffic.set_active(True) 884 | self.rad_map_traffic.toggled() 885 | elif self.map_data["map_mode"] == 1: 886 | self.rad_map_weather.set_active(True) 887 | self.rad_map_weather.toggled() 888 | 889 | if "width" and "height" in config: 890 | self.main_window.resize(config["width"], config["height"]) 891 | 892 | self.main_window.move(config["window_x"], config["window_y"]) 893 | self.spin_freq.set_value(config["frequency"]) 894 | self.spin_stream.set_value(config["stream"]) 895 | self.spin_gain.set_value(config["gain"]) 896 | self.cb_auto_gain.set_active(config["auto_gain"]) 897 | self.spin_ppm.set_value(config["ppm_error"]) 898 | self.spin_rtl.set_value(config["rtl"]) 899 | self.bookmarks = config["bookmarks"] 900 | for bookmark in self.bookmarks: 901 | self.ls_bookmarks.append(bookmark) 902 | except (OSError, json.decoder.JSONDecodeError, KeyError): 903 | logging.warning("Unable to load config") 904 | 905 | # create aas directory 906 | self.aas_dir = os.path.join(sys.path[0], "aas") 907 | if not os.path.isdir(self.aas_dir): 908 | try: 909 | os.mkdir(self.aas_dir) 910 | except OSError: 911 | logging.error("Unable to create AAS directory") 912 | self.aas_dir = None 913 | 914 | # create map directory 915 | self.map_dir = os.path.join(sys.path[0], "map") 916 | if not os.path.isdir(self.map_dir): 917 | try: 918 | os.mkdir(self.map_dir) 919 | except OSError: 920 | logging.error("Unable to create map directory") 921 | self.map_dir = None 922 | 923 | def shutdown(self, *_args): 924 | # stop map viewer animation if it's running 925 | if self.map_viewer is not None and self.map_viewer.animate_timer is not None: 926 | self.map_viewer.animate_timer.cancel() 927 | self.map_viewer.animate_stop = True 928 | 929 | while self.map_viewer.animate_busy: 930 | logging.debug("Animation busy - stopping") 931 | if self.map_viewer.animate_timer is not None: 932 | self.map_viewer.animate_timer.cancel() 933 | time.sleep(0.25) 934 | 935 | self.playing = False 936 | 937 | # kill nrsc5 if it's running 938 | if self.radio: 939 | self.radio.stop() 940 | self.radio.close() 941 | self.radio = None 942 | 943 | # shut down status timer if it's running 944 | if self.status_timer is not None: 945 | self.status_timer.cancel() 946 | 947 | self.audio_queue.put(None) 948 | self.audio_thread.join() 949 | 950 | # save settings 951 | try: 952 | with open("config.json", mode="w") as file: 953 | window_x, window_y = self.main_window.get_position() 954 | width, height = self.main_window.get_size() 955 | config = { 956 | "config_version": self.VERSION, 957 | "window_x": window_x, 958 | "window_y": window_y, 959 | "width": width, 960 | "height": height, 961 | "frequency": self.spin_freq.get_value(), 962 | "stream": int(self.spin_stream.get_value()), 963 | "gain": self.spin_gain.get_value(), 964 | "auto_gain": self.cb_auto_gain.get_active(), 965 | "ppm_error": int(self.spin_ppm.get_value()), 966 | "rtl": int(self.spin_rtl.get_value()), 967 | "bookmarks": self.bookmarks, 968 | "map_data": self.map_data, 969 | } 970 | # sort bookmarks 971 | config["bookmarks"].sort(key=lambda t: t[2]) 972 | 973 | json.dump(config, file, indent=2) 974 | 975 | with open("station_logos.json", mode="w") as file: 976 | json.dump(self.station_logos, file, indent=2) 977 | except OSError: 978 | logging.error("Unable to save config") 979 | 980 | 981 | class NRSC5Map(object): 982 | def __init__(self, parent, callback, data): 983 | # setup gui 984 | builder = Gtk.Builder() 985 | builder.add_from_file("map_form.glade") 986 | builder.connect_signals(self) 987 | 988 | self.parent = parent 989 | self.callback = callback 990 | self.data = data # map data 991 | self.animate_timer = None 992 | self.animate_busy = False 993 | self.animate_stop = False 994 | self.weather_maps = parent.weather_maps # list of weather maps sorted by time 995 | self.map_index = 0 # the index of the next weather map to display 996 | 997 | # get the controls 998 | self.map_window = builder.get_object("map_window") 999 | self.img_map = builder.get_object("img_map") 1000 | self.rad_map_weather = builder.get_object("rad_map_weather") 1001 | self.rad_map_traffic = builder.get_object("rad_map_traffic") 1002 | self.chk_animate = builder.get_object("chk_animate") 1003 | self.chk_scale = builder.get_object("chk_scale") 1004 | self.spin_speed = builder.get_object("spin_speed") 1005 | self.adj_speed = builder.get_object("adj_speed") 1006 | self.img_key = builder.get_object("img_key") 1007 | 1008 | self.map_window.connect("delete-event", self.on_map_window_delete) 1009 | 1010 | self.config = data["viewer_config"] 1011 | self.map_window.resize(*self.config["window_size"]) 1012 | self.map_window.move(*self.config["window_pos"]) 1013 | if self.config["mode"] == 0: 1014 | self.rad_map_traffic.set_active(True) 1015 | elif self.config["mode"] == 1: 1016 | self.rad_map_weather.set_active(True) 1017 | self.set_map(self.config["mode"]) 1018 | 1019 | self.chk_animate.set_active(self.config["animate"]) 1020 | self.chk_scale.set_active(self.config["scale"]) 1021 | self.spin_speed.set_value(self.config["animation_speed"]) 1022 | 1023 | def on_rad_map_toggled(self, btn): 1024 | if btn.get_active(): 1025 | if btn == self.rad_map_traffic: 1026 | self.config["mode"] = 0 1027 | self.img_key.set_visible(False) 1028 | 1029 | # stop animation if it's enabled 1030 | if self.animate_timer is not None: 1031 | self.animate_timer.cancel() 1032 | self.animate_timer = None 1033 | 1034 | self.set_map(0) # show the traffic map 1035 | 1036 | elif btn == self.rad_map_weather: 1037 | self.config["mode"] = 1 1038 | self.img_key.set_visible(True) # show the key for the weather radar 1039 | 1040 | # check if animate is enabled and start animation 1041 | if self.config["animate"] and self.animate_timer is None: 1042 | self.animate_timer = threading.Timer(0.05, self.animate) 1043 | self.animate_timer.start() 1044 | 1045 | # no animation, just show the current map 1046 | elif not self.config["animate"]: 1047 | self.set_map(1) 1048 | 1049 | def on_chk_animate_toggled(self, _btn): 1050 | self.config["animate"] = self.chk_animate.get_active() 1051 | 1052 | if self.config["animate"] and self.config["mode"] == 1: 1053 | # start animation 1054 | self.animate_timer = threading.Timer(self.config["animation_speed"], self.animate) 1055 | self.animate_timer.start() 1056 | else: 1057 | # stop animation 1058 | if self.animate_timer is not None: 1059 | self.animate_timer.cancel() 1060 | self.animate_timer = None 1061 | self.map_index = len(self.weather_maps)-1 # reset the animation index 1062 | self.set_map(self.config["mode"]) # show the most recent map 1063 | 1064 | def on_chk_scale_toggled(self, btn): 1065 | self.config["scale"] = btn.get_active() 1066 | if self.config["mode"] == 1: 1067 | if self.config["animate"]: 1068 | i = len(self.weather_maps)-1 if (self.map_index-1 < 0) else self.map_index-1 1069 | self.show_image(self.weather_maps[i], self.config["scale"]) 1070 | else: 1071 | self.show_image(self.data["weather_now"], self.config["scale"]) 1072 | 1073 | def on_spin_speed_value_changed(self, _spn): 1074 | self.config["animation_speed"] = self.adj_speed.get_value() 1075 | 1076 | def on_map_window_delete(self, *_args): 1077 | # cancel the timer if it's running 1078 | if self.animate_timer is not None: 1079 | self.animate_timer.cancel() 1080 | self.animate_stop = True 1081 | 1082 | # wait for animation to finish 1083 | while self.animate_busy: 1084 | self.parent.debugLog("Waiting for animation to finish") 1085 | if self.animate_timer is not None: 1086 | self.animate_timer.cancel() 1087 | time.sleep(0.25) 1088 | 1089 | self.config["window_pos"] = self.map_window.get_position() 1090 | self.config["window_size"] = self.map_window.get_size() 1091 | self.callback() 1092 | 1093 | def animate(self): 1094 | filename = self.weather_maps[self.map_index] if self.weather_maps else "" 1095 | if os.path.isfile(filename): 1096 | self.animate_busy = True 1097 | 1098 | if self.config["scale"]: 1099 | map_img = img_to_pixbuf(Image.open(filename).resize((600, 600), Image.LANCZOS)) 1100 | else: 1101 | map_img = img_to_pixbuf(Image.open(filename)) 1102 | 1103 | if self.config["animate"] and self.config["mode"] == 1 and not self.animate_stop: 1104 | self.img_map.set_from_pixbuf(map_img) 1105 | self.map_index += 1 1106 | if self.map_index >= len(self.weather_maps): 1107 | self.map_index = 0 1108 | self.animate_timer = threading.Timer(2, self.animate) # show the last image for a longer time 1109 | else: 1110 | self.animate_timer = threading.Timer(self.config["animation_speed"], self.animate) 1111 | 1112 | self.animate_timer.start() 1113 | else: 1114 | self.animate_timer = None 1115 | 1116 | self.animate_busy = False 1117 | else: 1118 | self.chk_animate.set_active(False) # stop animation if image was not found 1119 | self.map_index = 0 1120 | 1121 | def show_image(self, filename, scale): 1122 | if os.path.isfile(filename): 1123 | if scale: 1124 | map_img = Image.open(filename).resize((600, 600), Image.LANCZOS) 1125 | else: 1126 | map_img = Image.open(filename) 1127 | 1128 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img)) 1129 | else: 1130 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) 1131 | 1132 | def set_map(self, map_type): 1133 | if map_type == 0: 1134 | self.show_image(os.path.join("map", "traffic_map.png"), False) 1135 | elif map_type == 1: 1136 | self.show_image(self.data["weather_now"], self.config["scale"]) 1137 | 1138 | def updated(self): 1139 | if self.config["mode"] == 0: 1140 | self.set_map(0) 1141 | elif self.config["mode"] == 1: 1142 | self.set_map(1) 1143 | self.map_index = len(self.weather_maps)-1 1144 | 1145 | 1146 | def img_to_pixbuf(img): 1147 | """convert PIL.Image to GdkPixbuf.Pixbuf""" 1148 | data = GLib.Bytes.new(img.tobytes()) 1149 | return GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, 'A' in img.getbands(), 1150 | 8, img.width, img.height, len(img.getbands())*img.width) 1151 | 1152 | 1153 | if __name__ == "__main__": 1154 | os.chdir(sys.path[0]) 1155 | nrsc5_gui = NRSC5GUI() 1156 | nrsc5_gui.main_window.show() 1157 | Gtk.main() 1158 | -------------------------------------------------------------------------------- /radar_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/radar_key.png -------------------------------------------------------------------------------- /radar_key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 52 | 56 | 60 | 64 | 68 | 72 | 76 | 77 | 87 | 97 | 107 | 108 | 134 | 137 | 138 | 140 | 141 | 143 | image/svg+xml 144 | 146 | 147 | 148 | 149 | 150 | 174 | 179 | 184 | 192 | 200 | 208 | Rain 220 | Mix 232 | Snow 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /screenshots/album_art_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/album_art_tab.png -------------------------------------------------------------------------------- /screenshots/bookmarks_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/bookmarks_tab.png -------------------------------------------------------------------------------- /screenshots/info_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/info_tab.png -------------------------------------------------------------------------------- /screenshots/map_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/map_tab.png -------------------------------------------------------------------------------- /screenshots/settings_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/settings_tab.png -------------------------------------------------------------------------------- /weather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/weather.png --------------------------------------------------------------------------------