├── .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 | 
130 |
131 | And classes:
132 |
133 | 
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 |
--------------------------------------------------------------------------------