├── .gitignore ├── README.md ├── a2dp-sink.py ├── classes.png ├── doc ├── mpradio_classess.png └── mpradio_schematic.png ├── install ├── bluez.txt ├── etc │ ├── asound.conf │ ├── bluetooth │ │ ├── audio.conf │ │ └── main.conf │ ├── systemd │ │ └── system │ │ │ ├── bluealsa.service │ │ │ ├── dbus-org.bluez.service │ │ │ ├── mpradio-bt-setup.service │ │ │ ├── mpradio.service │ │ │ ├── need2recompile.service │ │ │ ├── obexpushd.service │ │ │ └── simple-agent.service │ └── udev │ │ └── rules.d │ │ └── 99-input.rules ├── install.sh ├── pirateradio │ └── pirateradio.config └── usr │ ├── lib │ └── udev │ │ └── bluetooth │ └── local │ └── bin │ ├── a2dp_connected.py │ ├── file_storage.sh │ ├── mpradio-bt-setup.sh │ ├── need2recompile.sh │ ├── simple-agent │ └── wifi-switch ├── mpradio-py-notes.txt ├── rfcomm-client.py ├── songs ├── 1.mp3 ├── 2.mp3 ├── 3.mp3 └── credits.txt ├── sounds ├── bt-connect.wav ├── credits.txt ├── silence.wav └── stop1.wav └── src ├── analog_output.py ├── bluetooth_daemon.py ├── bluetooth_player.py ├── bluetooth_player_lite.py ├── bluetooth_remote.py ├── bytearray_io.py ├── configuration.py ├── control_pipe.py ├── encoder.py ├── fm_output.py ├── gpio_remote.py ├── library.json.bak ├── media.py ├── media_scanner.py ├── mp_io.py ├── mpradio.py ├── output.py ├── player.py ├── playlist.py ├── prof.py ├── rds.py ├── storage_bluetooth_player.py ├── storage_player.py └── timer.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .idea 3 | venv 4 | *.mp3 5 | *.json 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpradio-py 2 | Morrolinux's Pirate radio (PiFmRDS implementation with Bluetooth and mp3 support) for all Raspberry Pi models 3 | 4 | Work in progress. 5 | 6 | The old implementation deeply relies on external services and it's not very object oriented nor flexible to changes, resulting in it being inconsistent in the user expirience across multiple devices and configurations. This project aims for a total rewrite with some structural changes to make it more modular, and try to integrate dependencies as much as possible for a better management. 7 | 8 | # COMPATIBILITY NOTICE 9 | 10 | This software is tested to work on Debian 10 and previous versions. Debian 11 and subsequent versions **won't work** due to massive breakage in core dependencies. Feel free to try and port it to the latest Debian if you wish, **or just use Debian 10** to avoid any issues. 11 | 12 | # Features 13 | Exclusively tested on Minimal Raspbian (ARM) 14 | - [x] Resume track from its playback status hh:mm:ss across reboots (CD-like expirience) 15 | - [x] Shuffle on/off 16 | - [x] Display track info over RDS (for both bluetooth playback and music on local storage) 17 | - [x] Skip song by pressing a push-button (GPIO-connected on pin 5 [BCM 3]) even when playing bluetooth audio 18 | - [x] Safely power on/off by holding the push-button 19 | - [x] Stream audio over FM or 3.5mm Jack (As a Bluetooth speaker via jack audio output) 20 | - [ ] Send mp3 files or zip/rar albums to the Pi via Bluetooth 21 | - [ ] Bluetooth OTA file management on the Pi with applications such as "Bluetooth Explorer Lite" 22 | - [x] Read metadata from mp3 files 23 | - [x] Play local music in multiple formats [ogg/m4a/mp3/wav/flac] 24 | - [ ] Read Only mode for saving sdcard from corruption when unplugging AC 25 | - [x] PiFmAdv (default)(experimental) implementation for better signal purity 26 | - [x] Multiple remotes available (GPIO pushbutton / Bluetooth Android App / Control Pipe via shell) 27 | - [ ] Update just mpradio by sending mpradio-master.zip via Bluetooth (Update via App will be soon available) 28 | - [ ] Bluetooth companion app for android (Work in progress...) 29 | 30 | # Installation 31 | `git clone https://github.com/morrolinux/mpradio-py.git mpradio` 32 | 33 | `cd mpradio/install && sudo bash install.sh` 34 | 35 | # Configuration 36 | By default, `mpradio` will always be running automatically after boot once installed. No additional configuration is needed. 37 | However, you can change the FM streaming frequency (which is otherwise defaulted to 88.0) by placing a file named pirateradio.config in the root of a USB key (which of course, will need to stay plugged for the settings to be permanent) 38 | 39 | default `pirateradio.config` here: https://github.com/morrolinux/mpradio-py/blob/master/install/pirateradio/pirateradio.config 40 | 41 | ### Optional: Protect your SD card from corruption by setting Read-Only mode. 42 | 43 | use utility/roswitch.sh as follows: 44 | 45 | `sudo bash roswitch.sh ro` to enable read-ony (effective from next boot) 46 | 47 | `sudo bash roswitch.sh rw` to disable read-only (effective immediately) 48 | 49 | 50 | # Known issues 51 | - Due to a design flaw in BCM43438 WIFI/BT chipset, you might need to disable WiFi if you experience BT audio stuttering on Pi Zero W and Pi 3: https://github.com/raspberrypi/linux/issues/1402 - you can switch onbloard WiFi on/off using `wifi-switch` command (even via Bluetooth link on the Android companion app typing in "settings" > "command" section) 52 | - Boot can take as long as 1m30s on the Pi 1 and 2 due to BT UART interface missing on the board. 53 | Reducing systemd timeout with `echo "DefaultTimeoutStartSec=40s" >> /etc/systemd/system.conf` should help 54 | 55 | # Usage 56 | It (should) work out of the box. You need your mp3 files to be on a FAT32 USB stick (along with the `pirateradio.config` file if you want to override the default settings). 57 | You can **safely** shut down the Pi by holding the push button or via App, and waiting for about 5 seconds until the status LED stops blinking. 58 | If you add new songs on the USB stick, they won't be played until the current playlist is consumed. You can "rebuild" the playlist (looking for new recently added files) if needed: 59 | - Via App 60 | 61 | or 62 | 63 | - Simply delete `playlist.json` and `library.json` from your USB stick when you add new songs to it. 64 | 65 | Also, please remember that (though it would be probably illegal) you can test FM broadcasting by plugging a 20cm wire on the **GPIO 4** of your Pi. 66 | 67 | ## Control pipe 68 | You can perform certain operations while `mpradio.service` is running by simply writing to `/tmp/mpradio_bt` 69 | 70 | Example: 71 | * Playback control: `echo "previous|next|resume|pause" > mpradio_bt` 72 | * System commands: `echo "poweroff|reboot" > mpradio_bt` 73 | 74 | ## Bluetooth companion app 75 | 76 | You can find the source code [here](https://github.com/morrolinux/mpradio-remote) OR you can test an alpha build (v0.3) [here](https://www.mediafire.com/file/t1q0jfthrto8q33/mpradio-py_0.3.apk/file) 77 | 78 | ### Here's how it works: 79 | 80 | 1. Install the App 81 | 2. Pair the Pi with your phone (via Android settings) 82 | 3. Open the App 83 | 84 | ### Notes 85 | * I haven't handled all corner conditions yet, so crashes may occour. 86 | * Make sure phone's Bluetooth is enabled and your Pi is paired before even starting the app, or it will just crash 87 | * Not all features have been implemented as of yet 88 | * You don't need your phone to be connected to the Pi when you start the app. Just paired is fine. 89 | 90 | # Contributing 91 | ## Guidelines 92 | One important requirement is for the program to be mostly testable on your developement machine instead of having to be copied to a Pi each time for testing. This speeds things up, from developing to testing and debugging. To acheive this, I've put platform checks within the code which should be run differently on a Pi rather than on a PC. If you happen to create logic which is supposed to be tested only on a Pi, please insert a platform check not to produce any execution errors on a PC. 93 | 94 | ## Path 95 | If you're testing on your computer, please `cd` to the `mpradio/src` folder and run `./mpradio.py` 96 | 97 | # Debugging / Troubleshooting 98 | ## Services 99 | `mpradio` is launched as a service (via systemd) upon each boot. 100 | 101 | To check whether the service is running or not: 102 | 103 | ` $ sudo systemctl status mpradio ` 104 | 105 | To start or stop the service: 106 | 107 | ` $ sudo systemctl [start/stop] mpradio ` 108 | 109 | ## Bluetooth 110 | 111 | Bluetooth connection logs are found at ` /var/log/bluetooth_dev `. 112 | 113 | If the Raspberry Pi is not showing up as a Bluetooth device, check whether the interface is UP, and that the `bt-setup` script is running: 114 | 115 | ` $ hciconfig ` 116 | 117 | ` $ sudo systemctl status bt-setup ` 118 | 119 | If you are having issues with pairing Bluetooth for audio, please also check if `simple-agent` service is running: 120 | 121 | ` $ sudo systemctl status simple-agent ` 122 | 123 | If you are having issues with Bluetooth not connecting once it's paired, please check whether `bluealsa` is running or not: 124 | 125 | ` $ sudo systemctl status bluealsa ` 126 | 127 | A simple schematic of how things work together: 128 | 129 | ![MPRadio schematic](/doc/mpradio_schematic.png?raw=true "mpradio schematic") 130 | 131 | And classes: 132 | 133 | ![MPRadio classes](/doc/mpradio_classess.png?raw=true "mpradio classes") 134 | 135 | # Warning and Disclaimer 136 | `mpradio` relies on PiFmAdv for FM-Streaming feature. Please note that in most states, transmitting radio waves without a state-issued licence specific to the transmission modalities (frequency, power, bandwidth, etc.) is illegal. Always use a shield between your radio receiver and the Raspberry. Never use an antenna. See PiFmAdv Waring and Disclamer for more information. 137 | -------------------------------------------------------------------------------- /a2dp-sink.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import absolute_import, print_function, unicode_literals 4 | 5 | import dbus 6 | 7 | bus = dbus.SystemBus() 8 | 9 | manager = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager") 10 | 11 | 12 | def extract_objects(object_list): 13 | list = "" 14 | for object in object_list: 15 | val = str(object) 16 | list = list + val[val.rfind("/") + 1:] + " " 17 | return list 18 | 19 | 20 | def extract_uuids(uuid_list): 21 | list = "" 22 | for uuid in uuid_list: 23 | if (uuid.endswith("-0000-1000-8000-00805f9b34fb")): 24 | if (uuid.startswith("0000")): 25 | val = "0x" + uuid[4:8] 26 | else: 27 | val = "0x" + uuid[0:8] 28 | else: 29 | val = str(uuid) 30 | list = list + val + " " 31 | return list 32 | 33 | 34 | objects = manager.GetManagedObjects() 35 | 36 | 37 | all_devices = (str(path) for path, interfaces in objects.items() if 38 | "org.bluez.Device1" in interfaces.keys()) 39 | 40 | for path, interfaces in objects.items(): 41 | if "org.bluez.Adapter1" not in interfaces.keys(): 42 | continue 43 | 44 | print("[ " + path + " ]") 45 | 46 | properties = interfaces["org.bluez.Adapter1"] 47 | for key in properties.keys(): 48 | value = properties[key] 49 | if (key == "UUIDs"): 50 | list = extract_uuids(value) 51 | print(" %s = %s" % (key, list)) 52 | else: 53 | print(" %s = %s" % (key, value)) 54 | 55 | device_list = [d for d in all_devices if d.startswith(path + "/")] 56 | 57 | for dev_path in device_list: 58 | print(" [ " + dev_path + " ]") 59 | 60 | dev = objects[dev_path] 61 | properties = dev["org.bluez.Device1"] 62 | 63 | for key in properties.keys(): 64 | value = properties[key] 65 | if (key == "UUIDs"): 66 | list = extract_uuids(value) 67 | print(" %s = %s" % (key, list)) 68 | elif (key == "Class"): 69 | print(" %s = 0x%06x" % (key, value)) 70 | elif (key == "Vendor"): 71 | print(" %s = 0x%04x" % (key, value)) 72 | elif (key == "Product"): 73 | print(" %s = 0x%04x" % (key, value)) 74 | elif (key == "Version"): 75 | print(" %s = 0x%04x" % (key, value)) 76 | else: 77 | print(" %s = %s" % (key, value)) 78 | 79 | print("") 80 | -------------------------------------------------------------------------------- /classes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/classes.png -------------------------------------------------------------------------------- /doc/mpradio_classess.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/doc/mpradio_classess.png -------------------------------------------------------------------------------- /doc/mpradio_schematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/doc/mpradio_schematic.png -------------------------------------------------------------------------------- /install/bluez.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /install/etc/asound.conf: -------------------------------------------------------------------------------- 1 | defaults.bluealsa.interface "hci0" 2 | defaults.bluealsa.device "XX:XX:XX:XX:XX:XX" 3 | defaults.bluealsa.profile "a2dp" 4 | defaults.bluealsa.delay 10000 5 | -------------------------------------------------------------------------------- /install/etc/bluetooth/audio.conf: -------------------------------------------------------------------------------- 1 | [General] 2 | Class = 0x20041C 3 | Enable=Source,Sink,Media,Socket 4 | -------------------------------------------------------------------------------- /install/etc/bluetooth/main.conf: -------------------------------------------------------------------------------- 1 | [General] 2 | 3 | # Default adapter name 4 | # Defaults to 'BlueZ X.YZ' 5 | #Name = BlueZ 6 | 7 | # Default device class. Only the major and minor device class bits are 8 | # considered. Defaults to '0x000000'. 9 | #Class = 0x000100 10 | Class = 0x20041C 11 | 12 | # How long to stay in discoverable mode before going back to non-discoverable 13 | # The value is in seconds. Default is 180, i.e. 3 minutes. 14 | # 0 = disable timer, i.e. stay discoverable forever 15 | DiscoverableTimeout = 0 16 | 17 | # How long to stay in pairable mode before going back to non-discoverable 18 | # The value is in seconds. Default is 0. 19 | # 0 = disable timer, i.e. stay pairable forever 20 | #PairableTimeout = 0 21 | 22 | # Automatic connection for bonded devices driven by platform/user events. 23 | # If a platform plugin uses this mechanism, automatic connections will be 24 | # enabled during the interval defined below. Initially, this feature 25 | # intends to be used to establish connections to ATT channels. Default is 60. 26 | #AutoConnectTimeout = 60 27 | 28 | # Use vendor id source (assigner), vendor, product and version information for 29 | # DID profile support. The values are separated by ":" and assigner, VID, PID 30 | # and version. 31 | # Possible vendor id source values: bluetooth, usb (defaults to usb) 32 | #DeviceID = bluetooth:1234:5678:abcd 33 | 34 | # Do reverse service discovery for previously unknown devices that connect to 35 | # us. This option is really only needed for qualification since the BITE tester 36 | # doesn't like us doing reverse SDP for some test cases (though there could in 37 | # theory be other useful purposes for this too). Defaults to 'true'. 38 | #ReverseServiceDiscovery = true 39 | 40 | # Enable name resolving after inquiry. Set it to 'false' if you don't need 41 | # remote devices name and want shorter discovery cycle. Defaults to 'true'. 42 | #NameResolving = true 43 | 44 | # Enable runtime persistency of debug link keys. Default is false which 45 | # makes debug link keys valid only for the duration of the connection 46 | # that they were created for. 47 | #DebugKeys = false 48 | 49 | # Restricts all controllers to the specified transport. Default value 50 | # is "dual", i.e. both BR/EDR and LE enabled (when supported by the HW). 51 | # Possible values: "dual", "bredr", "le" 52 | #ControllerMode = dual 53 | 54 | # Enables Multi Profile Specification support. This allows to specify if 55 | # system supports only Multiple Profiles Single Device (MPSD) configuration 56 | # or both Multiple Profiles Single Device (MPSD) and Multiple Profiles Multiple 57 | # Devices (MPMD) configurations. 58 | # Possible values: "off", "single", "multiple" 59 | #MultiProfile = off 60 | 61 | # Permanently enables the Fast Connectable setting for adapters that 62 | # support it. When enabled other devices can connect faster to us, 63 | # however the tradeoff is increased power consumptions. This feature 64 | # will fully work only on kernel version 4.1 and newer. Defaults to 65 | # 'false'. 66 | #FastConnectable = false 67 | 68 | # Default privacy setting. 69 | # Enables use of private address. 70 | # Possible values: "off", "device", "network" 71 | # "network" option not supported currently 72 | # Defaults to "off" 73 | # Privacy = off 74 | 75 | [Policy] 76 | # 77 | # The ReconnectUUIDs defines the set of remote services that should try 78 | # to be reconnected to in case of a link loss (link supervision 79 | # timeout). The policy plugin should contain a sane set of values by 80 | # default, but this list can be overridden here. By setting the list to 81 | # empty the reconnection feature gets disabled. 82 | #ReconnectUUIDs=00001112-0000-1000-8000-00805f9b34fb,0000111f-0000-1000-8000-00805f9b34fb,0000110a-0000-1000-8000-00805f9b34fb 83 | 84 | # ReconnectAttempts define the number of attempts to reconnect after a link 85 | # lost. Setting the value to 0 disables reconnecting feature. 86 | #ReconnectAttempts=7 87 | 88 | # ReconnectIntervals define the set of intervals in seconds to use in between 89 | # attempts. 90 | # If the number of attempts defined in ReconnectAttempts is bigger than the 91 | # set of intervals the last interval is repeated until the last attempt. 92 | #ReconnectIntervals=1,2,4,8,16,32,64 93 | 94 | # AutoEnable defines option to enable all controllers when they are found. 95 | # This includes adapters present on start as well as adapters that are plugged 96 | # in later on. Defaults to 'false'. 97 | AutoEnable=true 98 | -------------------------------------------------------------------------------- /install/etc/systemd/system/bluealsa.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bluealsa audio server 3 | After=mpradio-bt-setup.service 4 | 5 | [Service] 6 | User=root 7 | Group=root 8 | ExecStart=/bin/bash -l -c '/usr/bin/bluealsa -p a2dp-sink --a2dp-force-audio-cd' 9 | ExecReload=/bin/kill -HUP $MAINPID 10 | KillMode=control-group 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /install/etc/systemd/system/dbus-org.bluez.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bluetooth service 3 | Documentation=man:bluetoothd(8) 4 | ConditionPathIsDirectory=/sys/class/bluetooth 5 | 6 | [Service] 7 | Type=dbus 8 | BusName=org.bluez 9 | ExecStart=/usr/lib/bluetooth/bluetoothd -C 10 | NotifyAccess=main 11 | #WatchdogSec=10 12 | #Restart=on-failure 13 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE 14 | LimitNPROC=1 15 | ProtectHome=true 16 | ProtectSystem=full 17 | 18 | [Install] 19 | WantedBy=bluetooth.target 20 | Alias=dbus-org.bluez.service 21 | 22 | -------------------------------------------------------------------------------- /install/etc/systemd/system/mpradio-bt-setup.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Mpradio bluetooth setup 3 | After=bluetooth.target 4 | 5 | [Service] 6 | User=root 7 | Group=root 8 | Type=oneshot 9 | RemainAfterExit=true 10 | ExecStartPre=/bin/sleep 10 11 | ExecStart=/bin/bash -l -c '/usr/local/bin/mpradio-bt-setup.sh' 12 | ExecReload=/bin/kill -HUP $MAINPID 13 | KillMode=control-group 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /install/etc/systemd/system/mpradio.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Morrolinux Pirate Radio 3 | After=network.target auditd.service 4 | 5 | [Service] 6 | Type=idle 7 | User=pi 8 | Group=pi 9 | EnvironmentFile=/home/pi/.profile 10 | ExecStart=/bin/bash -l -c 'sudo -E chrt -f 99 /home/pi/mpradio.py' 11 | ExecReload=/bin/kill -HUP $MAINPID 12 | KillMode=control-group 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /install/etc/systemd/system/need2recompile.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Determine wheather the running device has changed and PiFmAdv recompile is needed or not 3 | After=network.target auditd.service 4 | 5 | [Service] 6 | Type=idle 7 | User=root 8 | Group=root 9 | ExecStart=/bin/bash /usr/local/bin/need2recompile.sh 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | KillMode=control-group 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | 16 | -------------------------------------------------------------------------------- /install/etc/systemd/system/obexpushd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=obex file transfer service 3 | After=simple-agent.service dbus-org.bluez.service 4 | Before=mpradio.service 5 | 6 | [Service] 7 | Type=simple 8 | EnvironmentFile=/root/.profile 9 | ExecStart=/usr/bin/obexpushd -B -n -o /pirateradio/ -s /usr/local/bin/file_storage.sh -t FTP 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | KillMode=control-group 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | 16 | -------------------------------------------------------------------------------- /install/etc/systemd/system/simple-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bluealsa audio server 3 | After=bluetooth.target mpradio-bt-setup.service 4 | 5 | [Service] 6 | User=root 7 | Group=root 8 | ExecStart=/bin/bash -l -c '/usr/local/bin/simple-agent' 9 | ExecReload=/bin/kill -HUP $MAINPID 10 | KillMode=control-group 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /install/etc/udev/rules.d/99-input.rules: -------------------------------------------------------------------------------- 1 | SUBSYSTEM=="input", RUN+="/usr/lib/udev/bluetooth" 2 | -------------------------------------------------------------------------------- /install/install.sh: -------------------------------------------------------------------------------- 1 | if [ "$(id -u)" != "0" ]; then 2 | echo "This script must be run as root" 1>&2 3 | exit 1 4 | fi 5 | 6 | ln -s /home/pi/mpradio/src/mpradio.py /home/pi/mpradio.py 7 | 8 | apt-get update 9 | apt-get -y install git libsndfile1-dev libbluetooth-dev bluez pi-bluetooth python-gobject python-gobject-2 bluez-tools sox ffmpeg libsox-fmt-mp3 libsoxr-dev python-dbus bluealsa obexpushd python3-rpi.gpio python3-mutagen python3-dbus python3-pip python3-dev pkg-config python3-pyaudio 10 | 11 | # pyav specific dependencies: 12 | apt-get install -y libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev 13 | 14 | # needed for rfcomm bluetooth interface; ffmpeg python bindings 15 | pip3 install pybluez psutil 16 | pip3 install 'av==6' 17 | 18 | # install requirements as a dir. structure 19 | for f in $(ls -d */) 20 | do 21 | sudo cp -R --parents $f / 22 | done 23 | 24 | # give execution permissions 25 | for f in $(ls usr/local/bin) 26 | do 27 | sudo chmod +x /usr/local/bin/$(basename $f) 28 | done 29 | 30 | # enable systemd units 31 | systemctl daemon-reload 32 | for f in $(ls etc/systemd/system/*.service) 33 | do 34 | sudo systemctl enable $(basename $f) 35 | done 36 | 37 | systemctl enable bluetooth.service 38 | 39 | cd /usr/local/src/ 40 | rm -rf PiFmAdv 41 | git clone https://github.com/Miegl/PiFmAdv.git 42 | cd PiFmAdv/src 43 | make clean 44 | make -j $(nproc) 45 | 46 | cp /usr/local/src/PiFmAdv/src/pi_fm_adv /usr/local/bin/pi_fm_adv 47 | 48 | # set gpu_freq needed by PiFmAdv 49 | if [[ $(grep "gpu_freq=250" /boot/config.txt) == "" ]]; then 50 | echo "gpu_freq=250" >> /boot/config.txt 51 | fi 52 | 53 | # Final configuration and perms... 54 | FSTAB="/etc/fstab" 55 | fstabline=$(grep "pirateradio" $FSTAB -n|cut -d: -f1) 56 | if [[ $fstabline == "" ]]; then 57 | echo "/dev/sda1 /pirateradio vfat defaults,rw,uid=pi,gid=pi,nofail 0 0" >> $FSTAB 58 | fi 59 | 60 | # usermod -a -G lp pi 61 | usermod -aG bluetooth pi 62 | chown -R pi:pi /pirateradio/ 63 | chmod +x /usr/lib/udev/bluetooth 64 | 65 | # add dbus rule for user pi 66 | bluez_pi=$(grep "policy user=\"pi\"" /etc/dbus-1/system.d/bluetooth.conf) 67 | if [[ $bluez_pi == "" ]]; then 68 | sed -i '//r bluez.txt' /etc/dbus-1/system.d/bluetooth.conf 69 | fi 70 | 71 | # set hostname 72 | echo PRETTY_HOSTNAME=mpradio > /etc/machine-info 73 | echo "mpradio" > /etc/hostname 74 | 75 | # avoid recompilation (by need2recompile.service) after first install 76 | cp -f /sys/firmware/devicetree/base/model /etc/lastmodel 77 | 78 | echo "these modules might cause trouble with audio sampling rate" 79 | echo "please blacklist them if you haven't already:" 80 | echo "echo \"blacklist snd_bcm2835\" >> /etc/modprobe.d/blacklist.conf" 81 | echo "echo \"blacklist ipv6\" >> /etc/modprobe.d/blacklist.conf" 82 | 83 | sleep 5 && reboot 84 | -------------------------------------------------------------------------------- /install/pirateradio/pirateradio.config: -------------------------------------------------------------------------------- 1 | [PIRATERADIO] 2 | frequency=88.0 3 | btGain=1.7 4 | storageGain=1 5 | treble=-6 6 | output=fm 7 | btBoost=false 8 | implementation=pi_fm_adv 9 | 10 | [PLAYLIST] 11 | persistentPlaylist=true 12 | resumePlayback=true 13 | shuffle=true 14 | 15 | [RDS] 16 | updateInterval=3 17 | charsJump=6 18 | stationName=MPRadio 19 | rdsPattern=$ARTIST_NAME - $SONG_NAME 20 | -------------------------------------------------------------------------------- /install/usr/lib/udev/bluetooth: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # at each BT connection/disconnection tell mpradio passing bluetooth address to the pipe 3 | # env > /tmp/testenv 4 | 5 | function log { 6 | sudo echo "[$(date)]: $*" > /var/log/bluetooth_dev 7 | } 8 | BTMAC=${NAME//\"/} 9 | 10 | if [[ $ACTION == "remove" ]] 11 | then 12 | log "Stop Bluetooth played connection " $BTMAC 13 | echo "bluetooth detach" > /tmp/mpradio_bt 14 | elif [[ $ACTION == "add" ]] 15 | then 16 | 17 | log "Start Bluetooth played connection " $BTMAC 18 | # sed -i "s/^defaults.bluealsa.device.*$/defaults.bluealsa.device \"$BTMAC\"/g" /etc/asound.conf 19 | echo "bluetooth attach" > /tmp/mpradio_bt 20 | else 21 | log "Other action " $ACTION 22 | fi 23 | -------------------------------------------------------------------------------- /install/usr/local/bin/a2dp_connected.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Note: this is not mine but I don't remember where does it come from. 4 | 5 | from __future__ import absolute_import, print_function, unicode_literals 6 | 7 | import dbus 8 | 9 | bus = dbus.SystemBus() 10 | 11 | manager = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager") 12 | 13 | 14 | def extract_objects(object_list): 15 | list = "" 16 | for object in object_list: 17 | val = str(object) 18 | list = list + val[val.rfind("/") + 1:] + " " 19 | return list 20 | 21 | 22 | def extract_uuids(uuid_list): 23 | list = "" 24 | for uuid in uuid_list: 25 | if (uuid.endswith("-0000-1000-8000-00805f9b34fb")): 26 | if (uuid.startswith("0000")): 27 | val = "0x" + uuid[4:8] 28 | else: 29 | val = "0x" + uuid[0:8] 30 | else: 31 | val = str(uuid) 32 | list = list + val + " " 33 | return list 34 | 35 | 36 | objects = manager.GetManagedObjects() 37 | 38 | 39 | all_devices = (str(path) for path, interfaces in objects.items() if 40 | "org.bluez.Device1" in interfaces.keys()) 41 | 42 | for path, interfaces in objects.items(): 43 | if "org.bluez.Adapter1" not in interfaces.keys(): 44 | continue 45 | 46 | device_list = [d for d in all_devices if d.startswith(path + "/")] 47 | 48 | for dev_path in device_list: 49 | 50 | dev = objects[dev_path] 51 | properties = dev["org.bluez.Device1"] 52 | 53 | for key in properties.keys(): 54 | value = properties[key] 55 | if key == "Connected": 56 | if value == 1: 57 | print(properties["Address"]) 58 | -------------------------------------------------------------------------------- /install/usr/local/bin/file_storage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | #DIALOG=Xdialog --default-no 6 | DIALOG=kdialog 7 | 8 | #ROOT_PATH="" 9 | ROOT_PATH="/pirateradio/" 10 | 11 | # 12 | # compatible with obexpushd >= 0.6 13 | # 14 | 15 | MODE="$1" 16 | echo "MODE: $MODE" 1>&2 17 | FROM="" 18 | NAME="" 19 | SUB_PATH="${ROOT_PATH}./" 20 | LENGTH="0" 21 | MIMETYPE="" 22 | while read LINE; do 23 | echo "${LINE}" 1>&2 24 | if ( test -z "${LINE}" ); then 25 | break 26 | fi 27 | TAG=$(echo "${LINE}" | cut -f 1 -d ":") 28 | VALUE=$(echo "${LINE}" | cut -f 2- -d " ") 29 | echo "TAG: $TAG VALUE: $VALUE" 1>&2 30 | 31 | case $TAG in 32 | From) FROM="${VALUE}";; 33 | Name) NAME="${VALUE}";; 34 | Path) SUB_PATH="${ROOT_PATH}${VALUE}/";; 35 | Length) LENGTH="${VALUE}";; 36 | Type) MIMETYPE="${VALUE}";; 37 | esac 38 | done 39 | 40 | echo "MODE: ${MODE}" 1>&2 41 | 42 | case "${MODE}" in 43 | put) 44 | FILE="${SUB_PATH}${NAME}" 45 | #echo "script: testing ${FILE}..." 1>&2 46 | #test -z "${FILE}" && exit 1 47 | #echo "script: testing for existence of ${FILE}..." 1>&2 48 | #test -e "${FILE}" && exit 1 49 | 50 | #tell obexpushd to go on 51 | echo "OK" 52 | 53 | echo "script: storing file on disk..." 1>&2 54 | cat > "${FILE}" 55 | echo "script: file saved to disk." 1>&2 56 | #setfattr -n "user.mime_type" -v "${MIMETYPE}" "${FILE}" 57 | sleep 1 58 | echo "script: done" 1>&2 59 | ;; 60 | 61 | get) 62 | FILE=${SUB_PATH}${NAME} 63 | test -z "${FILE}" && exit 1 64 | test -f "${FILE}" || exit 1 65 | 66 | stat --printf="Length: %s\n" ${FILE} 67 | stat --format="%y" "${FILE}" | date -u +"Time: %Y%m%dT%H%M%SZ" 68 | 69 | # MIMETYPE=$(getfattr -n "user.mime_type" "${FILE}") 70 | # if [ "${MIMETYPE}" ]; then 71 | # echo "Type: ${MIMETYPE}" 72 | # fi 73 | 74 | echo "" 75 | cat "${FILE}" 76 | ;; 77 | 78 | delete) 79 | FILE=${SUB_PATH}${NAME} 80 | exec rm -rf "${FILE}" 81 | ;; 82 | 83 | listdir) 84 | FILE=$(mktemp) 85 | obex-folder-listing ${SUB_PATH} >${FILE} 2>/dev/null 86 | stat --printf="Length: %s\n" ${FILE} 87 | echo "" 88 | cat ${FILE} 89 | rm -f ${FILE} 90 | ;; 91 | 92 | createdir) 93 | exec mkdir -p ${SUB_PATH} 94 | ;; 95 | 96 | capability) 97 | ;; 98 | esac 99 | exit 0 100 | 101 | -------------------------------------------------------------------------------- /install/usr/local/bin/mpradio-bt-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # bring up bluetooth interface 4 | hciconfig hci0 up 5 | hciconfig hci0 piscan 6 | 7 | # make sure udev events are not being ignored 8 | systemctl force-reload udev systemd-udevd-control.socket systemd-udevd-kernel.socket 9 | 10 | # register bluetooth serial port 11 | sdptool add SP 12 | chgrp bluetooth /var/run/sdp 13 | -------------------------------------------------------------------------------- /install/usr/local/bin/need2recompile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | currentmodel="/sys/firmware/devicetree/base/model" 4 | lastmodel="/etc/lastmodel" 5 | 6 | diff $currentmodel $lastmodel 7 | equals=$? 8 | 9 | if [[ $equals -eq 1 ]] 10 | then 11 | mount -o remount rw / 12 | 13 | cp $currentmodel $lastmodel 14 | systemctl stop mpradio 15 | 16 | cd /usr/local/src/PiFmAdv/src/ 17 | make clean 18 | make 19 | 20 | cp /usr/local/src/PiFmAdv/src/pi_fm_adv /usr/local/bin/pi_fm_adv 21 | 22 | reboot 23 | fi 24 | -------------------------------------------------------------------------------- /install/usr/local/bin/simple-agent: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # blueagent5.py 4 | # Dependencies: python-gobject (install with e.g. 'sudo apt-get install python-gobject' on Raspian 5 | # Author: Douglas Otwell 6 | # This software is released to the public domain 7 | 8 | # The Software is provided "as is" without warranty of any kind, either express or implied, 9 | # including without limitation any implied warranties of condition, uninterrupted use, 10 | # merchantability, fitness for a particular purpose, or non-infringement. 11 | 12 | import time 13 | import sys 14 | import dbus 15 | import dbus.service 16 | import dbus.mainloop.glib 17 | import gobject 18 | import logging 19 | from optparse import OptionParser 20 | 21 | SERVICE_NAME = "org.bluez" 22 | AGENT_IFACE = SERVICE_NAME + '.Agent1' 23 | ADAPTER_IFACE = SERVICE_NAME + ".Adapter1" 24 | DEVICE_IFACE = SERVICE_NAME + ".Device1" 25 | PLAYER_IFACE = SERVICE_NAME + '.MediaPlayer1' 26 | 27 | LOG_LEVEL = logging.INFO 28 | LOG_FILE = "/var/log/syslog" 29 | #LOG_LEVEL = logging.DEBUG 30 | #LOG_FILE = "/dev/stdout" 31 | LOG_FORMAT = "%(asctime)s %(levelname)s [%(module)s] %(message)s" 32 | 33 | """Utility functions from bluezutils.py""" 34 | def getManagedObjects(): 35 | bus = dbus.SystemBus() 36 | manager = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager") 37 | return manager.GetManagedObjects() 38 | 39 | def findAdapter(): 40 | objects = getManagedObjects(); 41 | bus = dbus.SystemBus() 42 | for path, ifaces in objects.iteritems(): 43 | adapter = ifaces.get(ADAPTER_IFACE) 44 | if adapter is None: 45 | continue 46 | obj = bus.get_object(SERVICE_NAME, path) 47 | return dbus.Interface(obj, ADAPTER_IFACE) 48 | raise Exception("Bluetooth adapter not found") 49 | 50 | 51 | class BlueAgent(dbus.service.Object): 52 | AGENT_PATH = "/blueagent5/agent" 53 | CAPABILITY = "DisplayOnly" 54 | pin_code = None 55 | 56 | def __init__(self, pin_code): 57 | dbus.service.Object.__init__(self, dbus.SystemBus(), BlueAgent.AGENT_PATH) 58 | self.pin_code = pin_code 59 | 60 | logging.basicConfig(filename=LOG_FILE, format=LOG_FORMAT, level=LOG_LEVEL) 61 | logging.info("Starting BlueAgent with PIN [{}]".format(self.pin_code)) 62 | 63 | @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="") 64 | def DisplayPinCode(self, device, pincode): 65 | logging.debug("BlueAgent DisplayPinCode invoked") 66 | 67 | @dbus.service.method(AGENT_IFACE, in_signature="ouq", out_signature="") 68 | def DisplayPasskey(self, device, passkey, entered): 69 | logging.debug("BlueAgent DisplayPasskey invoked") 70 | 71 | @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s") 72 | def RequestPinCode(self, device): 73 | logging.info("BlueAgent is pairing with device [{}]".format(device)) 74 | self.trustDevice(device) 75 | return self.pin_code 76 | 77 | @dbus.service.method(AGENT_IFACE, in_signature="ou", out_signature="") 78 | def RequestConfirmation(self, device, passkey): 79 | """Always confirm""" 80 | logging.info("BlueAgent is pairing with device [{}]".format(device)) 81 | self.trustDevice(device) 82 | return 83 | 84 | @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="") 85 | def AuthorizeService(self, device, uuid): 86 | """Always authorize""" 87 | logging.debug("BlueAgent AuthorizeService method invoked") 88 | return 89 | 90 | @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="u") 91 | def RequestPasskey(self, device): 92 | logging.debug("RequestPasskey returns 0") 93 | return dbus.UInt32(0) 94 | 95 | @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="") 96 | def RequestAuthorization(self, device): 97 | """Always authorize""" 98 | logging.info("BlueAgent is authorizing device [{}]".format(self.device)) 99 | return 100 | 101 | @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="") 102 | def Cancel(self): 103 | logging.info("BlueAgent pairing request canceled from device [{}]".format(self.device)) 104 | 105 | def trustDevice(self, path): 106 | bus = dbus.SystemBus() 107 | device_properties = dbus.Interface(bus.get_object(SERVICE_NAME, path), "org.freedesktop.DBus.Properties") 108 | device_properties.Set(DEVICE_IFACE, "Trusted", True) 109 | 110 | def registerAsDefault(self): 111 | bus = dbus.SystemBus() 112 | manager = dbus.Interface(bus.get_object(SERVICE_NAME, "/org/bluez"), "org.bluez.AgentManager1") 113 | manager.RegisterAgent(BlueAgent.AGENT_PATH, BlueAgent.CAPABILITY) 114 | manager.RequestDefaultAgent(BlueAgent.AGENT_PATH) 115 | 116 | def startPairing(self): 117 | bus = dbus.SystemBus() 118 | adapter_path = findAdapter().object_path 119 | adapter = dbus.Interface(bus.get_object(SERVICE_NAME, adapter_path), "org.freedesktop.DBus.Properties") 120 | adapter.Set(ADAPTER_IFACE, "Discoverable", True) 121 | 122 | logging.info("BlueAgent is waiting to pair with device") 123 | 124 | bus = None 125 | 126 | if __name__ == "__main__": 127 | pin_code = "0000" 128 | parser = OptionParser() 129 | parser.add_option("-p", "--pin", action="store", dest="pin_code", help="PIN code to pair with", metavar="PIN") 130 | (options, args) = parser.parse_args() 131 | 132 | # use the pin code if provided 133 | if (options.pin_code): 134 | pin_code = options.pin_code 135 | 136 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 137 | 138 | agent = BlueAgent(pin_code) 139 | agent.registerAsDefault() 140 | agent.startPairing() 141 | 142 | mainloop = gobject.MainLoop() 143 | mainloop.run() 144 | -------------------------------------------------------------------------------- /install/usr/local/bin/wifi-switch: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CONFIG="/boot/config.txt" 4 | DISABLE_STR="dtoverlay=pi3-disable-wifi" 5 | 6 | if [[ $# -ne 1 ]] 7 | then 8 | echo "usage: wifi-switch " 9 | exit 1 10 | fi 11 | 12 | if [[ $(grep "$DISABLE_STR" $CONFIG) == "" ]] 13 | then 14 | if [[ $1 == "status" ]] 15 | then 16 | echo "on" 17 | exit 0 18 | fi 19 | 20 | if [[ $1 == "off" ]] 21 | then 22 | echo "$DISABLE_STR" | sudo tee -a /boot/config.txt 23 | sudo reboot 24 | fi 25 | else 26 | if [[ $1 == "status" ]] 27 | then 28 | echo "off" 29 | exit 0 30 | fi 31 | 32 | if [[ $1 == "on" ]] 33 | then 34 | sudo sed -i "/^$DISABLE_STR/d" /boot/config.txt 35 | echo "wifi enabled" 36 | sudo reboot 37 | fi 38 | fi 39 | 40 | -------------------------------------------------------------------------------- /mpradio-py-notes.txt: -------------------------------------------------------------------------------- 1 | pulseaudio: 2 | parec --format=s16le --rate=44100 | sox -t raw -v 2 -G -b 16 -e signed -c 2 -r 44100 - -t wav -|sudo pi_fm_adv --audio - 3 | 4 | bluealsa: 5 | sudo bluealsa -p a2dp-sink --a2dp-force-audio-cd 6 | arecord -D bluealsa:HCI=hci0,DEV=48:2C:A0:32:A0:C1 -f cd -c 2 | sox -t raw -v 2 -G -b 16 -e signed -c 2 -r 44100 - -t wav -|sudo pi_fm_adv --audio - 7 | 8 | a2dp sink with raspberry (configurazione utenti): 9 | https://thecodeninja.net/2016/06/bluetooth-audio-receiver-a2dp-sink-with-raspberry-pi/ 10 | 11 | https://bbs.archlinux.org/viewtopic.php?id=151076 12 | 13 | https://askubuntu.com/questions/1104408/recording-audio-to-a-pulseaudio-stream-sink-input-and-playing-from-stream-on 14 | 15 | 16 | scripts python: 17 | https://gist.github.com/mill1000/74c7473ee3b4a5b13f6325e9994ff84c 18 | 19 | 20 | Play/pause bluetooth playback: 21 | dbus-send --system --type=method_call --dest=org.bluez /org/bluez/hci0/dev_48_2C_A0_32_A0_C1/player0 org.bluez.MediaPlayer1.Pause 22 | 23 | overview on a2dp: 24 | https://github.com/belese/a2dp-alsa/blob/master/README 25 | 26 | 27 | 28 | MP3 PLAYBACK SEEK/SKIP: (es: 50 secondi) 29 | ffmpeg -i song.m4a -ss 50 -vn -f wav pipe:1 | aplay - 30 | 31 | ffmpeg -i song.m4a -vn -f wav pipe:1 | sox -t raw -G -b 16 -e signed -c 2 -r 44100 - -t wav - | aplay - 32 | -------------------------------------------------------------------------------- /rfcomm-client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import bluetooth 4 | import sys 5 | 6 | if len(sys.argv) < 3: 7 | print("syntax:") 8 | print(" ") 9 | exit(1) 10 | 11 | bd_addr = sys.argv[1] 12 | port = 1 13 | 14 | sock=bluetooth.BluetoothSocket(bluetooth.RFCOMM) 15 | sock.connect((bd_addr, port)) 16 | sock.send(sys.argv[2]) 17 | sock.close() 18 | 19 | -------------------------------------------------------------------------------- /songs/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/songs/1.mp3 -------------------------------------------------------------------------------- /songs/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/songs/2.mp3 -------------------------------------------------------------------------------- /songs/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/songs/3.mp3 -------------------------------------------------------------------------------- /songs/credits.txt: -------------------------------------------------------------------------------- 1 | Music: https://www.bensound.com 2 | 3 | 4 | -------------------------------------------------------------------------------- /sounds/bt-connect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/sounds/bt-connect.wav -------------------------------------------------------------------------------- /sounds/credits.txt: -------------------------------------------------------------------------------- 1 | http://soundbible.com/819-Checkout-Scanner-Beep.html 2 | http://soundbible.com/91-Short-Beep-Tone.html 3 | -------------------------------------------------------------------------------- /sounds/silence.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/sounds/silence.wav -------------------------------------------------------------------------------- /sounds/stop1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrolinux/mpradio-py/d0b57b44e70fe0abfb31dd9e6f2eb47220a68ee2/sounds/stop1.wav -------------------------------------------------------------------------------- /src/analog_output.py: -------------------------------------------------------------------------------- 1 | from output import Output 2 | import subprocess 3 | 4 | 5 | class AnalogOutput(Output): 6 | 7 | def __init__(self): 8 | super().__init__() 9 | 10 | def run(self): 11 | self.stream = subprocess.Popen(["aplay", "-"], stdin=subprocess.PIPE, 12 | stdout=subprocess.PIPE) # , stderr=subprocess.PIPE) 13 | self.input_stream = self.stream.stdin 14 | self.output_stream = self.stream.stdout 15 | self.ready.set() 16 | 17 | def stop(self): 18 | self.stream.kill() 19 | 20 | def check_reload(self): 21 | pass 22 | 23 | def reload(self): 24 | pass 25 | -------------------------------------------------------------------------------- /src/bluetooth_daemon.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function, unicode_literals 2 | import dbus 3 | import subprocess 4 | 5 | 6 | class BluetoothDaemon: 7 | 8 | __simpleagent = None 9 | __bluealsa = None 10 | 11 | def __init__(self): 12 | self.bt_setup() 13 | self.run_bluealsa() 14 | self.run_simple_agent() 15 | 16 | def run_simple_agent(self): 17 | subprocess.Popen(["sudo", "killall", "simple-agent"]).wait(5) 18 | self.__simpleagent = subprocess.Popen(["sudo", "simple-agent"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 19 | 20 | def run_bluealsa(self): 21 | subprocess.Popen(["sudo", "killall", "bluealsa"]).wait(5) 22 | self.__bluealsa = subprocess.Popen(["sudo", "bluealsa", "-p", "a2dp-sink", "--a2dp-force-audio-cd"], 23 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 24 | 25 | def bt_setup(self): 26 | subprocess.Popen(["sudo", "hciconfig", "hci0", "up"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 27 | subprocess.Popen(["sudo", "hciconfig", "hci0", "piscan"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 28 | subprocess.Popen(["sudo", "systemctl", "force-reload", "udev", "systemd-udevd-control.socket", 29 | "systemd-udevd-kernel.socket"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 30 | 31 | def restart_simple_agent(self): 32 | self.__simpleagent.kill() 33 | self.run_simple_agent() 34 | 35 | def restart_bluealsa(self): 36 | self.__bluealsa.kill() 37 | self.run_bluealsa() 38 | 39 | 40 | def get_connected_device(): 41 | bus = dbus.SystemBus() 42 | manager = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager") 43 | objects = manager.GetManagedObjects() 44 | 45 | all_devices = (str(path) for path, interfaces in objects.items() if 46 | "org.bluez.Device1" in interfaces.keys()) 47 | 48 | for path, interfaces in objects.items(): 49 | if "org.bluez.Adapter1" not in interfaces.keys(): 50 | continue 51 | 52 | device_list = [d for d in all_devices if d.startswith(path + "/")] 53 | 54 | for dev_path in device_list: 55 | 56 | dev = objects[dev_path] 57 | properties = dev["org.bluez.Device1"] 58 | 59 | for key in properties.keys(): 60 | value = properties[key] 61 | if key == "Connected": 62 | if value == 1: 63 | return properties["Address"] -------------------------------------------------------------------------------- /src/bluetooth_player.py: -------------------------------------------------------------------------------- 1 | import dbus 2 | from player import Player 3 | import subprocess 4 | import av 5 | from mp_io import MpradioIO 6 | from rds import RdsUpdater 7 | import time 8 | import threading 9 | 10 | 11 | class BtPlayer(Player): 12 | 13 | __bt_addr = None 14 | __cmd_arr = None 15 | __rds_updater = None 16 | __now_playing = None 17 | output_stream = None 18 | __terminating = False 19 | CHUNK = 1024 * 64 # Pi0 on integrated bluetooth seem to work best with 64k chunks 20 | 21 | def __init__(self, bt_addr): 22 | super().__init__() 23 | self.__rds_updater = RdsUpdater() 24 | self.__bt_addr = bt_addr 25 | self.__cmd_arr = ["sudo", "dbus-send", "--system", "--type=method_call", "--dest=org.bluez", "/org/bluez/hci0/dev_" 26 | + bt_addr.replace(":", "_").upper() + "/player0", "org.bluez.MediaPlayer1.Pause"] 27 | 28 | def playback_position(self): 29 | pass 30 | 31 | def resume(self): 32 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Play" 33 | subprocess.call(self.__cmd_arr) 34 | 35 | def run(self): 36 | threading.Thread(target=self.__run).start() 37 | 38 | def __run(self): 39 | print("playing bluetooth:", self.__bt_addr) 40 | dev = "bluealsa:HCI=hci0,DEV="+self.__bt_addr 41 | self.__rds_updater.run() 42 | while not self.__terminating: 43 | self.play(dev) 44 | time.sleep(1) 45 | 46 | def play(self, device): 47 | # open input device 48 | try: 49 | input_container = av.open(device, format="alsa") 50 | 51 | audio_stream = None 52 | for i, stream in enumerate(input_container.streams): 53 | if stream.type == 'audio': 54 | audio_stream = stream 55 | break 56 | 57 | if not audio_stream: 58 | print("audio stream not found") 59 | return 60 | 61 | except av.AVError: 62 | print("Can't open input stream for device", device) 63 | return 64 | 65 | # open output stream 66 | self.output_stream = MpradioIO() 67 | out_container = av.open(self.output_stream, 'w', 'wav') 68 | out_stream = out_container.add_stream(codec_name='pcm_s16le', rate=44100) 69 | 70 | # transcode input to wav 71 | for i, packet in enumerate(input_container.demux(audio_stream)): 72 | try: 73 | for frame in packet.decode(): 74 | frame.pts = None 75 | out_pack = out_stream.encode(frame) 76 | if out_pack: 77 | out_container.mux(out_pack) 78 | else: 79 | print("out_pack is None") 80 | except av.AVError: 81 | print("Error during playback for:", device) 82 | return 83 | 84 | # stop transcoding if we receive termination signal 85 | if self.__terminating: 86 | print("termination signal received") 87 | break 88 | 89 | # pre-buffer 90 | if i == 2: 91 | self.ready.set() 92 | 93 | # Avoid CPU saturation on single-core systems. 94 | time.sleep(0.002) # useful on Pi0 with integrated BT 95 | 96 | # transcoding terminated. Flush output stream 97 | try: 98 | while True: 99 | out_pack = out_stream.encode(None) 100 | if out_pack: 101 | out_container.mux(out_pack) 102 | else: 103 | break 104 | except ValueError: 105 | print("skipping flush...") 106 | return 107 | 108 | # close output container and tell the buffer no more data is coming 109 | out_container.close() 110 | self.output_stream.set_write_completed() 111 | print("transcoding finished.") 112 | 113 | # TODO: check if this and the above is really needed when playing a device 114 | # wait until playback (buffer read) terminates; catch signals meanwhile 115 | while not self.output_stream.is_read_completed(): 116 | if self.__terminating: 117 | break 118 | time.sleep(0.2) 119 | 120 | def get_now_playing(self): 121 | try: 122 | bus = dbus.SystemBus() 123 | player = bus.get_object("org.bluez", "/org/bluez/hci0/dev_" + self.__bt_addr.replace(":", "_").upper() 124 | + "/player0") 125 | BT_Media_iface = dbus.Interface(player, dbus_interface="org.bluez.MediaPlayer1") 126 | BT_Media_probs = dbus.Interface(player, "org.freedesktop.DBus.Properties") 127 | probs = BT_Media_probs.GetAll("org.bluez.MediaPlayer1") 128 | 129 | self.__now_playing = dict() 130 | self.__now_playing["title"] = probs["Track"]["Title"] 131 | self.__now_playing["artist"] = probs["Track"]["Artist"] 132 | self.__now_playing["album"] = probs["Track"]["Album"] 133 | except dbus.DBusException: 134 | pass 135 | except KeyError: 136 | self.__now_playing = None 137 | 138 | def pause(self): 139 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Pause" 140 | subprocess.call(self.__cmd_arr) 141 | 142 | def next(self): 143 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Next" 144 | subprocess.call(self.__cmd_arr) 145 | 146 | def previous(self): 147 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Previous" 148 | subprocess.call(self.__cmd_arr) 149 | 150 | def repeat(self): 151 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Repeat" 152 | subprocess.call(self.__cmd_arr) 153 | 154 | def fast_forward(self): 155 | pass 156 | 157 | def rewind(self): 158 | pass 159 | 160 | def stop(self): 161 | self.output_stream.silence(True) 162 | self.__rds_updater.stop() 163 | self.__terminating = True 164 | time.sleep(1) 165 | self.output_stream.stop() 166 | print("bluetooth player stopped") 167 | 168 | def song_name(self): 169 | return self.__now_playing["title"] 170 | 171 | def song_artist(self): 172 | return self.__now_playing["artist"] 173 | 174 | def song_year(self): 175 | return self.__now_playing["year"] 176 | 177 | def song_album(self): 178 | return self.__now_playing["album"] 179 | 180 | -------------------------------------------------------------------------------- /src/bluetooth_player_lite.py: -------------------------------------------------------------------------------- 1 | import dbus 2 | from player import Player 3 | import subprocess 4 | from bytearray_io import BytearrayIO 5 | from rds import RdsUpdater 6 | import time 7 | import math 8 | import threading 9 | import pyaudio 10 | import wave 11 | 12 | 13 | class BtPlayerLite(Player): 14 | 15 | __bt_addr = None 16 | __cmd_arr = None 17 | __rds_updater = None 18 | __now_playing = None 19 | output_stream = None 20 | __terminating = False 21 | CHUNK = None 22 | 23 | def __init__(self, bt_addr): 24 | super().__init__() 25 | self.update_alsa_device(bt_addr) 26 | self.__rds_updater = RdsUpdater() 27 | self.__bt_addr = bt_addr 28 | self.__cmd_arr = ["sudo", "dbus-send", "--system", "--type=method_call", "--dest=org.bluez", "/org/bluez/hci0/dev_" 29 | + bt_addr.replace(":", "_").upper() + "/player0", "org.bluez.MediaPlayer1.Pause"] 30 | 31 | self.p = pyaudio.PyAudio() 32 | self.out_s = None 33 | 34 | def set_out_stream(self, outs): 35 | self.out_s = outs 36 | 37 | def playback_position(self): 38 | pass 39 | 40 | def resume(self): 41 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Play" 42 | subprocess.call(self.__cmd_arr) 43 | 44 | def run(self): 45 | threading.Thread(target=self.__run).start() 46 | 47 | def __run(self): 48 | print("playing bluetooth:", self.__bt_addr) 49 | dev = "bluealsa:HCI=hci0,DEV="+self.__bt_addr 50 | self.__rds_updater.run() 51 | while not self.__terminating: 52 | self.play(dev) 53 | time.sleep(1) 54 | 55 | def update_alsa_device(self, device): 56 | cmd = ["sed", "-i", "s/^defaults.bluealsa.device.*$/defaults.bluealsa.device "+device+"/g", "/etc/asound.conf"] 57 | subprocess.call(cmd) 58 | 59 | def play(self, device): 60 | # open input device 61 | dev = None 62 | for i in range(self.p.get_device_count()): 63 | if self.p.get_device_info_by_index(i)['name'] == 'bluealsa': 64 | dev = self.p.get_device_info_by_index(i) 65 | 66 | # Consider 1 byte = 8 bit uncompressed mono signal 67 | # double that for a stereo signal, we get 2 bytes, 68 | # 16 bit stereo means 4 bytes audio frames 69 | in_channels = 2 70 | in_fmt = pyaudio.paInt16 71 | # 44100 frames per second means 176400 bytes per second or 1411.2 Kbps 72 | sample_rate = 44100 73 | 74 | buffer_time = 50 # 50ms audio coverage per iteration 75 | 76 | # How many frames to read each time. for 44100Hz 44,1 is 1ms equivalent 77 | frame_chunk = math.ceil(int((sample_rate / 1000) * buffer_time)) 78 | 79 | # This will setup the stream to read CHUNK frames 80 | audio_stream = self.p.open(sample_rate, channels=in_channels, format=in_fmt, input=True, 81 | input_device_index=dev['index'], frames_per_buffer=frame_chunk) 82 | 83 | # open output stream 84 | self.output_stream = BytearrayIO() 85 | if self.out_s is not None: 86 | self.output_stream.set_out_stream(self.out_s) 87 | 88 | container = wave.open(self.output_stream, 'wb') 89 | container.setnchannels(in_channels) 90 | container.setsampwidth(self.p.get_sample_size(in_fmt)) 91 | container.setframerate(sample_rate) 92 | 93 | self.ready.set() 94 | 95 | while not self.__terminating: 96 | try: 97 | data = audio_stream.read(frame_chunk, False) # NB: If debugging, remove False 98 | container.writeframesraw(data) 99 | except OSError: 100 | self.__terminating = True 101 | break 102 | # TODO: stopping on bluetooth detach should be fully handled by the main thread 103 | 104 | # close output container and tell the buffer no more data is coming 105 | container.close() 106 | try: 107 | audio_stream.stop_stream() 108 | except OSError: 109 | pass 110 | audio_stream.close() 111 | self.p.terminate() 112 | 113 | def get_now_playing(self): 114 | try: 115 | bus = dbus.SystemBus() 116 | player = bus.get_object("org.bluez", "/org/bluez/hci0/dev_" + self.__bt_addr.replace(":", "_").upper() 117 | + "/player0") 118 | BT_Media_iface = dbus.Interface(player, dbus_interface="org.bluez.MediaPlayer1") 119 | BT_Media_probs = dbus.Interface(player, "org.freedesktop.DBus.Properties") 120 | probs = BT_Media_probs.GetAll("org.bluez.MediaPlayer1") 121 | 122 | self.__now_playing = dict() 123 | self.__now_playing["title"] = probs["Track"]["Title"] 124 | self.__now_playing["artist"] = probs["Track"]["Artist"] 125 | self.__now_playing["album"] = probs["Track"]["Album"] 126 | except dbus.DBusException: 127 | pass 128 | except KeyError: 129 | self.__now_playing = None 130 | 131 | def pause(self): 132 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Pause" 133 | subprocess.call(self.__cmd_arr) 134 | 135 | def next(self): 136 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Next" 137 | subprocess.call(self.__cmd_arr) 138 | 139 | def previous(self): 140 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Previous" 141 | subprocess.call(self.__cmd_arr) 142 | 143 | def repeat(self): 144 | self.__cmd_arr[len(self.__cmd_arr) - 1] = "org.bluez.MediaPlayer1.Repeat" 145 | subprocess.call(self.__cmd_arr) 146 | 147 | def fast_forward(self): 148 | pass 149 | 150 | def rewind(self): 151 | pass 152 | 153 | def stop(self): 154 | self.output_stream.silence(True) 155 | self.__rds_updater.stop() 156 | self.__terminating = True 157 | time.sleep(1) 158 | self.output_stream.stop() 159 | print("bluetooth player stopped") 160 | 161 | def song_name(self): 162 | return self.__now_playing["title"] 163 | 164 | def song_artist(self): 165 | return self.__now_playing["artist"] 166 | 167 | def song_year(self): 168 | return self.__now_playing["year"] 169 | 170 | def song_album(self): 171 | return self.__now_playing["album"] 172 | 173 | -------------------------------------------------------------------------------- /src/bluetooth_remote.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import bluetooth 3 | import ast 4 | 5 | 6 | class BtRemote: 7 | 8 | __remote_event = None # event generated by the the remote (this class) 9 | __msg = None # message exchange object 10 | __server_socket = None # bluetooth server socket 11 | __client_socket = None # bluetooth client socket 12 | __termination = None 13 | __SOCKET_TIMEOUT = 5 # socket timeout in seconds 14 | 15 | def __init__(self, remote_event, message): 16 | super().__init__() 17 | self.__remote_event = remote_event 18 | self.__msg = message 19 | self.__server_socket = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 20 | self.__server_socket.settimeout(self.__SOCKET_TIMEOUT) 21 | self.__termination = threading.Event() 22 | 23 | def run(self): 24 | threading.Thread(target=self.__run).start() 25 | 26 | def __run(self): 27 | self.__server_socket.bind(("", bluetooth.PORT_ANY)) 28 | self.__server_socket.listen(1) 29 | port = self.__server_socket.getsockname()[1] 30 | uuid = "00001101-0000-1000-8000-00805f9b34fb" # android app looks for this 31 | 32 | bluetooth.advertise_service(self.__server_socket, "MPRadio", 33 | service_id=uuid, 34 | service_classes=[uuid, bluetooth.SERIAL_PORT_CLASS], 35 | profiles=[bluetooth.SERIAL_PORT_PROFILE],) 36 | 37 | self.__accept_connection() 38 | 39 | while not self.__termination.is_set(): 40 | try: 41 | cmd = self.__client_socket.recv(1024) 42 | except bluetooth.btcommon.BluetoothError as e: # if a client disconnects, listen for new ones 43 | if "timed out" in str(e).lower(): 44 | continue 45 | else: 46 | self.__accept_connection() 47 | continue 48 | 49 | if len(cmd) > 0: 50 | # cmd = cmd.decode().strip().split() # .lower() 51 | cmd = ast.literal_eval(cmd.decode()) 52 | self.__msg["command"] = cmd["command"].split() 53 | self.__msg["data"] = cmd["data"] 54 | self.__msg["source"] = "bluetooth" 55 | print("bluetooth_remote received:", cmd) 56 | self.__remote_event.set() 57 | 58 | print("bluetooth remote stopped") 59 | 60 | def __accept_connection(self): 61 | self.__client_socket = None 62 | while self.__client_socket is None: 63 | try: 64 | self.__client_socket, address = self.__server_socket.accept() 65 | except bluetooth.BluetoothError as e: 66 | # print("ERROR while accept_connection:", str(e)) 67 | if "timed out" in str(e).lower(): 68 | # self.__client_socket = None # should be default 69 | continue 70 | else: 71 | # print("bluetooth socket closed") 72 | return 73 | print("Acceppted one bluetooth connection!") 74 | self.__client_socket.settimeout(self.__SOCKET_TIMEOUT) 75 | 76 | def reply(self, message): 77 | try: 78 | self.__client_socket.sendall(bytes(message+"\0", 'UTF-8')) 79 | except bluetooth.btcommon.BluetoothError: 80 | pass 81 | 82 | def stop(self): 83 | print("stopping bluetooth remote...") 84 | self.__termination.set() 85 | if self.__client_socket is not None: 86 | self.__client_socket.close() 87 | if self.__server_socket is not None: 88 | self.__server_socket.close() 89 | -------------------------------------------------------------------------------- /src/bytearray_io.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import time 3 | 4 | 5 | class BytearrayIO: 6 | 7 | def __init__(self): 8 | self.buf_size = 1024 * 1000 # 1M 9 | self.buf = bytearray(self.buf_size) 10 | self.mv = memoryview(self.buf) 11 | self.__last_r = 0 12 | self.__last_w = 0 13 | self.__wrap_around_at = self.buf_size 14 | self.__write_completed = False 15 | self.__available = 0 16 | self.__terminating = False 17 | self.out_stream = None 18 | 19 | def set_out_stream(self, out_s): 20 | self.out_stream = out_s 21 | 22 | def silence(self, silent=True): 23 | pass 24 | 25 | def read(self, size=16384): 26 | while True: 27 | if self.__terminating: 28 | break 29 | 30 | # buffer underrun protection by checking total amount of available data 31 | # note that this is an absolute value and doesn't consider wrap around(s) 32 | if self.__available <= 0: 33 | time.sleep(0.001) 34 | # print("buffer underrun. available =", self.__available) 35 | continue 36 | 37 | # reader wrap around when reaching last written byte 38 | if self.__last_r == self.__wrap_around_at: 39 | self.__last_r = 0 40 | print("read wrap around at", self.__wrap_around_at, "avail:", self.__available) 41 | 42 | start = self.__last_r 43 | 44 | # available data in the buffer (wrap around aware) 45 | # if margin > 0, the read head is following the write head: |--r--w--| 46 | # if margin < 0, the write head has wrapped around and is following/reaching the read head: |--w--r--| 47 | margin = self.__last_w - start 48 | 49 | # if the writer already wrapped around we still need to consume the buffer until the wrap around point 50 | if margin < 0: 51 | margin = self.__wrap_around_at - start 52 | # print("writer wrap around detected") 53 | 54 | # read what's available (could be lower than requested size) 55 | rs = min(size, margin) 56 | 57 | self.__available -= rs 58 | # print("now available:", self.available) 59 | 60 | end = start + rs 61 | self.__last_r = end 62 | return self.buf[start:end] 63 | 64 | def write(self, b: Union[bytes, bytearray]): 65 | if self.out_stream is None: 66 | size = len(b) 67 | 68 | # write wrap around 69 | if self.__last_w + size > self.buf_size: 70 | self.__wrap_around_at = self.__last_w 71 | self.__last_w = 0 72 | print("write wrap around at", self.__wrap_around_at) 73 | 74 | start = self.__last_w 75 | self.__last_w = start + size 76 | self.__available += size 77 | try: 78 | self.mv[start:self.__last_w] = b 79 | except ValueError: 80 | print("Value error. len(buf) = ", len(self.buf), "last_w = ", self.__last_w) 81 | else: 82 | try: 83 | self.out_stream.write(b) 84 | except BrokenPipeError: 85 | print("Broken pipe to output") 86 | 87 | def set_write_completed(self): 88 | self.__write_completed = True 89 | 90 | def is_read_completed(self): 91 | if self.__last_w > 0 and self.__last_w == self.__last_r and self.__write_completed: 92 | return True 93 | else: 94 | return False 95 | 96 | def seek_to_start(self): 97 | self.__last_r = 0 98 | 99 | def stop(self): 100 | self.__terminating = True 101 | 102 | # IoBase dummy interface implementation for pyav 103 | def seek(self, offset: int, whence: int = ...): 104 | pass 105 | 106 | def tell(self): 107 | return 0 108 | 109 | def flush(self): 110 | pass 111 | 112 | def close(self): 113 | pass 114 | -------------------------------------------------------------------------------- /src/configuration.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from configparser import ConfigParser 3 | import json 4 | 5 | 6 | class Configuration: 7 | 8 | __config = None 9 | __pirateradio_root = "/pirateradio/" 10 | __sounds_folder = "/home/pi/mpradio/sounds/" # TODO: maybe move sounds to /home/pi/sounds? 11 | __music_folder = "" # to be set depending on platform 12 | __config_file_name = "pirateradio.config" 13 | __config_file_path = "" 14 | __resume_file = "resume.json" 15 | __playlist_file = "playlist.json" 16 | __library_file = "library.json" 17 | __stop_sound = "silence.wav" 18 | __rds_ctl = "/tmp/rds_ctl" 19 | __ctl_path = "/tmp/mpradio_bt" 20 | 21 | def __init__(self): 22 | self.__config = ConfigParser() 23 | self.__config.optionxform = str 24 | 25 | if platform.machine() == "x86_64": 26 | self.__config_file_path = "../install/pirateradio/" + self.__config_file_name 27 | else: 28 | self.__config_file_path = self.__pirateradio_root + self.__config_file_name 29 | 30 | self.load() 31 | 32 | def load(self): 33 | if platform.machine() == "x86_64": 34 | if len(self.__config.read(self.__config_file_path)) < 1: 35 | print("Configuration file missing:", self.__config_file_path, 36 | "make sure you are running mpradio.py from src/ folder") 37 | 38 | # override system-specific configurations if not on a Pi 39 | self.__config["PIRATERADIO"]["output"] = "analog" 40 | self.__sounds_folder = "../sounds/" 41 | self.__music_folder = "../songs/" 42 | else: 43 | if len(self.__config.read(self.__config_file_path)) < 1: 44 | print("Configuration file missing:", self.__config_file_path, 45 | "make sure you have pirateradio.config under", self.__pirateradio_root, "folder") 46 | 47 | self.__resume_file = self.__pirateradio_root + self.__resume_file 48 | self.__playlist_file = self.__pirateradio_root + self.__playlist_file 49 | self.__library_file = self.__pirateradio_root + self.__library_file 50 | self.__music_folder = self.__pirateradio_root 51 | 52 | def save(self): 53 | with open(self.__config_file_path, 'w') as configfile: 54 | self.__config.write(configfile) 55 | 56 | def load_json(self, configuration): 57 | self.__config.read_dict(json.loads(configuration)) 58 | self.save() 59 | 60 | def to_json(self): 61 | d = dict() 62 | for section in dict(self.__config.items()): 63 | if section == "DEFAULT": 64 | continue 65 | d[section] = dict() 66 | for item in dict(self.__config.items(section)): 67 | d[section][item] = self.__config[section][item] 68 | return json.dumps(d) 69 | 70 | def get_settings(self): 71 | return self.__config 72 | 73 | def get_root(self): 74 | return self.__pirateradio_root 75 | 76 | def get_resume_file(self): 77 | return self.__resume_file 78 | 79 | def get_sounds_folder(self): 80 | return self.__sounds_folder 81 | 82 | def get_stop_sound(self): 83 | return self.__stop_sound 84 | 85 | def get_playlist_file(self): 86 | return self.__playlist_file 87 | 88 | def get_library_file(self): 89 | return self.__library_file 90 | 91 | def get_music_folder(self): 92 | return self.__music_folder 93 | 94 | def get_rds_ctl(self): 95 | return self.__rds_ctl 96 | 97 | def get_ctl_path(self): 98 | return self.__ctl_path 99 | 100 | 101 | # This will be executed on first module import only 102 | config = Configuration() 103 | -------------------------------------------------------------------------------- /src/control_pipe.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import threading 4 | from configuration import config 5 | 6 | 7 | class ControlPipe: 8 | __ctl_path = None 9 | __control = None 10 | __event = None 11 | __termination = None 12 | 13 | def __init__(self, event, message): 14 | self.__termination = threading.Event() 15 | self.__event = event 16 | self.__msg = message 17 | self.__ctl_path = config.get_ctl_path() 18 | self.fifo_setup() 19 | 20 | # TODO: always try to delete, then make fifo (cleanup events before starting) 21 | def fifo_setup(self): 22 | try: 23 | os.mkfifo(self.__ctl_path) 24 | except FileExistsError: 25 | os.remove(self.__ctl_path) 26 | os.mkfifo(self.__ctl_path) 27 | pass 28 | self.__control = os.open(self.__ctl_path, os.O_RDONLY | os.O_NONBLOCK) 29 | 30 | def listen(self): 31 | threading.Thread(target=self.__listen).start() 32 | 33 | def __listen(self): 34 | while not self.__termination.is_set(): 35 | time.sleep(0.2) 36 | # cmd = os.read(self.__control, 100).decode().strip().lower().split() 37 | try: 38 | cmd = os.read(self.__control, 500).decode().split() 39 | except BlockingIOError: 40 | print("control_pipe: BlockingIOError") 41 | continue 42 | 43 | if len(cmd) > 0: 44 | self.__msg["command"] = cmd 45 | self.__msg["source"] = "control_pipe" 46 | self.__event.set() 47 | 48 | def stop(self): 49 | self.__termination.set() 50 | -------------------------------------------------------------------------------- /src/encoder.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from fcntl import fcntl, F_GETFL, F_SETFL 3 | from os import O_NONBLOCK 4 | from configuration import config 5 | import threading 6 | import time 7 | 8 | 9 | class Encoder: 10 | 11 | stream = None 12 | __sox_compression = ["compand", "0.3,1", "6:-70,-60,-20", "-5", "-90", "0.2"] 13 | __compression_supported_models = ("Pi 3", "Pi 2") 14 | input_stream = None 15 | output_stream = None 16 | ready = threading.Event() 17 | 18 | def __init__(self): 19 | self.__sox_cmd = ["sox", "-t", "raw", "-G", "-b", "16", "-e", "signed", 20 | "-c", "2", "-r", "44100", "-", "-t", "wav", "-"] 21 | self.__enable_compression_if_supported() 22 | self.__set_treble() 23 | 24 | def run(self): 25 | self.stream = subprocess.Popen(self.__sox_cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, 26 | stderr=subprocess.PIPE) 27 | while self.stream.pid is None: 28 | print("waiting for the encoder process to spawn...") 29 | time.sleep(0.1) 30 | 31 | # set the encoder to non-blocking output: 32 | flags = fcntl(self.stream.stdout, F_GETFL) # get current stdout flags 33 | fcntl(self.stream.stdout, F_SETFL, flags | O_NONBLOCK) 34 | self.input_stream = self.stream.stdin 35 | self.output_stream = self.stream.stdout 36 | 37 | self.ready.set() 38 | 39 | def reload(self): 40 | self.ready.clear() 41 | self.stop() 42 | self.__init__() 43 | self.run() 44 | 45 | def stop(self): 46 | self.stream.kill() 47 | 48 | def __enable_compression_if_supported(self): 49 | try: 50 | with open("/sys/firmware/devicetree/base/model") as f: 51 | model = f.read() 52 | for supp in self.__compression_supported_models: 53 | if supp in model: 54 | self.__sox_cmd.extend(self.__sox_compression) 55 | except FileNotFoundError: 56 | pass 57 | 58 | def __set_treble(self): 59 | treble = config.get_settings()["PIRATERADIO"]["treble"] 60 | if treble != "0": 61 | self.__sox_cmd.extend(["treble", treble]) 62 | -------------------------------------------------------------------------------- /src/fm_output.py: -------------------------------------------------------------------------------- 1 | from output import Output 2 | import subprocess 3 | from configuration import config 4 | from subprocess import call 5 | import os 6 | 7 | 8 | class FmOutput(Output): 9 | 10 | __frequency = None 11 | __rds_ctl = None 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self.__load_settings() 16 | 17 | def __load_settings(self): 18 | self.__frequency = config.get_settings()["PIRATERADIO"]["frequency"] 19 | self.__rds_ctl = config.get_rds_ctl() 20 | try: 21 | os.remove(self.__rds_ctl) 22 | except FileNotFoundError: 23 | pass 24 | try: 25 | os.mkfifo(self.__rds_ctl) 26 | except FileExistsError: 27 | pass 28 | 29 | def run(self): 30 | print("broadcasting on FM", self.__frequency) 31 | self.stream = subprocess.Popen(["sudo", "pi_fm_adv", "--freq", self.__frequency, 32 | "--ctl", self.__rds_ctl, "--audio", "-"], 33 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 34 | self.input_stream = self.stream.stdin 35 | self.ready.set() 36 | 37 | def stop(self): 38 | call(["sudo", "pkill", "-P", str(self.stream.pid)]) 39 | self.stream.wait() 40 | 41 | def reload(self): 42 | self.ready.clear() 43 | self.stop() 44 | self.__load_settings() 45 | self.run() 46 | 47 | def check_reload(self): 48 | if self.__frequency != config.get_settings()["PIRATERADIO"]["frequency"]: 49 | self.reload() 50 | -------------------------------------------------------------------------------- /src/gpio_remote.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import RPi.GPIO as GPIO 3 | import time 4 | 5 | 6 | class GpioRemote: 7 | 8 | __event = None 9 | __msg = None 10 | __s = None 11 | __paused = False 12 | __termination = None 13 | __gpio_mode = GPIO.input 14 | 15 | def __init__(self, event, msg): 16 | self.__event = event 17 | self.__msg = msg 18 | self.__termination = threading.Event() 19 | self.__s = [] 20 | self.reset_s() 21 | 22 | def __run(self): 23 | GPIO.setmode(GPIO.BOARD) 24 | GPIO.setup(5, GPIO.IN) 25 | GPIO.add_event_detect(5, GPIO.RISING) 26 | down = 0 27 | up = 0 28 | fired = False 29 | 30 | while not self.__termination.is_set(): 31 | input_state = not self.__gpio_mode(5) 32 | 33 | if input_state: # button down 34 | up = 0 35 | down += 1 36 | if self.__s[len(self.__s) - 1] == 0: 37 | self.__s.append(1) 38 | else: # button up 39 | up += 1 40 | down = 0 41 | if self.__s[len(self.__s) - 1] == 1: 42 | self.__s.append(0) 43 | 44 | # long press 45 | if down > 20: 46 | fired = True 47 | self.poweroff() 48 | 49 | # single and double click 50 | if up in range(8, 16) and len(self.__s) > 2 and not fired: 51 | action = "".join([str(b) for b in self.__s]) 52 | if action == "010": # single click 53 | fired = True 54 | self.reset_s() 55 | self.next() # TODO: read what to do from ini settings 56 | elif action == "01010": # double click 57 | fired = True 58 | self.reset_s() 59 | self.play_pause() 60 | elif up > 16: # reset status and prepare for next click 61 | self.reset_s() 62 | fired = False 63 | 64 | # cleanup after long time unused 65 | if up > 18000: 66 | self.reset_s() 67 | up = 0 68 | 69 | time.sleep(0.05) 70 | 71 | def run(self): 72 | threading.Thread(target=self.__run).start() 73 | 74 | def play_pause(self): 75 | if self.__paused: 76 | self.resume() 77 | else: 78 | self.pause() 79 | 80 | def resume(self): 81 | self.__paused = False 82 | self.__msg["command"] = ["resume"] 83 | self.__msg["source"] = "gpio" 84 | self.__event.set() 85 | 86 | def pause(self): 87 | self.__paused = True 88 | self.__msg["command"] = ["pause"] 89 | self.__msg["source"] = "gpio" 90 | self.__event.set() 91 | 92 | def next(self): 93 | self.__msg["command"] = ["next"] 94 | self.__msg["source"] = "gpio" 95 | self.__event.set() 96 | 97 | def previous(self): 98 | self.__msg["command"] = ["previous"] 99 | self.__msg["source"] = "gpio" 100 | self.__event.set() 101 | 102 | def poweroff(self): 103 | self.__msg["command"] = ["system", "poweroff"] 104 | self.__msg["source"] = "gpio" 105 | self.__event.set() 106 | 107 | def reset_s(self): 108 | self.__s.clear() 109 | self.__s.append(0) 110 | 111 | def stop(self): 112 | self.__termination.set() 113 | -------------------------------------------------------------------------------- /src/library.json.bak: -------------------------------------------------------------------------------- 1 | [{"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/3.mp3", "title": "3", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/4.mp3", "title": "4", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/Alice\\ Merton\\ -\\ Lash\\ Out\\ (Official\\ Video).mp3", "title": "Alice Merton - Lash Out (Official Video)", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/2.mp3", "title": "2", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}, {"path": "../songs/1.mp3", "title": "1", "artist": "", "album": "songs", "year": ""}] 2 | -------------------------------------------------------------------------------- /src/media.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class MediaControl(ABC): 5 | 6 | @abstractmethod 7 | def run(self): 8 | pass 9 | 10 | @abstractmethod 11 | def resume(self): 12 | pass 13 | 14 | @abstractmethod 15 | def pause(self): 16 | pass 17 | 18 | @abstractmethod 19 | def next(self): 20 | pass 21 | 22 | @abstractmethod 23 | def previous(self): 24 | pass 25 | 26 | @abstractmethod 27 | def repeat(self): 28 | pass 29 | 30 | @abstractmethod 31 | def fast_forward(self): 32 | pass 33 | 34 | @abstractmethod 35 | def rewind(self): 36 | pass 37 | 38 | @abstractmethod 39 | def stop(self): 40 | pass 41 | 42 | 43 | class MediaInfo(ABC): 44 | 45 | @abstractmethod 46 | def song_name(self): 47 | pass 48 | 49 | @abstractmethod 50 | def song_artist(self): 51 | pass 52 | 53 | @abstractmethod 54 | def song_year(self): 55 | pass 56 | 57 | @abstractmethod 58 | def song_album(self): 59 | pass 60 | -------------------------------------------------------------------------------- /src/media_scanner.py: -------------------------------------------------------------------------------- 1 | import os 2 | from mutagen.id3 import ID3NoHeaderError 3 | from mutagen.easyid3 import EasyID3 4 | from configuration import config 5 | import json 6 | 7 | 8 | class MediaScanner: 9 | 10 | supported_formats = ("mp3", "m4a", "wav", "flac", "ogg") 11 | __songs = None 12 | 13 | def __init__(self): 14 | self.__songs = [] 15 | 16 | def scan(self, path=None): 17 | if path is None: 18 | path = config.get_music_folder() 19 | 20 | print("Media scanner scanning folder:", path) 21 | 22 | for root, d_names, f_names in os.walk(path): 23 | for f in f_names: 24 | if f.endswith(self.supported_formats): 25 | # skip cache and unwanted files 26 | if "/." in root: 27 | continue 28 | if f.startswith('.'): 29 | continue 30 | 31 | tmp = dict() 32 | tmp["path"] = os.path.join(root, f).replace(" ", "\\ ") 33 | fallback_title = f 34 | for curr_format in self.supported_formats: 35 | fallback_title = fallback_title.replace("." + curr_format, "") 36 | tmp["title"] = fallback_title 37 | 38 | # Avoid "can only concatenate str (not "NoneType") to str" error everywhere 39 | # default empty string will do just fine. 40 | tmp["artist"] = "" 41 | tmp["album"] = os.path.basename(os.path.dirname(path)) 42 | tmp["year"] = "" 43 | 44 | # Otherwise The application will crash if no id3 header is present 45 | try: 46 | audio_id3 = EasyID3(r""+tmp["path"].replace("\\", "")) 47 | for key in tmp: 48 | if key in audio_id3 and len(audio_id3[key]) > 0: 49 | tmp[key] = audio_id3[key][0] 50 | except ID3NoHeaderError: 51 | pass 52 | 53 | self.__songs.append(tmp) 54 | 55 | self.save_library() 56 | return self.__songs 57 | 58 | def save_library(self): 59 | with open(config.get_library_file(), "w") as f: 60 | j = json.dumps(self.__songs) 61 | f.write(j) 62 | -------------------------------------------------------------------------------- /src/mp_io.py: -------------------------------------------------------------------------------- 1 | import io 2 | import threading 3 | from typing import Union 4 | import time 5 | 6 | 7 | class MpradioIO(io.BytesIO): 8 | 9 | def __init__(self): 10 | super().__init__() 11 | self.__lock = threading.Lock() 12 | self.__last_r = 0 13 | self.__size = 0 14 | self.__write_completed = False 15 | self.__terminating = False 16 | self.__silent = False 17 | self.__first_chunk = True 18 | self.chunk_sleep_time = 0 19 | 20 | def read(self, size=None): 21 | 22 | # generate silence (zeroes) if silent is set 23 | if self.__silent: 24 | if size is None: 25 | size = 1024 * 4 26 | return bytearray(size) 27 | 28 | # read the first chunk with no delay, but read the subsequent chunks after a short delay 29 | # (to be set from the player and < player's buffer time) to give it the time to write before the next read 30 | if not self.__first_chunk: 31 | time.sleep(self.chunk_sleep_time) 32 | else: 33 | self.__first_chunk = False 34 | 35 | while True: 36 | self.__lock.acquire() # thread-safe lock 37 | self.seek(self.__last_r) # Seek to last read position 38 | result = super().read(size) # read the specified amount of bytes 39 | self.__last_r += len(result) # update the last read position 40 | self.__size = self.seek(0, 2) # seek to the end (prepare for next write) 41 | self.__lock.release() 42 | 43 | if len(result) < 1: 44 | # print("MpradioIO error: read 0 bytes.") 45 | time.sleep(0.008) # TODO: maybe align it to bluetooth player buffer time? 46 | if self.__terminating: 47 | break 48 | else: 49 | break 50 | 51 | # print("read", len(result), "bytes") 52 | return result 53 | 54 | def stop(self): 55 | self.__terminating = True 56 | 57 | def silence(self, silent=True): 58 | self.__silent = silent 59 | 60 | def write(self, b: Union[bytes, bytearray]): 61 | self.__lock.acquire() # thread-safe lock 62 | super().write(b) # write 63 | self.__lock.release() # release lock 64 | # print("wrote", len(b), "bytes") 65 | 66 | def set_write_completed(self): 67 | self.__write_completed = True 68 | 69 | def is_read_completed(self): 70 | if self.__size > 0 and self.__size == self.__last_r and self.__write_completed: 71 | return True 72 | else: 73 | return False 74 | 75 | def seek_to_start(self): 76 | self.__last_r = 0 77 | -------------------------------------------------------------------------------- /src/mpradio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import signal 3 | import os 4 | import threading 5 | import time 6 | from encoder import Encoder 7 | from configuration import config # must be imported before all other modules (dependency) 8 | from bluetooth_remote import BtRemote 9 | from bluetooth_player import BtPlayer 10 | from bluetooth_daemon import get_connected_device 11 | from bluetooth_player_lite import BtPlayerLite 12 | from fm_output import FmOutput 13 | from analog_output import AnalogOutput 14 | from storage_player import StoragePlayer 15 | from control_pipe import ControlPipe 16 | from media import MediaControl, MediaInfo 17 | import platform 18 | from subprocess import call, Popen, PIPE 19 | import json 20 | 21 | 22 | class Mpradio: 23 | 24 | control_pipe = None 25 | bt_remote = None 26 | gpio_remote = None 27 | remote_event = None 28 | remote_msg = None 29 | media_control_methods = None 30 | media_info_methods = None 31 | remotes_termination = None 32 | 33 | player = None 34 | encoder = None 35 | output = None 36 | 37 | def __init__(self): 38 | super().__init__() 39 | signal.signal(signal.SIGUSR1, self.handler) 40 | signal.signal(signal.SIGINT, self.termination_handler) 41 | self.remote_msg = dict() 42 | self.remotes_termination = threading.Event() 43 | self.remote_event = threading.Event() # Event for signaling control thread(s) events to main thread 44 | self.reply_event = threading.Event() 45 | self.control_pipe = ControlPipe(self.remote_event, self.remote_msg) 46 | self.bt_remote = BtRemote(self.remote_event, self.remote_msg) 47 | 48 | # TODO: maybe refractor into player_methods and always eval()? 49 | self.media_control_methods = [f for f in dir(MediaControl) 50 | if not f.startswith('_') and callable(getattr(MediaControl, f))] 51 | self.media_info_methods = [f for f in dir(MediaInfo) 52 | if not f.startswith('_') and callable(getattr(MediaInfo, f))] 53 | self.player = StoragePlayer() 54 | self.encoder = Encoder() 55 | 56 | if config.get_settings()["PIRATERADIO"]["output"] == "fm": 57 | self.output = FmOutput() 58 | else: 59 | self.output = AnalogOutput() 60 | 61 | def handler(self, signum, frame): 62 | print("received signal", signum) 63 | 64 | def termination_handler(self, signum, frame): 65 | print("stopping threads and clean termination...") 66 | self.remotes_termination.set() 67 | self.player.stop() 68 | self.encoder.stop() 69 | self.output.stop() 70 | quit(0) 71 | 72 | def run(self): 73 | self.encoder.run() 74 | self.output.run() 75 | self.player.set_out_stream(self.output.input_stream) 76 | self.player.run() 77 | self.bt_remote.run() 78 | self.control_pipe.listen() 79 | if platform.machine() != "x86_64": 80 | from gpio_remote import GpioRemote 81 | self.gpio_remote = GpioRemote(self.remote_event, self.remote_msg) 82 | self.gpio_remote.run() 83 | 84 | threading.Thread(target=self.check_remotes).start() 85 | 86 | ''' 87 | # play stream 88 | while True: 89 | self.player.ready.wait() 90 | data = self.player.output_stream.read() 91 | 92 | if data is not None: 93 | self.output.ready.wait() 94 | self.output.input_stream.write(data) 95 | t = 0.005 96 | wait_time = ((len(data)/4)/44.1) * 0.001 97 | # print("just read", len(data), "bytes. sleeping for", wait_time, "-", t) 98 | if wait_time >= t: 99 | wait_time -= t 100 | time.sleep(wait_time) 101 | # print("advancing playhead...") 102 | ''' 103 | 104 | def check_remotes(self): 105 | while not self.remotes_termination.is_set(): 106 | time.sleep(0.02) 107 | if self.remote_event.is_set(): 108 | self.remote_event.clear() 109 | try: 110 | cmd = self.remote_msg["command"] 111 | except KeyError: 112 | continue 113 | 114 | if cmd[0] in self.media_control_methods: 115 | exec("self.player."+cmd[0]+"()") 116 | elif cmd[0] in self.media_info_methods: 117 | result = eval("self.player."+cmd[0]+"()") 118 | if self.remote_msg["source"] == "bluetooth": 119 | self.bt_remote.reply(result) 120 | elif cmd[0] == "bluetooth": 121 | if cmd[1] == "attach": 122 | mac = get_connected_device() 123 | if self.player.__class__.__name__ == "BtPlayerLite" or mac is None: 124 | continue 125 | tmp = BtPlayerLite(mac) 126 | self.player.stop() 127 | self.player = tmp 128 | self.player.set_out_stream(self.output.input_stream) 129 | self.player.run() 130 | print("bluetooth attached") 131 | elif cmd[1] == "detach": 132 | if self.player.__class__.__name__ != "BtPlayerLite": 133 | continue 134 | self.player.stop() 135 | self.player = StoragePlayer() 136 | self.player.set_out_stream(self.output.input_stream) 137 | self.player.run() 138 | # self.player.ready.wait() 139 | print("bluetooth detached") 140 | elif cmd[0] == "system": 141 | if cmd[1] == "poweroff": 142 | self.player.pause() 143 | call(["sudo", "poweroff"]) 144 | elif cmd[1] == "reboot": 145 | self.player.pause() 146 | call(["sudo", "reboot"]) 147 | elif cmd[1] == "wifi-switch" and cmd[2] == "status": 148 | if self.remote_msg["source"] == "bluetooth": 149 | result = Popen(cmd[1:], stdout=PIPE).stdout.read().decode() 150 | self.bt_remote.reply(result) 151 | else: 152 | call(["sudo"] + cmd[1:]) 153 | 154 | elif cmd[0] == "play": 155 | if self.player.__class__.__name__ != "StoragePlayer": 156 | continue 157 | what = json.loads(self.remote_msg["data"]) 158 | self.player.play_on_demand(what) 159 | elif cmd[0] == "playlist": 160 | try: 161 | with open(config.get_playlist_file()) as file: # TODO: implement in player 162 | pl = str(json.load(file)) 163 | self.bt_remote.reply(pl) 164 | except FileNotFoundError: 165 | pass 166 | elif cmd[0] == "library": 167 | try: 168 | with open(config.get_library_file()) as file: 169 | lib = str(json.load(file)) 170 | self.bt_remote.reply(lib) 171 | except FileNotFoundError: 172 | pass 173 | elif cmd[0] == "config": 174 | if cmd[1] == "get": 175 | self.bt_remote.reply(config.to_json()) 176 | elif cmd[1] == "set": 177 | cfg = self.remote_msg["data"] 178 | self.apply_configuration(cfg) 179 | elif cmd[1] == "reload": # TODO: remove. this is for testing purposes only 180 | self.reload_configuration() 181 | else: 182 | print("unknown command received:", cmd) 183 | self.remote_msg.clear() # clean for next usage 184 | 185 | # Remote checker termination 186 | self.control_pipe.stop() 187 | self.bt_remote.stop() 188 | if self.gpio_remote is not None: 189 | self.gpio_remote.stop() 190 | 191 | def apply_configuration(self, cfg): 192 | config.load_json(cfg) 193 | self.reload_configuration() 194 | 195 | def reload_configuration(self): 196 | self.player.pause() # player must be paused/silenced to avoid audio feed loop on fm transmission 197 | self.encoder.reload() # encoded must be reloaded to avoid broken pipe 198 | self.output.check_reload() # don't restart output if not needed 199 | self.player.resume() 200 | 201 | 202 | if __name__ == "__main__": 203 | print('mpradio main PID is:', os.getpid()) 204 | 205 | mpradio = Mpradio() 206 | mpradio.run() 207 | 208 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /src/output.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import threading 3 | 4 | 5 | class Output(ABC): 6 | 7 | stream = None 8 | input_stream = None 9 | output_stream = None 10 | ready = None 11 | 12 | def __init__(self): 13 | self.ready = threading.Event() 14 | 15 | @abstractmethod 16 | def run(self): 17 | pass 18 | 19 | @abstractmethod 20 | def stop(self): 21 | pass 22 | 23 | def reload(self): 24 | pass 25 | 26 | def check_reload(self): 27 | pass 28 | -------------------------------------------------------------------------------- /src/player.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from media import MediaInfo, MediaControl 3 | import threading 4 | import subprocess 5 | import time 6 | 7 | 8 | class Player(MediaControl, MediaInfo): 9 | 10 | CHUNK = 1024 * 8 # set to 8192 for it to perform well on the orignal Pi 1. For any newer model, 2048 will do. 11 | SLEEP_TIME = 0.035 12 | output_stream = None 13 | ready = None 14 | 15 | def __init__(self): 16 | self.ready = threading.Event() 17 | 18 | @abstractmethod 19 | def playback_position(self): 20 | pass 21 | 22 | @abstractmethod 23 | def set_out_stream(self, outs): 24 | pass 25 | 26 | """ 27 | legacy method for generating silence 28 | def silence(self, silence_time=1.2): 29 | tmp_stream = self.output_stream 30 | self.output_stream = subprocess.Popen(["sox", "-n", "-r", "48000", "-b", "16", "-c", "1", "-t", "wav", "-", 31 | "trim", "0", str(silence_time)], 32 | stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout 33 | time.sleep(silence_time) 34 | self.output_stream = tmp_stream 35 | """ 36 | -------------------------------------------------------------------------------- /src/playlist.py: -------------------------------------------------------------------------------- 1 | from media_scanner import MediaScanner 2 | import json 3 | from os import path 4 | from configuration import config 5 | from random import randint 6 | 7 | 8 | class Playlist: 9 | 10 | __queued = None 11 | __played = None 12 | __current = None 13 | __ms = None 14 | __playlist_file = None 15 | __noshuffle = False 16 | 17 | def __init__(self): 18 | self.__playlist_file = config.get_playlist_file() 19 | self.load_playlist() 20 | self.__ms = MediaScanner() 21 | if self.__queued is None: 22 | self.__queued = self.__ms.scan() 23 | self.save_playlist() 24 | self.__played = [] 25 | 26 | def __iter__(self): 27 | return self 28 | 29 | def __next__(self): 30 | if len(self.__queued) > 0: 31 | idx = len(self.__queued) - 1 32 | # pop a random song according to settings. 33 | # Unless another song must be resumed from previous boot 34 | if config.get_settings()["PLAYLIST"]["shuffle"] == "true" and not self.__noshuffle: 35 | idx = randint(0, len(self.__queued) - 1) 36 | self.__noshuffle = False 37 | self.__current = self.__queued.pop(idx) 38 | self.__played.append(self.__current) 39 | else: 40 | self.__queued = self.__ms.scan() 41 | try: 42 | self.__current = self.__queued.pop() # TODO: handle the "no songs" scenario 43 | except IndexError: 44 | print("ERR: No songs to play") 45 | return None 46 | self.__played.append(self.__current) 47 | 48 | # print("\n\nplaylist:", [song["path"] for song in self.__queued], "\n") 49 | # print("played:", [song["path"] for song in self.__played], "\n\n") 50 | self.save_playlist() 51 | 52 | return self.__current 53 | 54 | def save_playlist(self): 55 | with open(self.__playlist_file, "w") as f: 56 | j = json.dumps(self.__queued) 57 | f.write(j) 58 | 59 | def load_playlist(self): 60 | if not path.isfile(self.__playlist_file): 61 | return 62 | with open(self.__playlist_file) as file: 63 | try: 64 | self.__queued = json.load(file) 65 | except json.decoder.JSONDecodeError: 66 | print("playlist file damaged") 67 | return 68 | 69 | def back(self, n=0): 70 | for _ in range(n+1): 71 | try: 72 | s = self.__played.pop() 73 | s["position"] = "0" 74 | self.__queued.append(s) 75 | except IndexError: 76 | print("no songs left in playback history") 77 | 78 | def elements(self): 79 | return self.__queued 80 | 81 | def current(self): 82 | return self.__current 83 | 84 | def add(self, song): 85 | self.__queued.append(song) 86 | 87 | def set_noshuffle(self): 88 | self.__noshuffle = True 89 | -------------------------------------------------------------------------------- /src/prof.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import psutil 4 | 5 | 6 | class Profiler: 7 | __termination = threading.Event() 8 | __l = None 9 | __cpu_monitoring = False 10 | 11 | def __init__(self, cpu_mon=True): 12 | self.__l = threading.Lock() 13 | self.__basetime = None 14 | self.__cpu_graph = [] 15 | self.__event_graph = [] 16 | self.__cpu_monitoring = cpu_mon 17 | 18 | def add(self, event): 19 | d = self.__get_cpu_status() 20 | d["event"] = event 21 | # print(">>", d["time"], ":", event) 22 | self.__add(d) 23 | 24 | def start(self): 25 | self.__basetime = time.time() 26 | if self.__cpu_monitoring: 27 | threading.Thread(target=self.__cpu_monitor).start() 28 | 29 | def stop(self): 30 | self.__termination.set() 31 | 32 | def __cpu_monitor(self): 33 | while not self.__termination.is_set(): 34 | self.__add(self.__get_cpu_status()) 35 | time.sleep(5) 36 | 37 | def __get_cpu_status(self): 38 | t = time.time() - self.__basetime 39 | if self.__cpu_monitoring: 40 | perc = psutil.cpu_percent() 41 | else: 42 | perc = 0 43 | return {"time": t, "cpu": perc} 44 | 45 | def __add(self, point): 46 | self.__l.acquire(blocking=True) 47 | self.__cpu_graph.append(point) 48 | self.__l.release() 49 | 50 | def print_stats(self): 51 | for i in self.__cpu_graph: 52 | print("t:", i["time"], "cpu:", i["cpu"], i["event"] if i.get("event") else "") 53 | 54 | self.export_csv() 55 | 56 | def export_csv(self, name="prof.csv"): 57 | with open(name, "w") as f: 58 | f.write("time;cpu;event\n") 59 | for i in self.__cpu_graph: 60 | s = str(i["time"]) + ";" + str(i["cpu"]) + ";" + (i["event"] if i.get("event") else "") 61 | s += "\n" 62 | f.write(s) 63 | -------------------------------------------------------------------------------- /src/rds.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import platform 4 | from configuration import config 5 | 6 | 7 | class RdsUpdater: 8 | 9 | __interval = None 10 | __termination = None 11 | __song = None 12 | __step = None 13 | __output = None 14 | __rds_ctl = None 15 | __updated = False 16 | 17 | def __init__(self): 18 | self.__termination = threading.Event() 19 | self.__rds_ctl = config.get_rds_ctl() 20 | 21 | if platform.machine() == "x86_64": 22 | self.__output = print 23 | else: 24 | self.__output = self.write_rds_to_pipe 25 | 26 | self.__interval = int(config.get_settings()["RDS"]["updateInterval"]) 27 | self.__step = int(config.get_settings()["RDS"]["charsJump"]) 28 | 29 | def set(self, song): 30 | if song != self.__song: 31 | self.__song = song 32 | self.__updated = True 33 | 34 | def write_rds_to_pipe(self, text): 35 | with open(self.__rds_ctl, "w") as f: 36 | text = text.strip() + "\n" 37 | f.write("PS "+text) 38 | f.write("RT "+text) 39 | 40 | def __run(self): 41 | while not self.__termination.is_set(): 42 | # wait for the song to be set. 43 | if self.__song is None: 44 | time.sleep(0.2) 45 | continue 46 | 47 | for qg in self.q_gram(self.__song["title"]+" - "+self.__song["artist"]): 48 | self.__output(qg) 49 | if self.__termination.is_set(): 50 | return 51 | if self.__updated: 52 | self.__updated = False 53 | break 54 | time.sleep(self.__interval) 55 | 56 | # print q-grams of the given title 57 | def q_gram(self, text): 58 | q = [] 59 | 60 | if len(text) < 9: 61 | q.append(text) 62 | return q 63 | 64 | for i in range(0, len(text), self.__step): 65 | start = i 66 | end = i + 8 67 | if end > len(text): 68 | break 69 | s = text[start:end] 70 | q.append(s) 71 | return q 72 | 73 | def run(self): 74 | threading.Thread(target=self.__run).start() 75 | 76 | def stop(self): 77 | self.__termination.set() 78 | -------------------------------------------------------------------------------- /src/storage_bluetooth_player.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import time 3 | from timer import Timer 4 | from player import Player 5 | from playlist import Playlist 6 | from rds import RdsUpdater 7 | import threading 8 | import json 9 | from configuration import config 10 | import psutil 11 | import av 12 | from mp_io import MpradioIO 13 | 14 | 15 | class StoragePlayer(Player): 16 | 17 | __terminating = False 18 | __playlist = None 19 | __now_playing = None 20 | __timer = None 21 | __resume_file = None 22 | __rds_updater = None 23 | __skip = None 24 | __out = None 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.__playlist = Playlist() 29 | self.__rds_updater = RdsUpdater() 30 | self.__resume_file = config.get_resume_file() 31 | self.__skip = threading.Event() 32 | 33 | def playback_position(self): 34 | return self.__timer.get_time() 35 | 36 | def __update_playback_position_thread(self): 37 | while not self.__terminating: 38 | if self.__now_playing is not None: 39 | self.__now_playing["position"] = self.playback_position() 40 | with open(self.__resume_file, "w") as f: 41 | j = json.dumps(self.__now_playing) 42 | f.write(j) 43 | time.sleep(5) 44 | 45 | def __update_playback_position(self): 46 | threading.Thread(target=self.__update_playback_position_thread).start() 47 | 48 | def __retrive_last_boot_playback(self): 49 | if not path.isfile(self.__resume_file): 50 | # start the timer from 0 51 | self.__timer = Timer() 52 | return 53 | 54 | try: 55 | with open(self.__resume_file) as file: 56 | song = json.load(file) 57 | except json.decoder.JSONDecodeError: 58 | self.__timer = Timer() 59 | return 60 | 61 | if song is not None: 62 | # resume the timer from previous state 63 | try: 64 | self.__timer = Timer(song["position"]) 65 | self.enqueue(song) 66 | except TypeError: 67 | self.__timer = Timer() 68 | except KeyError: 69 | self.__timer = Timer() 70 | else: 71 | self.__timer = Timer() 72 | 73 | def run(self): 74 | threading.Thread(target=self.__run).start() 75 | 76 | def __run(self): 77 | self.__retrive_last_boot_playback() 78 | self.__timer.start() 79 | self.__rds_updater.run() 80 | self.__update_playback_position() 81 | 82 | for song in self.__playlist: 83 | if song is None: 84 | return 85 | print("storage_player playing:", song["path"]) 86 | self.play(song) # blocking 87 | if self.__terminating: 88 | return 89 | 90 | def play_on_demand(self, song): 91 | self.enqueue(song) 92 | self.next() 93 | 94 | def enqueue(self, song): 95 | self.__playlist.add(song) 96 | self.__playlist.set_noshuffle() 97 | 98 | def play(self, device): 99 | self._tmp_stream = None 100 | 101 | # open input device 102 | try: 103 | input_container = av.open("bluealsa:HCI=hci0,DEV=48:2C:A0:32:A0:C1", format="alsa") 104 | 105 | audio_stream = None 106 | for i, stream in enumerate(input_container.streams): 107 | if stream.type == 'audio': 108 | audio_stream = stream 109 | break 110 | 111 | if not audio_stream: 112 | print("audio stream not found") 113 | return 114 | 115 | except av.AVError: 116 | print("Can't open input stream for device", device) 117 | return 118 | 119 | # open output stream 120 | self.__out = MpradioIO() 121 | self.output_stream = self.__out # link for external access 122 | out_container = av.open(self.__out, 'w', 'wav') 123 | out_stream = out_container.add_stream(codec_name='pcm_s16le', rate=44100) 124 | 125 | # transcode input to wav 126 | for i, packet in enumerate(input_container.demux(audio_stream)): 127 | try: 128 | for frame in packet.decode(): 129 | frame.pts = None 130 | out_pack = out_stream.encode(frame) 131 | if out_pack: 132 | out_container.mux(out_pack) 133 | else: 134 | print("out_pack is None") 135 | except av.AVError: 136 | print("Error during playback for:", song_path) 137 | return 138 | 139 | # stop transcoding if we receive termination signal 140 | if self.__terminating: 141 | print("termination signal received") 142 | break 143 | 144 | if i == 10: 145 | self.ready.set() 146 | 147 | # avoid CPU saturation on single-core systems 148 | if psutil.cpu_percent() > 95: 149 | time.sleep(0.01) 150 | 151 | # transcoding terminated. Flush output stream 152 | try: 153 | while True: 154 | out_pack = out_stream.encode(None) 155 | if out_pack: 156 | out_container.mux(out_pack) 157 | else: 158 | break 159 | except ValueError: 160 | print("skipping flush...") 161 | return 162 | 163 | # close output container and tell the buffer no more data is coming 164 | out_container.close() 165 | self.__out.set_write_completed() 166 | print("transcoding finished.") 167 | 168 | # TODO: check if this and the above is really needed when playing a device 169 | # wait until playback (buffer read) terminates; catch signals meanwhile 170 | while not self.__out.is_read_completed(): 171 | if self.__skip.is_set(): 172 | self.__skip.clear() 173 | break 174 | if self.__terminating: 175 | break 176 | time.sleep(0.2) 177 | 178 | def pause(self): 179 | if self.__timer.is_paused(): 180 | return 181 | self.__timer.pause() 182 | self.silence() 183 | self.ready.clear() 184 | 185 | def resume(self): 186 | self.__timer.resume() 187 | self.ready.set() 188 | 189 | def next(self): 190 | self.__skip.set() 191 | 192 | def previous(self): 193 | self.__playlist.back(n=1) 194 | self.next() 195 | 196 | def repeat(self): 197 | self.__playlist.back() 198 | 199 | def fast_forward(self): 200 | pass 201 | 202 | def rewind(self): 203 | self.__playlist.back() 204 | self.next() 205 | 206 | def stop(self): 207 | self.__terminating = True 208 | self.silence() 209 | self.ready.clear() 210 | self.__timer.stop() 211 | self.__rds_updater.stop() 212 | 213 | def song_name(self): 214 | return self.__now_playing["title"] 215 | 216 | def song_artist(self): 217 | return self.__now_playing["artist"] 218 | 219 | def song_year(self): 220 | return self.__now_playing["year"] 221 | 222 | def song_album(self): 223 | return self.__now_playing["album"] 224 | -------------------------------------------------------------------------------- /src/storage_player.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import time 3 | from timer import Timer 4 | from player import Player 5 | from playlist import Playlist 6 | from rds import RdsUpdater 7 | import threading 8 | import json 9 | from configuration import config 10 | import av 11 | from mp_io import MpradioIO 12 | from bytearray_io import BytearrayIO 13 | 14 | 15 | class StoragePlayer(Player): 16 | 17 | __terminating = False 18 | __playlist = None 19 | __now_playing = None 20 | __timer = None 21 | __resume_file = None 22 | __rds_updater = None 23 | __skip = None 24 | __out = None 25 | __silence_track = None 26 | CHUNK = 1024 * 4 27 | 28 | def __init__(self): 29 | super().__init__() 30 | self.__playlist = Playlist() 31 | self.__rds_updater = RdsUpdater() 32 | self.__resume_file = config.get_resume_file() 33 | self.__skip = threading.Event() 34 | self.output_stream = BytearrayIO() 35 | 36 | def playback_position(self): 37 | return self.__timer.get_time() 38 | 39 | def __update_playback_position_thread(self): 40 | while not self.__terminating: 41 | if self.__now_playing is not None: 42 | self.__now_playing["position"] = self.playback_position() 43 | with open(self.__resume_file, "w") as f: 44 | j = json.dumps(self.__now_playing) 45 | f.write(j) 46 | time.sleep(5) 47 | 48 | def __update_playback_position(self): 49 | threading.Thread(target=self.__update_playback_position_thread).start() 50 | 51 | def __retrieve_last_boot_playback(self): 52 | if not path.isfile(self.__resume_file): 53 | # start the timer from 0 54 | self.__timer = Timer() 55 | return 56 | 57 | try: 58 | with open(self.__resume_file) as file: 59 | song = json.load(file) 60 | except json.decoder.JSONDecodeError: 61 | self.__timer = Timer() 62 | return 63 | 64 | if song is not None: 65 | # resume the timer from previous state 66 | try: 67 | self.__timer = Timer(song["position"]) 68 | self.enqueue(song) 69 | except TypeError: 70 | self.__timer = Timer() 71 | except KeyError: 72 | self.__timer = Timer() 73 | else: 74 | self.__timer = Timer() 75 | 76 | def run(self): 77 | threading.Thread(target=self.__run).start() 78 | 79 | def __run(self): 80 | self.__retrieve_last_boot_playback() 81 | self.__timer.start() 82 | self.__rds_updater.run() 83 | self.__update_playback_position() 84 | 85 | for song in self.__playlist: 86 | if song is None: 87 | time.sleep(1) 88 | continue 89 | print("storage_player playing:", song["path"]) 90 | self.play(song) # blocking 91 | if self.__terminating: 92 | return 93 | 94 | def play_on_demand(self, song): 95 | self.enqueue(song) 96 | self.next() 97 | 98 | def enqueue(self, song): 99 | self.__playlist.add(song) 100 | self.__playlist.set_noshuffle() 101 | 102 | def set_out_stream(self, outs): 103 | if outs is not None: 104 | self.output_stream.set_out_stream(outs) 105 | 106 | def play(self, song): 107 | # get/set/resume song timer 108 | resume_time = song.get("position") 109 | if resume_time is None: 110 | resume_time = 0 111 | self.__timer.reset() 112 | self.__timer.resume() 113 | 114 | # update song name 115 | self.__now_playing = song 116 | self.__rds_updater.set(song) 117 | song_path = r"" + song["path"].replace("\\", "") 118 | 119 | # open input file 120 | try: 121 | input_container = av.open(song_path) 122 | audio_stream = None 123 | for i, stream in enumerate(input_container.streams): 124 | if stream.type == 'audio': 125 | audio_stream = stream 126 | break 127 | if not audio_stream: 128 | return 129 | except av.AVError: 130 | print("Can't open file:", song_path, "skipping...") 131 | return 132 | 133 | # set-up output stream 134 | out_container = av.open(self.output_stream, 'w', 'wav') 135 | out_stream = out_container.add_stream(codec_name='pcm_s16le', rate=44100) 136 | 137 | # calculate initial seek 138 | try: 139 | time_unit = input_container.size/int(input_container.duration/1000000) 140 | except ZeroDivisionError: 141 | time_unit = 0 142 | seek_point = int(time_unit) * int(resume_time) 143 | 144 | buffer_ready = False 145 | 146 | # transcode input to wav 147 | for i, packet in enumerate(input_container.demux(audio_stream)): 148 | 149 | # seek to the point 150 | try: 151 | if packet.pos < seek_point: 152 | continue 153 | except TypeError: 154 | pass 155 | 156 | try: 157 | for frame in packet.decode(): 158 | frame.pts = None 159 | out_pack = out_stream.encode(frame) 160 | if out_pack: 161 | out_container.mux(out_pack) 162 | except av.AVError as err: 163 | print("Error during playback for:", song_path, err) 164 | return 165 | 166 | # stop transcoding if we receive skip or termination signal 167 | if self.__terminating or self.__skip.is_set(): 168 | break 169 | 170 | # pre-buffer some output and set the player to ready 171 | if not buffer_ready: 172 | try: 173 | if packet.pos > resume_time + time_unit * 2: 174 | self.ready.set() 175 | buffer_ready = True 176 | except TypeError: 177 | pass 178 | 179 | # transcoding terminated. Flush output stream 180 | try: 181 | while True: 182 | out_pack = out_stream.encode(None) 183 | if out_pack: 184 | out_container.mux(out_pack) 185 | else: 186 | break 187 | except ValueError: 188 | print("skipping flush...") 189 | return 190 | 191 | # close output container and tell the buffer no more data is coming 192 | del input_container 193 | out_container.close() 194 | self.output_stream.set_write_completed() 195 | print("transcoding finished.") 196 | 197 | # wait until playback (buffer read) terminates; catch signals meanwhile 198 | # while not self.output_stream.is_read_completed(): 199 | # time.sleep(0.2) 200 | # if self.__terminating: 201 | # break 202 | 203 | if self.__skip.is_set(): 204 | self.__skip.clear() 205 | 206 | def pause(self): 207 | if self.__timer.is_paused(): 208 | return 209 | self.__timer.pause() 210 | self.output_stream.silence(True) 211 | 212 | def resume(self): 213 | if not self.__timer.is_paused(): 214 | return 215 | self.output_stream.silence(False) 216 | self.__timer.resume() 217 | self.ready.set() 218 | 219 | def next(self): 220 | self.__skip.set() 221 | 222 | def previous(self): 223 | self.__playlist.back(n=1) 224 | self.next() 225 | 226 | def repeat(self): 227 | self.__playlist.back() 228 | 229 | def fast_forward(self): 230 | pass 231 | 232 | def rewind(self): 233 | self.__playlist.back() 234 | self.next() 235 | 236 | def stop(self): 237 | self.output_stream.silence(True) 238 | self.__terminating = True 239 | self.__timer.stop() 240 | self.__rds_updater.stop() 241 | time.sleep(1) 242 | self.ready.clear() 243 | self.output_stream.stop() 244 | 245 | def song_name(self): 246 | return self.__now_playing["title"] 247 | 248 | def song_artist(self): 249 | return self.__now_playing["artist"] 250 | 251 | def song_year(self): 252 | return self.__now_playing["year"] 253 | 254 | def song_album(self): 255 | return self.__now_playing["album"] 256 | -------------------------------------------------------------------------------- /src/timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | 4 | 5 | class Timer: 6 | 7 | __time = None 8 | __paused = False 9 | __termination = None 10 | 11 | def __init__(self, start_time=0): 12 | self.__termination = threading.Event() 13 | self.__time = start_time 14 | 15 | def count(self): 16 | while not self.__termination.is_set(): 17 | time.sleep(1) 18 | if not self.__paused: 19 | self.__time += 1 20 | 21 | def start(self): 22 | self.__paused = False 23 | threading.Thread(target=self.count).start() 24 | 25 | def stop(self): 26 | self.__termination.set() 27 | 28 | def pause(self): 29 | self.__paused = True 30 | 31 | def resume(self): 32 | self.__paused = False 33 | 34 | def reset(self): 35 | self.__time = 0 36 | 37 | def get_time(self): 38 | return self.__time 39 | 40 | def is_paused(self): 41 | return self.__paused 42 | --------------------------------------------------------------------------------