Compare commits

..

2 commits
main ... pydub

Author SHA1 Message Date
Patrick Moessler
d816eb9fca add playing 2023-05-15 01:28:55 +02:00
Asaril
cde05a556b use pydub 2023-05-15 00:46:38 +02:00
6 changed files with 106 additions and 613 deletions

View file

@ -1,75 +0,0 @@
# Sleepywaves
A RFID powered musick/audiobook player with configurable tracklists and playing modes.
Place a tag onto the reader to start playing. Remove it to stop, or configure a timeout to stop automatically.
## Prerequisites / Recommended Setup
- Raspberry Pi (any with audio and SPI. 3A+ works fine)
- MFRC522 RFID reader module
- storage medium, e.g. USB stick. Works with SD if nothing else is available.
- dietpi with the following packages installed:
- mpd - audio player
- python3
- pip3 install poetry
- (mympd) - optional web control UI
- (samba server) - optional file share server to easily copy new tracks
- enable SPI in dietpi-config
## Installation
Sleepywaves uses `poetry` to prepare the python environment. Install it with:
- Clone/download the repository
- Run `poetry install` to create a venv with the required python packages.
- See `sleepywaves.example.service` for a reference how a systemd unit file could look like
## Running
- Run `poetry shell` or use the python path from the poetry venv directly in the command below.
- Run `python sleepywaves.py -m <MEDIA DIR>`
Alternatively, start with systemd: `systemctl enable sleepywaves.service` and `systemctl start sleepywaves.service`.
## Tag / title configuration
A new config file is created in the media directory once a new tag is scanned.
It will be named `changeme_<tag id>.json`.
Remove the `changeme_` prefix, and configure the file as you wish.
Tracks are added to the `tracks` list in the file, and the start time in a track can be configured by
adding a `"<name>":<seconds>` pair to `start_times`.
Setting a timeout other than `0` will enable the sleep timer.
The player will stop after the configured time in seconds, even if the tag is still present on the reader.
These modes are supported:
| Play mode | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| sequence | Start with the first track (at custom start time if configured), advance through the list one-by-one |
| random | Select a random track from `tracks` (at custom start time if configured) each time the tag is put on the reader, or after one track finishes playing |
| random_sequence | Select a random track from `tracks` (at custom start time if configured) when the tag is put on the reader. After the track finishes, advance to the next in the `tracks` list |
| random_start_sequence | Select a random starting track and time from the list of `start_times`. Advance to the next track in `tracks` after it finishes playing |
Setting `resume_track` to true will save the current playing position if the tag is removed, or the timer is reached.
Playback will continue at this point when the tag is put on the reader again.
Note: if a custom `start_times` entry is present for a track, all playback will start at this time (i.e. even if the
track is next-in-line for sequence play mode).
Note: the `random_start_sequence` mode is most useful for using audiobooks with chapters broken into shorter tracks and
the sleep timer feature. If the book e.g. has tracks of ~4 minutes, but the timeout is set to 8 minutes, it will start
at one of the configured start tracks and continue playing across the track breaks, but never start at the intermediate
tracks.
### Adding tracks to a title easily
Adding tracks to the renamed file can be automated with the add_tracks.py tool.
Run it as `python add_tracks.py -m <media dir> -t <tag id> -p <file name pattern>`
A pattern of `*Hobbit*.mp3` will find `<media dir>/Hobbit/chapter1.mp3` and similar.
Using the `-o` flag will overwrite the track list in the title config file (*USE WITH CAUTION*). If not given, tracks will be appended to the existing tracklist.

293
poetry.lock generated
View file

@ -1,129 +1,5 @@
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "black"
version = "23.3.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"},
{file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"},
{file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"},
{file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"},
{file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"},
{file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"},
{file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"},
{file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"},
{file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"},
{file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"},
{file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"},
{file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"},
{file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"},
{file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"},
{file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"},
{file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"},
{file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"},
{file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"},
{file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"},
{file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"},
{file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"},
{file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"},
{file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"},
{file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"},
{file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "flake8"
version = "6.0.0"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.8.1"
files = [
{file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"},
{file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"},
]
[package.dependencies]
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.10.0,<2.11.0"
pyflakes = ">=3.0.0,<3.1.0"
[[package]]
name = "flake8-pyproject"
version = "1.2.3"
description = "Flake8 plug-in loading the configuration from pyproject.toml"
category = "dev"
optional = false
python-versions = ">= 3.6"
files = [
{file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"},
]
[package.dependencies]
Flake8 = ">=5"
TOMLi = {version = "*", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["pyTest", "pyTest-cov"]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "mfrc522"
version = "0.0.7"
@ -141,117 +17,6 @@ files = [
"RPi.GPIO" = "*"
spidev = "*"
[[package]]
name = "mypy"
version = "1.3.0"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "mypy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eb485cea53f4f5284e5baf92902cd0088b24984f4209e25981cc359d64448d"},
{file = "mypy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c99c3ecf223cf2952638da9cd82793d8f3c0c5fa8b6ae2b2d9ed1e1ff51ba85"},
{file = "mypy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:550a8b3a19bb6589679a7c3c31f64312e7ff482a816c96e0cecec9ad3a7564dd"},
{file = "mypy-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cbc07246253b9e3d7d74c9ff948cd0fd7a71afcc2b77c7f0a59c26e9395cb152"},
{file = "mypy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a22435632710a4fcf8acf86cbd0d69f68ac389a3892cb23fbad176d1cddaf228"},
{file = "mypy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e33bb8b2613614a33dff70565f4c803f889ebd2f859466e42b46e1df76018dd"},
{file = "mypy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d23370d2a6b7a71dc65d1266f9a34e4cde9e8e21511322415db4b26f46f6b8c"},
{file = "mypy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:658fe7b674769a0770d4b26cb4d6f005e88a442fe82446f020be8e5f5efb2fae"},
{file = "mypy-1.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d29e324cdda61daaec2336c42512e59c7c375340bd202efa1fe0f7b8f8ca"},
{file = "mypy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d0b6c62206e04061e27009481cb0ec966f7d6172b5b936f3ead3d74f29fe3dcf"},
{file = "mypy-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:76ec771e2342f1b558c36d49900dfe81d140361dd0d2df6cd71b3db1be155409"},
{file = "mypy-1.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc95f8386314272bbc817026f8ce8f4f0d2ef7ae44f947c4664efac9adec929"},
{file = "mypy-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:faff86aa10c1aa4a10e1a301de160f3d8fc8703b88c7e98de46b531ff1276a9a"},
{file = "mypy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8c5979d0deb27e0f4479bee18ea0f83732a893e81b78e62e2dda3e7e518c92ee"},
{file = "mypy-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c5d2cc54175bab47011b09688b418db71403aefad07cbcd62d44010543fc143f"},
{file = "mypy-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:87df44954c31d86df96c8bd6e80dfcd773473e877ac6176a8e29898bfb3501cb"},
{file = "mypy-1.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:473117e310febe632ddf10e745a355714e771ffe534f06db40702775056614c4"},
{file = "mypy-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:74bc9b6e0e79808bf8678d7678b2ae3736ea72d56eede3820bd3849823e7f305"},
{file = "mypy-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:44797d031a41516fcf5cbfa652265bb994e53e51994c1bd649ffcd0c3a7eccbf"},
{file = "mypy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ddae0f39ca146972ff6bb4399f3b2943884a774b8771ea0a8f50e971f5ea5ba8"},
{file = "mypy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c4c42c60a8103ead4c1c060ac3cdd3ff01e18fddce6f1016e08939647a0e703"},
{file = "mypy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e86c2c6852f62f8f2b24cb7a613ebe8e0c7dc1402c61d36a609174f63e0ff017"},
{file = "mypy-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f9dca1e257d4cc129517779226753dbefb4f2266c4eaad610fc15c6a7e14283e"},
{file = "mypy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:95d8d31a7713510685b05fbb18d6ac287a56c8f6554d88c19e73f724a445448a"},
{file = "mypy-1.3.0-py3-none-any.whl", hash = "sha256:a8763e72d5d9574d45ce5881962bc8e9046bf7b375b0abf031f3e6811732a897"},
{file = "mypy-1.3.0.tar.gz", hash = "sha256:e1f4d16e296f5135624b34e8fb741eb0eadedca90862405b1f1fde2040b9bd11"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
python2 = ["typed-ast (>=1.4.0,<2)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
category = "dev"
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.1"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
{file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
]
[[package]]
name = "pathspec"
version = "0.11.1"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
{file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
]
[[package]]
name = "platformdirs"
version = "3.5.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"},
{file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"},
]
[package.extras]
docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]]
name = "pycodestyle"
version = "2.10.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"},
{file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"},
]
[[package]]
name = "pydantic"
version = "1.10.7"
@ -306,32 +71,17 @@ dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyflakes"
version = "3.0.1"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"},
{file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"},
]
[[package]]
name = "python-mpd2"
version = "3.1.0"
description = "A Python MPD client library"
name = "pydub"
version = "0.25.1"
description = "Manipulate audio with an simple and easy high level interface"
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = "*"
files = [
{file = "python-mpd2-3.1.0.tar.gz", hash = "sha256:f33c2cdb0d6baa74a36724f38c1c4a099a7ce2c8ec4a2bb7192150a5855df476"},
{file = "python_mpd2-3.1.0-py2.py3-none-any.whl", hash = "sha256:c4d44a54e88a675f7301fdb11a1bd31165a6f51a664dd41e8137e92f7b02ebfb"},
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
]
[package.extras]
twisted = ["Twisted"]
[[package]]
name = "rpi-gpio"
version = "0.7.1"
@ -348,6 +98,23 @@ files = [
{file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"},
]
[[package]]
name = "simpleaudio"
version = "1.0.4"
description = "Simple, asynchronous audio playback for Python 3."
category = "main"
optional = false
python-versions = "*"
files = [
{file = "simpleaudio-1.0.4-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:05b63da515f5fc7c6f40e4d9673d22239c5e03e2bda200fc09fd21c185d73713"},
{file = "simpleaudio-1.0.4-cp37-cp37m-win32.whl", hash = "sha256:f1a4fe3358429b2ea3181fd782e4c4fff5c123ca86ec7fc29e01ee9acd8a227a"},
{file = "simpleaudio-1.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:86f1b0985629852afe67259ac6c24905ca731cb202a6e96b818865c56ced0c27"},
{file = "simpleaudio-1.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f68820297ad51577e3a77369e7e9b23989d30d5ae923bf34c92cf983c04ade04"},
{file = "simpleaudio-1.0.4-cp38-cp38-win32.whl", hash = "sha256:67348e3d3ccbae73bd126beed7f1e242976889620dbc6974c36800cd286430fc"},
{file = "simpleaudio-1.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:f346a4eac9cdbb1b3f3d0995095b7e86c12219964c022f4d920c22f6ca05fb4c"},
{file = "simpleaudio-1.0.4.tar.gz", hash = "sha256:691c88649243544db717e7edf6a9831df112104e1aefb5f6038a5d071e8cf41d"},
]
[[package]]
name = "spidev"
version = "3.6"
@ -360,18 +127,6 @@ files = [
{file = "spidev-3.6.tar.gz", hash = "sha256:14dbc37594a4aaef85403ab617985d3c3ef464d62bc9b769ef552db53701115b"},
]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typing-extensions"
version = "4.5.0"
@ -387,4 +142,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "bbed92f250e97d898bbff9948aa49a22308abedc4f9f5dd165c94e9af1972bae"
content-hash = "904a2bc758faf9140c7d8250804d72b41216d7c88c351fb07c0bd6da3cfcb730"

View file

@ -7,23 +7,13 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.9"
spidev = { version = "^3.6", "platform" = "linux" }
mfrc522 = { version = "^0.0.7", platform = "linux" }
spidev = "^3.6"
mfrc522 = "^0.0.7"
pydantic = "^1.10.7"
python-mpd2 = "^3.1.0"
simpleaudio = "^1.0.4"
pydub = "^0.25.1"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
mypy = "^1.3.0"
flake8 = "^6.0.0"
flake8-pyproject = "^1.2.3"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.flake8]
max-line-length = 88
select = ['C', 'E', 'F', 'W', 'B', 'B950']
extend-ignore = ['E203', 'E501', 'W503']

View file

@ -1,13 +0,0 @@
[Unit]
Description=Sleepywaves
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=1
User=dietpi
ExecStart=/home/dietpi/.cache/pypoetry/virtualenvs/sleepywaves-py3.9/bin/python /home/dietpi/sleepywaves/src/sleepywaves.py -m /mnt/usbstick
[Install]
WantedBy=multi-user.target

View file

@ -1,121 +0,0 @@
"""add_tracks.py
Easily add tracks to a given tag config by scanning the directory tree with a pattern.
"""
import json
import fnmatch
import logging
import os
import os.path
from typing import Any, Dict, List, Optional
log = logging.getLogger(__name__ if __name__ != "__main__" else "add_tracks")
log_levels = (
"CRITICAL",
"ERROR",
"WARNING",
"INFO",
"DEBUG",
)
def test_dir(files: List[str], pattern: str) -> List[str]:
log.debug("trying:\n " + "\n ".join(files))
valid_files = fnmatch.filter(files, pattern)
if valid_files:
log.info("found:\n " + "\n ".join(valid_files))
return valid_files
def main(
media_path: str,
tag: str,
pattern: str,
recursive: bool = False,
overwrite: bool = False,
) -> None:
abs_media_path = os.path.abspath(media_path)
log.info(
f"Scanning {abs_media_path}{' recursively' if recursive else ''} for {pattern}"
)
valid_paths: List[str] = []
if not recursive:
valid_paths = test_dir(
files=os.listdir(os.path.abspath(media_path)), pattern=pattern
)
else:
for root, _, files in os.walk(os.path.abspath(media_path)):
valid_paths.extend(
test_dir(
files=[
os.path.relpath(os.path.join(root, f), abs_media_path)
for f in files
],
pattern=pattern,
)
)
config: Optional[Dict[str, Any]] = None
config_path = os.path.join(abs_media_path, f"{tag}.json")
with open(config_path, "rb") as jf:
config = json.load(jf)
if config is None:
raise FileNotFoundError(f"Config file could not be loaded for tag id: {tag}")
tracks: List[str] = config.get("tracks", []) if not overwrite else []
tracks.extend(sorted((f.replace("\\", "/") for f in valid_paths)))
config["tracks"] = tracks
with open(config_path, "w") as jf:
json.dump(config, jf, indent=2)
if __name__ == "__main__":
from argparse import ArgumentParser
parser = ArgumentParser(description=__doc__)
parser.add_argument(
"-m",
"--media_path",
required=True,
help="path to the media directory (e.g., an USB stick)",
)
parser.add_argument(
"--log",
choices=list(log_levels) + [lv.lower() for lv in log_levels],
default="WARNING",
help="log level",
)
parser.add_argument("-t", "--tag", required=True, help="Tag ID to add tracks to.")
parser.add_argument("-p", "--pattern", required=True, help="Pattern to use")
parser.add_argument(
"-r",
"--recursive",
action="store_true",
help="Search recursively. Pattern applies to whole path (relative to media_path).",
)
parser.add_argument(
"-o",
"--overwrite",
action="store_true",
help="Overwrite and replace existing track list.",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
log.setLevel(args.log.upper())
main(
media_path=args.media_path,
tag=args.tag,
pattern=args.pattern,
recursive=args.recursive,
overwrite=args.overwrite,
)

View file

@ -2,19 +2,21 @@
RFID/NFC-based sleep time audio player. """
from typing import Literal, Optional, Dict, List
from typing import Literal, Optional, cast
from abc import ABC, abstractmethod
import logging
import os
import os.path
import time
import simpleaudio as au
from dataclasses import dataclass
from mfrc522.SimpleMFRC522 import SimpleMFRC522
from pydantic import BaseModel
from random import choice, randrange
from mpd import MPDClient
from random import randrange
from pydub import AudioSegment
from pydub.playback import play
import RPi.GPIO as GPIO
@ -27,25 +29,22 @@ log_levels = (
"DEBUG",
)
PlayMode = Literal["random", "sequence", "random_sequence", "random_start_sequence"]
PlayMode = Literal["random", "sequence"]
CONFIG_FILE_NAME = "config.json"
NEW_CONFIG_FILE_PREFIX = "changeme_"
STATUS_FILE_SUFFIX = "_status"
NEW_CONFIG_FILE_NAME = "changeme_" + CONFIG_FILE_NAME
STATUS_FILE_NAME = "status.json"
class Config(BaseModel):
default_mode: PlayMode = "sequence"
default_timeout: int = 0
default_timeout: int = 8 * 60
class TitleConfig(BaseModel):
name: Optional[str] = None
mode: Optional[PlayMode] = None
timeout: Optional[int] = None
resume_track: Optional[bool] = True
tracks: Optional[List[str]] = None
start_times: Optional[Dict[str, int]] = None
class TitleStatus(BaseModel):
@ -58,9 +57,14 @@ class Title:
tag_id: int
config: TitleConfig
status: TitleStatus
tracks: list[str]
class Renderer(ABC):
@abstractmethod
def get_tracks(self, path: str) -> list[str]:
pass
@abstractmethod
def play(self, path: str, from_time: int) -> None:
pass
@ -77,10 +81,6 @@ class Renderer(ABC):
def is_playing(self) -> bool:
pass
@abstractmethod
def close(self) -> None:
pass
class Player:
reader: SimpleMFRC522
@ -110,17 +110,19 @@ class Player:
log.debug(f"loading global config from: {cfg_path}")
self.config = Config.parse_file(cfg_path)
def get_title_file(self, tag_id: int, prefix: str = "", suffix: str = "") -> str:
return os.path.join(self.media_path, f"{prefix}{tag_id:010x}{suffix}.json")
def get_title_path(self, tag_id: int) -> str:
return os.path.join(self.media_path, f"{tag_id:10x}")
def get_title_config(self, tag_id: int) -> Optional[TitleConfig]:
cfg_path = self.get_title_file(tag_id=tag_id)
title_path = self.get_title_path(tag_id)
cfg_path = os.path.join(title_path, CONFIG_FILE_NAME)
if os.path.exists(cfg_path):
return TitleConfig.parse_file(cfg_path)
return None
def get_title_status(self, tag_id: int) -> TitleStatus:
status_path = self.get_title_file(tag_id=tag_id, suffix=STATUS_FILE_SUFFIX)
title_path = self.get_title_path(tag_id)
status_path = os.path.join(title_path, STATUS_FILE_NAME)
if os.path.exists(status_path):
log.debug(f"loading title status from {status_path}")
return TitleStatus.parse_file(status_path)
@ -128,17 +130,13 @@ class Player:
return TitleStatus(last_track="", last_time=0)
def create_title(self, tag_id: int) -> None:
cfg_path = self.get_title_file(tag_id=tag_id)
new_cfg_path = self.get_title_file(tag_id=tag_id, prefix=NEW_CONFIG_FILE_PREFIX)
if not os.path.exists(cfg_path) and not os.path.exists(new_cfg_path):
log.info(f"new title created: {new_cfg_path}")
title_path = self.get_title_path(tag_id)
if not os.path.exists(title_path):
log.info(f"new title created: {title_path}")
os.makedirs(title_path)
new_cfg_path = os.path.join(title_path, NEW_CONFIG_FILE_NAME)
new_config = TitleConfig(
name="",
mode=self.config.default_mode,
timeout=self.config.default_timeout,
tracks=[],
start_times={},
resume_track=True,
mode=self.config.default_mode, timeout=self.config.default_timeout
)
with open(new_cfg_path, "w") as cfg_file:
cfg_file.write(new_config.json(indent=2))
@ -146,58 +144,31 @@ class Player:
def read_burst(self) -> Optional[int]:
tag_id: Optional[int] = None
for _ in range(10):
tag_id = self.reader.read_id_no_block()
tag_id = self.reader.read_id_no_block() # type:ignore
if tag_id is not None:
return tag_id
return None
def advance(self, first: bool = False) -> None:
def advance(self) -> None:
assert self.current_title is not None
assert self.current_title.config.tracks is not None
track_id = (
self.current_title.config.tracks.index(self.current_title.status.last_track)
self.current_title.tracks.index(self.current_title.status.last_track)
if self.current_title.status.last_track
else -1
)
log.debug(f"advance from #{track_id}: {self.current_title.status.last_track}")
if self.current_title.config.mode == "sequence":
track_id = (track_id + 1) % len(self.current_title.config.tracks)
track_id = (track_id + 1) % len(self.current_title.tracks)
elif self.current_title.config.mode == "random":
track_id = randrange(0, len(self.current_title.config.tracks), 1)
elif self.current_title.config.mode == "random_sequence":
if first:
track_id = randrange(0, len(self.current_title.config.tracks), 1)
else:
track_id = (track_id + 1) % len(self.current_title.config.tracks)
elif self.current_title.config.mode == "random_start_sequence":
if first:
if not self.current_title.config.start_times:
track_id = 0
else:
start_name = choice(
list(self.current_title.config.start_times.keys())
)
track_id = self.current_title.config.tracks.index(start_name)
else:
track_id = (track_id + 1) % len(self.current_title.config.tracks)
self.current_title.status.last_track = self.current_title.config.tracks[
track_id
]
track_id = randrange(0, len(self.current_title.tracks), 1)
self.current_title.status.last_track = self.current_title.tracks[track_id]
self.current_title.status.last_time = 0
if (
self.current_title.config.start_times is not None
and self.current_title.status.last_track
in self.current_title.config.start_times
):
self.current_title.status.last_time = self.current_title.config.start_times[
self.current_title.status.last_track
]
log.debug(f"advance to #{track_id}: {self.current_title.status.last_track}")
def start_playing(self) -> None:
if self.current_title is not None:
track_path = os.path.join(
self.media_path,
self.get_title_path(self.current_title.tag_id),
self.current_title.status.last_track,
)
log.debug(
@ -218,16 +189,17 @@ class Player:
)
self.renderer.stop()
status_path = self.get_title_file(
tag_id=self.current_title.tag_id, suffix=STATUS_FILE_SUFFIX
status_path = os.path.join(
self.get_title_path(self.current_title.tag_id), STATUS_FILE_NAME
)
with open(status_path, "w") as status_file:
status_file.write(self.current_title.status.json(indent=2))
self.current_title = None
def process(self) -> None:
def process(self):
tag_id: Optional[int] = self.read_burst()
# current_tag = self.current_title.tag_id if self.current_title else None
if tag_id != self.old_tag:
log.debug(f"self.old_tag: {self.old_tag or 0:10x} tag_id:{tag_id or 0:10x}")
if self.old_tag is not None:
@ -249,7 +221,7 @@ class Player:
self.advance()
self.start_playing()
def tag_detected(self, tag_id: int) -> None:
def tag_detected(self, tag_id: int):
log.info(f"tag detected: {tag_id:x}")
cfg = self.get_title_config(tag_id)
if not cfg:
@ -259,12 +231,13 @@ class Player:
tag_id=tag_id,
config=cfg,
status=self.get_title_status(tag_id),
tracks=self.renderer.get_tracks(self.get_title_path(tag_id)),
)
if (
self.current_title.status.last_track == ""
or self.current_title.config.resume_track is False
):
self.advance(first=True)
self.advance()
self.start_playing()
if (
self.current_title.config.timeout is not None
@ -272,14 +245,11 @@ class Player:
):
self.start_time = time.time()
def tag_removed(self) -> None:
def tag_removed(self):
if self.current_title is not None:
log.info(f"tag removed: {self.current_title.tag_id:x}")
self.stop_playing()
def close(self) -> None:
self.renderer.close()
class DebugRenderer(Renderer):
def __init__(self) -> None:
@ -307,69 +277,59 @@ class DebugRenderer(Renderer):
def is_playing(self) -> bool:
return (self.start != 0) and (self.get_time() - self.offset < 10)
def close(self) -> None:
log.debug("DebugRenderer: close()")
class MpdRenderer(Renderer):
class PydubRenderer(Renderer):
def __init__(self) -> None:
super().__init__()
self.start: int = 0
self.audio_in: Optional[AudioSegment] = None
self.audio_out: Optional[au.PlayObject] = None
self.mpd: MPDClient = MPDClient()
self.mpd.timeout = 10
self.mpd.connect("/var/run/mpd/socket")
self.mpd.clear()
self.mpd.single(1)
self.mpd.consume(1)
# self.start: int = 0
# self.offset: int = 0
def get_tracks(self, path: str) -> list[str]:
all_files = os.listdir(path)
return sorted([f for f in all_files if f.endswith(".mp3")])
def play(self, path: str, from_time: int) -> None:
log.info(f"MpdRenderer: play({path}, {from_time})")
self.mpd.clear()
self.mpd.add(f"file://{path}")
self.mpd.play()
if from_time != 0:
self.mpd.seekcur(from_time)
# self.start = int(time.time())
# self.offset = from_time
log.info(f"PydubRenderer: play({path}, {from_time})")
self.audio_in = cast(AudioSegment, AudioSegment.from_mp3(path))
self.audio_out = au.play_buffer(
audio_data=self.audio_in[from_time*1000:],
num_channels=self.audio_in.channels,
bytes_per_sample=self.audio_in.sample_width,
sample_rate = self.audio_in.frame_rate
)
self.start = int(time.time())
self.offset = from_time
self.audio_out.play()
def get_time(self) -> int:
# return int(time.time()) - self.start + self.offset
return int(float(self.mpd.status()["elapsed"]))
return int(time.time()) - self.start + self.offset
def stop(self) -> None:
log.info("MpdRenderer: stop()")
self.mpd.stop()
self.mpd.clear()
# self.start = 0
# self.offset = 0
log.info("PydubRenderer: stop()")
if self.is_playing():
self.audio_out.stop()
self.audio_out = None
self.audio_in = None
self.start = 0
self.offset = 0
def is_playing(self) -> bool:
return self.mpd.status()["state"] == "play"
# return (self.start != 0) and (self.get_time() - self.offset < 10)
def close(self) -> None:
self.mpd.close()
self.mpd.disconnect()
return self.audio_out is not None and self.audio_out.is_playing()
def main(media_path: str, renderer_type: str) -> None:
if renderer_type == "dummy":
renderer = DebugRenderer()
elif renderer_type == "mpd":
renderer = MpdRenderer()
elif renderer_type == "pydub":
renderer = PydubRenderer()
else:
renderer = DebugRenderer()
player = Player(media_path=media_path, renderer=renderer)
log.info("sleepywaves ready.")
try:
while True:
player.process()
time.sleep(1)
finally:
player.close()
while True:
player.process()
time.sleep(1)
if __name__ == "__main__":
@ -383,22 +343,19 @@ if __name__ == "__main__":
help="path to the media directory (e.g., an USB stick)",
)
parser.add_argument(
"--log",
choices=list(log_levels) + [lv.lower() for lv in log_levels],
default="WARNING",
help="log level",
"--log", choices=log_levels, default="WARNING", help="log level"
)
parser.add_argument(
"--renderer",
choices=("dummy", "mpd"),
default="mpd",
choices=("dummy", "pydub"),
default="pydub",
help="media renderer to use as backend",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
log.setLevel(args.log.upper())
log.setLevel(args.log)
try:
main(media_path=args.media_path, renderer_type=args.renderer)