mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Merge remote-tracking branch 'librespot-org/dev' into new-api-wip
This commit is contained in:
commit
f03a7e95c1
48 changed files with 2297 additions and 1287 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
@ -6,15 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- [main] Enforce reasonable ranges for option values (breaking).
|
||||||
|
- [main] Don't evaluate options that would otherwise have no effect.
|
||||||
|
- [playback] `alsa`: Improve `--device ?` functionality for the alsa backend.
|
||||||
|
- [contrib] Hardened security of the systemd service units
|
||||||
|
- [main] Verbose logging mode (`-v`, `--verbose`) now logs all parsed environment variables and command line arguments (credentials are redacted).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- [cache] Add `disable-credential-cache` flag (breaking).
|
||||||
|
- [main] Use different option descriptions and error messages based on what backends are enabled at build time.
|
||||||
|
- [main] Add a `-q`, `--quiet` option that changes the logging level to warn.
|
||||||
|
- [main] Add a short name for every flag and option.
|
||||||
|
- [main] Add the ability to parse environment variables.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given.
|
||||||
|
- [main] Don't panic when parsing options. Instead list valid values and exit.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
|
||||||
|
- [playback] `alsamixer`: previously deprecated option `mixer-name` has been removed.
|
||||||
|
- [playback] `alsamixer`: previously deprecated option `mixer-index` has been removed.
|
||||||
|
|
||||||
|
## [0.3.1] - 2021-10-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Include build profile in the displayed version information
|
||||||
|
- [playback] Improve dithering CPU usage by about 33%
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- [connect] Partly fix behavior after last track of an album/playlist
|
||||||
|
|
||||||
|
## [0.3.0] - 2021-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`.
|
- [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`.
|
||||||
- [playback] Add support for dithering with `--dither` for lower requantization error (breaking)
|
- [playback] Add support for dithering with `--dither` for lower requantization error (breaking)
|
||||||
- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves
|
- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves
|
||||||
- [playback] `alsamixer`: support for querying dB range from Alsa softvol
|
- [playback] `alsamixer`: support for querying dB range from Alsa softvol
|
||||||
- [playback] Add `--format F64` (supported by Alsa and GStreamer only)
|
- [playback] Add `--format F64` (supported by Alsa and GStreamer only)
|
||||||
|
- [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking)
|
- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `DecoderError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking)
|
||||||
- [audio, playback] Use `Duration` for time constants and functions (breaking)
|
- [audio, playback] Use `Duration` for time constants and functions (breaking)
|
||||||
- [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate
|
- [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate
|
||||||
- [connect] Synchronize player volume with mixer volume on playback
|
- [connect] Synchronize player volume with mixer volume on playback
|
||||||
|
@ -22,20 +58,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic`
|
- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic`
|
||||||
- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)
|
- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)
|
||||||
- [playback] `alsamixer`: complete rewrite (breaking)
|
- [playback] `alsamixer`: complete rewrite (breaking)
|
||||||
- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise
|
- [playback] `alsamixer`: query card dB range for the volume control unless specified otherwise
|
||||||
- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise
|
- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise
|
||||||
- [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking)
|
- [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking)
|
||||||
|
- [playback] `player`: make `convert` and `decoder` public so you can implement your own `Sink`
|
||||||
|
- [playback] `player`: update default normalisation threshold to -2 dBFS
|
||||||
|
- [playback] `player`: default normalisation type is now `auto`
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
- [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate
|
- [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate
|
||||||
|
- [playback] `alsamixer`: renamed `mixer-card` to `alsa-mixer-device`
|
||||||
|
- [playback] `alsamixer`: renamed `mixer-name` to `alsa-mixer-control`
|
||||||
|
- [playback] `alsamixer`: renamed `mixer-index` to `alsa-mixer-index`
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- [connect] Removed no-op mixer started/stopped logic (breaking)
|
- [connect] Removed no-op mixer started/stopped logic (breaking)
|
||||||
- [playback] Removed `with-vorbis` and `with-tremor` features
|
- [playback] Removed `with-vorbis` and `with-tremor` features
|
||||||
- [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead
|
- [playback] `alsamixer`: removed `--mixer-linear-volume` option, now that `--volume-ctrl {linear|log}` work as expected on Alsa
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- [connect] Fix step size on volume up/down events
|
- [connect] Fix step size on volume up/down events
|
||||||
|
- [connect] Fix looping back to the first track after the last track of an album or playlist
|
||||||
- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream
|
- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream
|
||||||
- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume
|
- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume
|
||||||
- [playback] Fix `S24_3` format on big-endian systems
|
- [playback] Fix `S24_3` format on big-endian systems
|
||||||
|
@ -43,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected
|
- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected
|
||||||
- [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness
|
- [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness
|
||||||
- [playback] `alsa`: revert buffer size to ~500 ms
|
- [playback] `alsa`: revert buffer size to ~500 ms
|
||||||
- [playback] `alsa`, `pipe`: better error handling
|
- [playback] `alsa`, `pipe`, `pulseaudio`: better error handling
|
||||||
|
- [metadata] Skip tracks whose Spotify ID's can't be found (e.g. local files, which aren't supported)
|
||||||
|
|
||||||
## [0.2.0] - 2021-05-04
|
## [0.2.0] - 2021-05-04
|
||||||
|
|
||||||
|
@ -59,7 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [0.1.0] - 2019-11-06
|
## [0.1.0] - 2019-11-06
|
||||||
|
|
||||||
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.2.0..HEAD
|
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.3.1..HEAD
|
||||||
|
[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1
|
||||||
|
[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0..v0.3.0
|
||||||
[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0
|
[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0
|
||||||
[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5..v0.1.6
|
[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5..v0.1.6
|
||||||
[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3..v0.1.5
|
[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3..v0.1.5
|
||||||
|
|
25
COMPILING.md
25
COMPILING.md
|
@ -5,20 +5,15 @@
|
||||||
In order to compile librespot, you will first need to set up a suitable Rust build environment, with the necessary dependencies installed. You will need to have a C compiler, Rust, and the development libraries for the audio backend(s) you want installed. These instructions will walk you through setting up a simple build environment.
|
In order to compile librespot, you will first need to set up a suitable Rust build environment, with the necessary dependencies installed. You will need to have a C compiler, Rust, and the development libraries for the audio backend(s) you want installed. These instructions will walk you through setting up a simple build environment.
|
||||||
|
|
||||||
### Install Rust
|
### Install Rust
|
||||||
The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). On Unix/MacOS You can install `rustup` with this command:
|
The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). Once that’s installed, Rust's standard tools should be set up and ready to use.
|
||||||
|
|
||||||
```bash
|
|
||||||
curl https://sh.rustup.rs -sSf | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow any prompts it gives you to install Rust. Once that’s done, Rust's standard tools should be setup and ready to use.
|
|
||||||
|
|
||||||
*Note: The current minimum required Rust version at the time of writing is 1.48, you can find the current minimum version specified in the `.github/workflow/test.yml` file.*
|
*Note: The current minimum required Rust version at the time of writing is 1.48, you can find the current minimum version specified in the `.github/workflow/test.yml` file.*
|
||||||
|
|
||||||
#### Additional Rust tools - `rustfmt`
|
#### Additional Rust tools - `rustfmt`
|
||||||
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt), which is installed by default with `rustup` these days, else it can be installed manually with:
|
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt) and [`clippy`](https://github.com/rust-lang/rust-clippy), which are installed by default with `rustup` these days, else they can be installed manually with:
|
||||||
```bash
|
```bash
|
||||||
rustup component add rustfmt
|
rustup component add rustfmt
|
||||||
|
rustup component add clippy
|
||||||
```
|
```
|
||||||
Using `rustfmt` is not optional, as our CI checks against this repo's rules.
|
Using `rustfmt` is not optional, as our CI checks against this repo's rules.
|
||||||
|
|
||||||
|
@ -43,12 +38,13 @@ Depending on the chosen backend, specific development libraries are required.
|
||||||
|--------------------|------------------------------|-----------------------------------|-------------|
|
|--------------------|------------------------------|-----------------------------------|-------------|
|
||||||
|Rodio (default) | `libasound2-dev` | `alsa-lib-devel` | |
|
|Rodio (default) | `libasound2-dev` | `alsa-lib-devel` | |
|
||||||
|ALSA | `libasound2-dev, pkg-config` | `alsa-lib-devel` | |
|
|ALSA | `libasound2-dev, pkg-config` | `alsa-lib-devel` | |
|
||||||
|
|GStreamer | `gstreamer1.0-plugins-base libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good libgstreamer-plugins-good1.0-dev` | `gstreamer1 gstreamer1-devel gstreamer1-plugins-base-devel gstreamer1-plugins-good` | `gstreamer gst-devtools gst-plugins-base gst-plugins-good` |
|
||||||
|PortAudio | `portaudio19-dev` | `portaudio-devel` | `portaudio` |
|
|PortAudio | `portaudio19-dev` | `portaudio-devel` | `portaudio` |
|
||||||
|PulseAudio | `libpulse-dev` | `pulseaudio-libs-devel` | |
|
|PulseAudio | `libpulse-dev` | `pulseaudio-libs-devel` | |
|
||||||
|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | |
|
|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` |
|
||||||
|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | - |
|
|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` |
|
||||||
|SDL | `libsdl2-dev` | `SDL2-devel` | |
|
|SDL | `libsdl2-dev` | `SDL2-devel` | `sdl2` |
|
||||||
|Pipe | - | - | - |
|
|Pipe & subprocess | - | - | - |
|
||||||
|
|
||||||
###### For example, to build an ALSA based backend, you would need to run the following to install the required dependencies:
|
###### For example, to build an ALSA based backend, you would need to run the following to install the required dependencies:
|
||||||
|
|
||||||
|
@ -68,7 +64,6 @@ The recommended method is to first fork the repo, so that you have a copy that y
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:YOURUSERNAME/librespot.git
|
git clone git@github.com:YOURUSERNAME/librespot.git
|
||||||
cd librespot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compiling & Running
|
## Compiling & Running
|
||||||
|
@ -109,7 +104,9 @@ cargo build --no-default-features --features "alsa-backend"
|
||||||
Assuming you just compiled a ```debug``` build, you can run librespot with the following command:
|
Assuming you just compiled a ```debug``` build, you can run librespot with the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/debug/librespot -n Librespot
|
./target/debug/librespot
|
||||||
```
|
```
|
||||||
|
|
||||||
There are various runtime options, documented in the wiki, and visible by running librespot with the ```-h``` argument.
|
There are various runtime options, documented in the wiki, and visible by running librespot with the ```-h``` argument.
|
||||||
|
|
||||||
|
Note that debug builds may cause buffer underruns and choppy audio when dithering is enabled (which it is by default). You can disable dithering with ```--dither none```.
|
||||||
|
|
|
@ -11,6 +11,8 @@ Please also make sure that your issues are helpful. To ensure that your issue is
|
||||||
- Please take a moment to search/read previous similar issues to ensure you aren’t posting a duplicate. Duplicates will be closed immediately.
|
- Please take a moment to search/read previous similar issues to ensure you aren’t posting a duplicate. Duplicates will be closed immediately.
|
||||||
- Please include a clear description of what the issue is. Issues with descriptions such as ‘It hangs after 40 minutes’ will be closed immediately.
|
- Please include a clear description of what the issue is. Issues with descriptions such as ‘It hangs after 40 minutes’ will be closed immediately.
|
||||||
- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues.
|
- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues.
|
||||||
|
- Please be alert and respond to questions asked by any project members. Stale issues will be closed.
|
||||||
|
- When your issue concerns audio playback, please first make sure that your audio system is set up correctly and can play audio from other applications. This project aims to provide correct audio backends, not to provide Linux support to end users.
|
||||||
- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run ‘export RUST_BACKTRACE=full’ before running librespot to enable backtraces.
|
- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run ‘export RUST_BACKTRACE=full’ before running librespot to enable backtraces.
|
||||||
|
|
||||||
## Contributing Code
|
## Contributing Code
|
||||||
|
@ -29,20 +31,25 @@ In order to prepare for a PR, you will need to do a couple of things first:
|
||||||
|
|
||||||
Make any changes that you are going to make to the code, but do not commit yet.
|
Make any changes that you are going to make to the code, but do not commit yet.
|
||||||
|
|
||||||
Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like.
|
Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like. If your changes break the API such that downstream packages that depend on librespot need to update their source to still compile, you should mark your changes as `(breaking)`.
|
||||||
|
|
||||||
Make sure that the code is correctly formatted by running:
|
Make sure that the code is correctly formatted by running:
|
||||||
```bash
|
```bash
|
||||||
cargo +stable fmt --all
|
cargo fmt --all
|
||||||
```
|
```
|
||||||
|
|
||||||
This command runs the previously installed stable version of ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project:
|
This command runs ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
|
||||||
Once it has built, and you have confirmed there are no warnings or errors, you should commit your changes.
|
Once it has built, check for common code mistakes by running:
|
||||||
|
```bash
|
||||||
|
cargo clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you have confirmed there are no warnings or errors, you should commit your changes.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -a -m "My fancy fix"
|
git commit -a -m "My fancy fix"
|
||||||
|
|
566
Cargo.lock
generated
566
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
16
Cargo.toml
16
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot"
|
name = "librespot"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Librespot Org"]
|
authors = ["Librespot Org"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "An open source client library for Spotify, with support for Spotify Connect"
|
description = "An open source client library for Spotify, with support for Spotify Connect"
|
||||||
|
@ -22,31 +22,31 @@ doc = false
|
||||||
|
|
||||||
[dependencies.librespot-audio]
|
[dependencies.librespot-audio]
|
||||||
path = "audio"
|
path = "audio"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-connect]
|
[dependencies.librespot-connect]
|
||||||
path = "connect"
|
path = "connect"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "core"
|
path = "core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-discovery]
|
[dependencies.librespot-discovery]
|
||||||
path = "discovery"
|
path = "discovery"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-metadata]
|
[dependencies.librespot-metadata]
|
||||||
path = "metadata"
|
path = "metadata"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-playback]
|
[dependencies.librespot-playback]
|
||||||
path = "playback"
|
path = "playback"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-protocol]
|
[dependencies.librespot-protocol]
|
||||||
path = "protocol"
|
path = "protocol"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
|
21
README.md
21
README.md
|
@ -2,12 +2,12 @@
|
||||||
[![Gitter chat](https://badges.gitter.im/librespot-org/librespot.png)](https://gitter.im/librespot-org/spotify-connect-resources)
|
[![Gitter chat](https://badges.gitter.im/librespot-org/librespot.png)](https://gitter.im/librespot-org/spotify-connect-resources)
|
||||||
[![Crates.io](https://img.shields.io/crates/v/librespot.svg)](https://crates.io/crates/librespot)
|
[![Crates.io](https://img.shields.io/crates/v/librespot.svg)](https://crates.io/crates/librespot)
|
||||||
|
|
||||||
Current maintainer is [@awiouy](https://github.com/awiouy) folks.
|
Current maintainers are [listed on GitHub](https://github.com/orgs/librespot-org/people).
|
||||||
|
|
||||||
# librespot
|
# librespot
|
||||||
*librespot* is an open source client library for Spotify. It enables applications to use Spotify's service to control and play music via various backends, and to act as a Spotify Connect receiver. It is an alternative to the official and [now deprecated](https://pyspotify.mopidy.com/en/latest/#libspotify-s-deprecation) closed-source `libspotify`. Additionally, it will provide extra features which are not available in the official library.
|
*librespot* is an open source client library for Spotify. It enables applications to use Spotify's service to control and play music via various backends, and to act as a Spotify Connect receiver. It is an alternative to the official and [now deprecated](https://pyspotify.mopidy.com/en/latest/#libspotify-s-deprecation) closed-source `libspotify`. Additionally, it will provide extra features which are not available in the official library.
|
||||||
|
|
||||||
_Note: librespot only works with Spotify Premium. This will remain the case for the foreseeable future, as we are unlikely to work on implementing the features such as limited skips and adverts that would be required to make librespot compliant with free accounts._
|
_Note: librespot only works with Spotify Premium. This will remain the case. We will not support any features to make librespot compatible with free accounts, such as limited skips and adverts._
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
We're available on [crates.io](https://crates.io/crates/librespot) as the _librespot_ package. Simply run `cargo install librespot` to install librespot on your system. Check the wiki for more info and possible [usage options](https://github.com/librespot-org/librespot/wiki/Options).
|
We're available on [crates.io](https://crates.io/crates/librespot) as the _librespot_ package. Simply run `cargo install librespot` to install librespot on your system. Check the wiki for more info and possible [usage options](https://github.com/librespot-org/librespot/wiki/Options).
|
||||||
|
@ -20,7 +20,7 @@ As the origin by [plietar](https://github.com/plietar/) is no longer actively ma
|
||||||
# Documentation
|
# Documentation
|
||||||
Documentation is currently a work in progress, contributions are welcome!
|
Documentation is currently a work in progress, contributions are welcome!
|
||||||
|
|
||||||
There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder,
|
There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder.
|
||||||
|
|
||||||
[COMPILING.md](https://github.com/librespot-org/librespot/blob/master/COMPILING.md) contains detailed instructions on setting up a development environment, and compiling librespot. More general usage and compilation information is available on the [wiki](https://github.com/librespot-org/librespot/wiki).
|
[COMPILING.md](https://github.com/librespot-org/librespot/blob/master/COMPILING.md) contains detailed instructions on setting up a development environment, and compiling librespot. More general usage and compilation information is available on the [wiki](https://github.com/librespot-org/librespot/wiki).
|
||||||
[CONTRIBUTING.md](https://github.com/librespot-org/librespot/blob/master/CONTRIBUTING.md) also contains our contributing guidelines.
|
[CONTRIBUTING.md](https://github.com/librespot-org/librespot/blob/master/CONTRIBUTING.md) also contains our contributing guidelines.
|
||||||
|
@ -30,10 +30,10 @@ If you wish to learn more about how librespot works overall, the best way is to
|
||||||
# Issues & Discussions
|
# Issues & Discussions
|
||||||
**We have recently started using Github discussions for general questions and feature requests, as they are a more natural medium for such cases, and allow for upvoting to prioritize feature development. Check them out [here](https://github.com/librespot-org/librespot/discussions). Bugs and issues with the underlying library should still be reported as issues.**
|
**We have recently started using Github discussions for general questions and feature requests, as they are a more natural medium for such cases, and allow for upvoting to prioritize feature development. Check them out [here](https://github.com/librespot-org/librespot/discussions). Bugs and issues with the underlying library should still be reported as issues.**
|
||||||
|
|
||||||
If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, eg. the Spotify URI of the song that caused the crash.
|
If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, e.g. the Spotify URI of the song that caused the crash.
|
||||||
|
|
||||||
# Building
|
# Building
|
||||||
A quick walk through of the build process is outlined here, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md).
|
A quick walkthrough of the build process is outlined below, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md).
|
||||||
|
|
||||||
## Additional Dependencies
|
## Additional Dependencies
|
||||||
We recently switched to using [Rodio](https://github.com/tomaka/rodio) for audio playback by default, hence for macOS and Windows, you should just be able to clone and build librespot (with the command below).
|
We recently switched to using [Rodio](https://github.com/tomaka/rodio) for audio playback by default, hence for macOS and Windows, you should just be able to clone and build librespot (with the command below).
|
||||||
|
@ -49,18 +49,20 @@ On Fedora systems, the following command will install these dependencies :
|
||||||
sudo dnf install alsa-lib-devel make gcc
|
sudo dnf install alsa-lib-devel make gcc
|
||||||
```
|
```
|
||||||
|
|
||||||
librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends).
|
librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends):
|
||||||
```
|
```
|
||||||
Rodio (default)
|
Rodio (default)
|
||||||
ALSA
|
ALSA
|
||||||
|
GStreamer
|
||||||
PortAudio
|
PortAudio
|
||||||
PulseAudio
|
PulseAudio
|
||||||
JACK
|
JACK
|
||||||
JACK over Rodio
|
JACK over Rodio
|
||||||
SDL
|
SDL
|
||||||
Pipe
|
Pipe
|
||||||
|
Subprocess
|
||||||
```
|
```
|
||||||
Please check the corresponding [compiling entry](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) for backend specific dependencies.
|
Please check the corresponding [Compiling](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) entry on the wiki for backend specific dependencies.
|
||||||
|
|
||||||
Once you've installed the dependencies and cloned this repository you can build *librespot* with the default backend using Cargo.
|
Once you've installed the dependencies and cloned this repository you can build *librespot* with the default backend using Cargo.
|
||||||
```shell
|
```shell
|
||||||
|
@ -86,12 +88,12 @@ target/release/librespot -n "Librespot" -b 320 -c ./cache --enable-volume-normal
|
||||||
```
|
```
|
||||||
The above command will create a receiver named ```Librespot```, with bitrate set to 320 kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials.
|
The above command will create a receiver named ```Librespot```, with bitrate set to 320 kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials.
|
||||||
|
|
||||||
A full list of runtime options are available [here](https://github.com/librespot-org/librespot/wiki/Options)
|
A full list of runtime options is available [here](https://github.com/librespot-org/librespot/wiki/Options).
|
||||||
|
|
||||||
_Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._
|
_Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
Come and hang out on gitter if you need help or want to offer some.
|
Come and hang out on gitter if you need help or want to offer some:
|
||||||
https://gitter.im/librespot-org/spotify-connect-resources
|
https://gitter.im/librespot-org/spotify-connect-resources
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
@ -114,3 +116,4 @@ functionality.
|
||||||
- [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot.
|
- [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot.
|
||||||
- [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client.
|
- [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client.
|
||||||
- [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot.
|
- [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot.
|
||||||
|
- [Spot](https://github.com/xou816/spot) - Gtk/Rust native Spotify client for the GNOME desktop.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-audio"
|
name = "librespot-audio"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||||
description="The audio fetching and processing logic for librespot"
|
description="The audio fetching and processing logic for librespot"
|
||||||
license="MIT"
|
license="MIT"
|
||||||
|
@ -8,7 +8,7 @@ edition = "2018"
|
||||||
|
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes-ctr = "0.6"
|
aes-ctr = "0.6"
|
||||||
|
|
|
@ -18,8 +18,8 @@ pub struct AudioDecrypt<T: io::Read> {
|
||||||
impl<T: io::Read> AudioDecrypt<T> {
|
impl<T: io::Read> AudioDecrypt<T> {
|
||||||
pub fn new(key: AudioKey, reader: T) -> AudioDecrypt<T> {
|
pub fn new(key: AudioKey, reader: T) -> AudioDecrypt<T> {
|
||||||
let cipher = Aes128Ctr::new(
|
let cipher = Aes128Ctr::new(
|
||||||
&GenericArray::from_slice(&key.0),
|
GenericArray::from_slice(&key.0),
|
||||||
&GenericArray::from_slice(&AUDIO_AESIV),
|
GenericArray::from_slice(&AUDIO_AESIV),
|
||||||
);
|
);
|
||||||
AudioDecrypt { cipher, reader }
|
AudioDecrypt { cipher, reader }
|
||||||
}
|
}
|
||||||
|
|
|
@ -267,7 +267,8 @@ impl AudioFileFetch {
|
||||||
fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow {
|
fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow {
|
||||||
match data {
|
match data {
|
||||||
ReceivedData::ResponseTime(response_time) => {
|
ReceivedData::ResponseTime(response_time) => {
|
||||||
trace!("Ping time estimated as: {}ms", response_time.as_millis());
|
// chatty
|
||||||
|
// trace!("Ping time estimated as: {}ms", response_time.as_millis());
|
||||||
|
|
||||||
// prune old response times. Keep at most two so we can push a third.
|
// prune old response times. Keep at most two so we can push a third.
|
||||||
while self.network_response_times.len() >= 3 {
|
while self.network_response_times.len() >= 3 {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-connect"
|
name = "librespot-connect"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||||
description = "The discovery and Spotify Connect logic for librespot"
|
description = "The discovery and Spotify Connect logic for librespot"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -20,19 +20,19 @@ tokio-stream = "0.1.1"
|
||||||
|
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-playback]
|
[dependencies.librespot-playback]
|
||||||
path = "../playback"
|
path = "../playback"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-protocol]
|
[dependencies.librespot-protocol]
|
||||||
path = "../protocol"
|
path = "../protocol"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-discovery]
|
[dependencies.librespot-discovery]
|
||||||
path = "../discovery"
|
path = "../discovery"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
with-dns-sd = ["librespot-discovery/with-dns-sd"]
|
with-dns-sd = ["librespot-discovery/with-dns-sd"]
|
||||||
|
|
|
@ -46,6 +46,7 @@ pub struct TrackContext {
|
||||||
// pub metadata: MetadataContext,
|
// pub metadata: MetadataContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ArtistContext {
|
pub struct ArtistContext {
|
||||||
|
@ -54,6 +55,7 @@ pub struct ArtistContext {
|
||||||
image_uri: String,
|
image_uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct MetadataContext {
|
pub struct MetadataContext {
|
||||||
album_title: String,
|
album_title: String,
|
||||||
|
|
|
@ -888,8 +888,8 @@ impl SpircTask {
|
||||||
let tracks_len = self.state.get_track().len() as u32;
|
let tracks_len = self.state.get_track().len() as u32;
|
||||||
debug!(
|
debug!(
|
||||||
"At track {:?} of {:?} <{:?}> update [{}]",
|
"At track {:?} of {:?} <{:?}> update [{}]",
|
||||||
new_index,
|
new_index + 1,
|
||||||
self.state.get_track().len(),
|
tracks_len,
|
||||||
self.state.get_context_uri(),
|
self.state.get_context_uri(),
|
||||||
tracks_len - new_index < CONTEXT_FETCH_THRESHOLD
|
tracks_len - new_index < CONTEXT_FETCH_THRESHOLD
|
||||||
);
|
);
|
||||||
|
@ -903,16 +903,20 @@ impl SpircTask {
|
||||||
self.context_fut = self.resolve_station(&context_uri);
|
self.context_fut = self.resolve_station(&context_uri);
|
||||||
self.update_tracks_from_context();
|
self.update_tracks_from_context();
|
||||||
}
|
}
|
||||||
if self.config.autoplay && new_index == tracks_len - 1 {
|
if new_index >= tracks_len {
|
||||||
|
if self.config.autoplay {
|
||||||
// Extend the playlist
|
// Extend the playlist
|
||||||
// Note: This doesn't seem to reflect in the UI
|
|
||||||
// the additional tracks in the frame don't show up as with station view
|
|
||||||
debug!("Extending playlist <{}>", context_uri);
|
debug!("Extending playlist <{}>", context_uri);
|
||||||
self.update_tracks_from_context();
|
self.update_tracks_from_context();
|
||||||
}
|
self.player.set_auto_normalise_as_album(false);
|
||||||
if new_index >= tracks_len {
|
} else {
|
||||||
new_index = 0; // Loop around back to start
|
new_index = 0;
|
||||||
continue_playing = self.state.get_repeat();
|
continue_playing = self.state.get_repeat();
|
||||||
|
debug!(
|
||||||
|
"Looping around back to start, repeat is {}",
|
||||||
|
continue_playing
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tracks_len > 0 {
|
if tracks_len > 0 {
|
||||||
|
@ -1034,7 +1038,7 @@ impl SpircTask {
|
||||||
.payload
|
.payload
|
||||||
.first()
|
.first()
|
||||||
.expect("Empty payload on context uri");
|
.expect("Empty payload on context uri");
|
||||||
let response: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
let response: serde_json::Value = serde_json::from_slice(data).unwrap();
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
@ -1052,7 +1056,7 @@ impl SpircTask {
|
||||||
if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) {
|
if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) {
|
||||||
track_vec.drain(0..head);
|
track_vec.drain(0..head);
|
||||||
}
|
}
|
||||||
track_vec.extend_from_slice(&new_tracks);
|
track_vec.extend_from_slice(new_tracks);
|
||||||
self.state
|
self.state
|
||||||
.set_track(protobuf::RepeatedField::from_vec(track_vec));
|
.set_track(protobuf::RepeatedField::from_vec(track_vec));
|
||||||
|
|
||||||
|
@ -1085,6 +1089,9 @@ impl SpircTask {
|
||||||
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
|
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.player
|
||||||
|
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
|
||||||
|
|
||||||
self.state.set_playing_track_index(index);
|
self.state.set_playing_track_index(index);
|
||||||
self.state.set_track(tracks.iter().cloned().collect());
|
self.state.set_track(tracks.iter().cloned().collect());
|
||||||
self.state.set_context_uri(context_uri);
|
self.state.set_context_uri(context_uri);
|
||||||
|
@ -1125,6 +1132,14 @@ impl SpircTask {
|
||||||
fn get_track_id_to_play_from_playlist(&self, index: u32) -> Option<(SpotifyId, u32)> {
|
fn get_track_id_to_play_from_playlist(&self, index: u32) -> Option<(SpotifyId, u32)> {
|
||||||
let tracks_len = self.state.get_track().len();
|
let tracks_len = self.state.get_track().len();
|
||||||
|
|
||||||
|
// Guard against tracks_len being zero to prevent
|
||||||
|
// 'index out of bounds: the len is 0 but the index is 0'
|
||||||
|
// https://github.com/librespot-org/librespot/issues/226#issuecomment-971642037
|
||||||
|
if tracks_len == 0 {
|
||||||
|
warn!("No playable track found in state: {:?}", self.state);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let mut new_playlist_index = index as usize;
|
let mut new_playlist_index = index as usize;
|
||||||
|
|
||||||
if new_playlist_index >= tracks_len {
|
if new_playlist_index >= tracks_len {
|
||||||
|
@ -1207,7 +1222,7 @@ impl SpircTask {
|
||||||
trace!("Sending status to server: [{}]", status_string);
|
trace!("Sending status to server: [{}]", status_string);
|
||||||
let mut cs = CommandSender::new(self, MessageType::kMessageTypeNotify);
|
let mut cs = CommandSender::new(self, MessageType::kMessageTypeNotify);
|
||||||
if let Some(s) = recipient {
|
if let Some(s) = recipient {
|
||||||
cs = cs.recipient(&s);
|
cs = cs.recipient(s);
|
||||||
}
|
}
|
||||||
cs.send();
|
cs.send();
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
Description=Librespot (an open source Spotify client)
|
Description=Librespot (an open source Spotify client)
|
||||||
Documentation=https://github.com/librespot-org/librespot
|
Documentation=https://github.com/librespot-org/librespot
|
||||||
Documentation=https://github.com/librespot-org/librespot/wiki/Options
|
Documentation=https://github.com/librespot-org/librespot/wiki/Options
|
||||||
Requires=network-online.target
|
Wants=network.target sound.target
|
||||||
After=network-online.target
|
After=network.target sound.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=nobody
|
DynamicUser=yes
|
||||||
Group=audio
|
SupplementaryGroups=audio
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
ExecStart=/usr/bin/librespot --name "%p@%H"
|
ExecStart=/usr/bin/librespot --name "%p@%H"
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
Description=Librespot (an open source Spotify client)
|
Description=Librespot (an open source Spotify client)
|
||||||
Documentation=https://github.com/librespot-org/librespot
|
Documentation=https://github.com/librespot-org/librespot
|
||||||
Documentation=https://github.com/librespot-org/librespot/wiki/Options
|
Documentation=https://github.com/librespot-org/librespot/wiki/Options
|
||||||
|
Wants=network.target sound.target
|
||||||
|
After=network.target sound.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-core"
|
name = "librespot-core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
description = "The core functionality provided by librespot"
|
description = "The core functionality provided by librespot"
|
||||||
|
@ -10,7 +10,7 @@ edition = "2018"
|
||||||
|
|
||||||
[dependencies.librespot-protocol]
|
[dependencies.librespot-protocol]
|
||||||
path = "../protocol"
|
path = "../protocol"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes = "0.6"
|
aes = "0.6"
|
||||||
|
|
|
@ -42,7 +42,11 @@ impl Credentials {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_blob(username: String, encrypted_blob: &str, device_id: &str) -> Credentials {
|
pub fn with_blob(
|
||||||
|
username: impl Into<String>,
|
||||||
|
encrypted_blob: impl AsRef<[u8]>,
|
||||||
|
device_id: impl AsRef<[u8]>,
|
||||||
|
) -> Credentials {
|
||||||
fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {
|
fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {
|
||||||
let mut data = [0u8];
|
let mut data = [0u8];
|
||||||
stream.read_exact(&mut data)?;
|
stream.read_exact(&mut data)?;
|
||||||
|
@ -67,7 +71,9 @@ impl Credentials {
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret = Sha1::digest(device_id.as_bytes());
|
let username = username.into();
|
||||||
|
|
||||||
|
let secret = Sha1::digest(device_id.as_ref());
|
||||||
|
|
||||||
let key = {
|
let key = {
|
||||||
let mut key = [0u8; 24];
|
let mut key = [0u8; 24];
|
||||||
|
@ -88,9 +94,9 @@ impl Credentials {
|
||||||
let mut data = base64::decode(encrypted_blob).unwrap();
|
let mut data = base64::decode(encrypted_blob).unwrap();
|
||||||
let cipher = Aes192::new(GenericArray::from_slice(&key));
|
let cipher = Aes192::new(GenericArray::from_slice(&key));
|
||||||
let block_size = <Aes192 as BlockCipher>::BlockSize::to_usize();
|
let block_size = <Aes192 as BlockCipher>::BlockSize::to_usize();
|
||||||
|
|
||||||
assert_eq!(data.len() % block_size, 0);
|
assert_eq!(data.len() % block_size, 0);
|
||||||
// replace to chunks_exact_mut with MSRV bump to 1.31
|
for chunk in data.chunks_exact_mut(block_size) {
|
||||||
for chunk in data.chunks_mut(block_size) {
|
|
||||||
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
|
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,7 +108,7 @@ impl Credentials {
|
||||||
data
|
data
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut cursor = io::Cursor::new(&blob);
|
let mut cursor = io::Cursor::new(blob.as_slice());
|
||||||
read_u8(&mut cursor).unwrap();
|
read_u8(&mut cursor).unwrap();
|
||||||
read_bytes(&mut cursor).unwrap();
|
read_bytes(&mut cursor).unwrap();
|
||||||
read_u8(&mut cursor).unwrap();
|
read_u8(&mut cursor).unwrap();
|
||||||
|
|
|
@ -238,29 +238,38 @@ pub struct RemoveFileError(());
|
||||||
|
|
||||||
impl Cache {
|
impl Cache {
|
||||||
pub fn new<P: AsRef<Path>>(
|
pub fn new<P: AsRef<Path>>(
|
||||||
system_location: Option<P>,
|
credentials_path: Option<P>,
|
||||||
audio_location: Option<P>,
|
volume_path: Option<P>,
|
||||||
|
audio_path: Option<P>,
|
||||||
size_limit: Option<u64>,
|
size_limit: Option<u64>,
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
if let Some(location) = &system_location {
|
let mut size_limiter = None;
|
||||||
|
|
||||||
|
if let Some(location) = &credentials_path {
|
||||||
fs::create_dir_all(location)?;
|
fs::create_dir_all(location)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut size_limiter = None;
|
let credentials_location = credentials_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.as_ref().join("credentials.json"));
|
||||||
|
|
||||||
if let Some(location) = &audio_location {
|
if let Some(location) = &volume_path {
|
||||||
fs::create_dir_all(location)?;
|
fs::create_dir_all(location)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let volume_location = volume_path.as_ref().map(|p| p.as_ref().join("volume"));
|
||||||
|
|
||||||
|
if let Some(location) = &audio_path {
|
||||||
|
fs::create_dir_all(location)?;
|
||||||
|
|
||||||
if let Some(limit) = size_limit {
|
if let Some(limit) = size_limit {
|
||||||
let limiter = FsSizeLimiter::new(location.as_ref(), limit);
|
let limiter = FsSizeLimiter::new(location.as_ref(), limit);
|
||||||
|
|
||||||
size_limiter = Some(Arc::new(limiter));
|
size_limiter = Some(Arc::new(limiter));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let audio_location = audio_location.map(|p| p.as_ref().to_owned());
|
let audio_location = audio_path.map(|p| p.as_ref().to_owned());
|
||||||
let volume_location = system_location.as_ref().map(|p| p.as_ref().join("volume"));
|
|
||||||
let credentials_location = system_location
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| p.as_ref().join("credentials.json"));
|
|
||||||
|
|
||||||
let cache = Cache {
|
let cache = Cache {
|
||||||
credentials_location,
|
credentials_location,
|
||||||
|
|
|
@ -125,3 +125,15 @@ pub struct ConnectConfig {
|
||||||
pub has_volume_ctrl: bool,
|
pub has_volume_ctrl: bool,
|
||||||
pub autoplay: bool,
|
pub autoplay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectConfig {
|
||||||
|
fn default() -> ConnectConfig {
|
||||||
|
ConnectConfig {
|
||||||
|
name: "Librespot".to_string(),
|
||||||
|
device_type: DeviceType::default(),
|
||||||
|
initial_volume: Some(50),
|
||||||
|
has_volume_ctrl: true,
|
||||||
|
autoplay: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -87,8 +87,7 @@ impl Decoder for ApCodec {
|
||||||
|
|
||||||
let mut payload = buf.split_to(size + MAC_SIZE);
|
let mut payload = buf.split_to(size + MAC_SIZE);
|
||||||
|
|
||||||
self.decode_cipher
|
self.decode_cipher.decrypt(payload.get_mut(..size).unwrap());
|
||||||
.decrypt(&mut payload.get_mut(..size).unwrap());
|
|
||||||
let mac = payload.split_off(size);
|
let mac = payload.split_off(size);
|
||||||
self.decode_cipher.check_mac(mac.as_ref())?;
|
self.decode_cipher.check_mac(mac.as_ref())?;
|
||||||
|
|
||||||
|
|
|
@ -124,7 +124,7 @@ fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<
|
||||||
let mut data = Vec::with_capacity(0x64);
|
let mut data = Vec::with_capacity(0x64);
|
||||||
for i in 1..6 {
|
for i in 1..6 {
|
||||||
let mut mac =
|
let mut mac =
|
||||||
HmacSha1::new_from_slice(&shared_secret).expect("HMAC can take key of any size");
|
HmacSha1::new_from_slice(shared_secret).expect("HMAC can take key of any size");
|
||||||
mac.update(packets);
|
mac.update(packets);
|
||||||
mac.update(&[i]);
|
mac.update(&[i]);
|
||||||
data.extend_from_slice(&mac.finalize().into_bytes());
|
data.extend_from_slice(&mac.finalize().into_bytes());
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-discovery"
|
name = "librespot-discovery"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||||
description = "The discovery logic for librespot"
|
description = "The discovery logic for librespot"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -28,7 +28,7 @@ dns-sd = { version = "0.1.3", optional = true }
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
default_features = false
|
default_features = false
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
|
@ -129,11 +129,10 @@ impl RequestHandler {
|
||||||
GenericArray::from_slice(iv),
|
GenericArray::from_slice(iv),
|
||||||
);
|
);
|
||||||
cipher.apply_keystream(&mut data);
|
cipher.apply_keystream(&mut data);
|
||||||
String::from_utf8(data).unwrap()
|
data
|
||||||
};
|
};
|
||||||
|
|
||||||
let credentials =
|
let credentials = Credentials::with_blob(username, &decrypted, &self.config.device_id);
|
||||||
Credentials::with_blob(username.to_string(), &decrypted, &self.config.device_id);
|
|
||||||
|
|
||||||
self.tx.send(credentials).unwrap();
|
self.tx.send(credentials).unwrap();
|
||||||
|
|
||||||
|
|
|
@ -57,20 +57,4 @@ login_data = AES192-DECRYPT(key, data)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Facebook based Authentication
|
## Facebook based Authentication
|
||||||
The client starts an HTTPS server, and makes the user visit
|
Facebook authentication is currently broken due to Spotify changing the authentication flow. The details of how the new flow works are detailed in https://github.com/librespot-org/librespot/issues/244 and will be implemented at some point in the future.
|
||||||
`https://login.spotify.com/login-facebook-sso/?csrf=CSRF&port=PORT`
|
|
||||||
in their browser, where CSRF is a random token, and PORT is the HTTPS server's port.
|
|
||||||
|
|
||||||
This will redirect to Facebook, where the user must login and authorize Spotify, and
|
|
||||||
finally make a GET request to
|
|
||||||
`https://login.spotilocal.com:PORT/login/facebook_login_sso.json?csrf=CSRF&access_token=TOKEN`,
|
|
||||||
where PORT and CSRF are the same as sent earlier, and TOKEN is the facebook authentication token.
|
|
||||||
|
|
||||||
Since `login.spotilocal.com` resolves the 127.0.0.1, the request is received by the client.
|
|
||||||
|
|
||||||
The client must then contact Facebook's API at
|
|
||||||
`https://graph.facebook.com/me?fields=id&access_token=TOKEN`
|
|
||||||
in order to retrieve the user's Facebook ID.
|
|
||||||
|
|
||||||
The Facebook ID is the `username`, the TOKEN the `auth_data`, and `auth_type` is set to `AUTHENTICATION_FACEBOOK_TOKEN`.
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-metadata"
|
name = "librespot-metadata"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||||
description = "The metadata logic for librespot"
|
description = "The metadata logic for librespot"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -19,8 +19,8 @@ uuid = { version = "0.8", default-features = false }
|
||||||
|
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies.librespot-protocol]
|
[dependencies.librespot-protocol]
|
||||||
path = "../protocol"
|
path = "../protocol"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-playback"
|
name = "librespot-playback"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Sasha Hilton <sashahilton00@gmail.com>"]
|
authors = ["Sasha Hilton <sashahilton00@gmail.com>"]
|
||||||
description = "The audio playback logic for librespot"
|
description = "The audio playback logic for librespot"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -9,13 +9,13 @@ edition = "2018"
|
||||||
|
|
||||||
[dependencies.librespot-audio]
|
[dependencies.librespot-audio]
|
||||||
path = "../audio"
|
path = "../audio"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
[dependencies.librespot-metadata]
|
[dependencies.librespot-metadata]
|
||||||
path = "../metadata"
|
path = "../metadata"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures-executor = "0.3"
|
futures-executor = "0.3"
|
||||||
|
@ -25,6 +25,7 @@ byteorder = "1.4"
|
||||||
shell-words = "1.0.0"
|
shell-words = "1.0.0"
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] }
|
||||||
zerocopy = { version = "0.3" }
|
zerocopy = { version = "0.3" }
|
||||||
|
thiserror = { version = "1" }
|
||||||
|
|
||||||
# Backends
|
# Backends
|
||||||
alsa = { version = "0.5", optional = true }
|
alsa = { version = "0.5", optional = true }
|
||||||
|
@ -40,22 +41,21 @@ glib = { version = "0.10", optional = true }
|
||||||
# Rodio dependencies
|
# Rodio dependencies
|
||||||
rodio = { version = "0.14", optional = true, default-features = false }
|
rodio = { version = "0.14", optional = true, default-features = false }
|
||||||
cpal = { version = "0.13", optional = true }
|
cpal = { version = "0.13", optional = true }
|
||||||
thiserror = { version = "1", optional = true }
|
|
||||||
|
|
||||||
# Decoder
|
# Decoder
|
||||||
lewton = "0.10"
|
lewton = "0.10"
|
||||||
ogg = "0.8"
|
ogg = "0.8"
|
||||||
|
|
||||||
# Dithering
|
# Dithering
|
||||||
rand = "0.8"
|
rand = { version = "0.8", features = ["small_rng"] }
|
||||||
rand_distr = "0.4"
|
rand_distr = "0.4"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
alsa-backend = ["alsa", "thiserror"]
|
alsa-backend = ["alsa"]
|
||||||
portaudio-backend = ["portaudio-rs"]
|
portaudio-backend = ["portaudio-rs"]
|
||||||
pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"]
|
pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"]
|
||||||
jackaudio-backend = ["jack"]
|
jackaudio-backend = ["jack"]
|
||||||
rodio-backend = ["rodio", "cpal", "thiserror"]
|
rodio-backend = ["rodio", "cpal"]
|
||||||
rodiojack-backend = ["rodio", "cpal/jack", "thiserror"]
|
rodiojack-backend = ["rodio", "cpal/jack"]
|
||||||
sdl-backend = ["sdl2"]
|
sdl-backend = ["sdl2"]
|
||||||
gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"]
|
gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{Open, Sink, SinkAsBytes};
|
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
|
@ -7,45 +7,94 @@ use alsa::device_name::HintIter;
|
||||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||||
use alsa::{Direction, ValueOr};
|
use alsa::{Direction, ValueOr};
|
||||||
use std::cmp::min;
|
use std::cmp::min;
|
||||||
use std::io;
|
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
// 125 ms Period time * 4 periods = 0.5 sec buffer.
|
// 0.5 sec buffer.
|
||||||
const PERIOD_TIME: Duration = Duration::from_millis(125);
|
const PERIOD_TIME: Duration = Duration::from_millis(100);
|
||||||
const NUM_PERIODS: u32 = 4;
|
const BUFFER_TIME: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
enum AlsaError {
|
enum AlsaError {
|
||||||
#[error("AlsaSink, device {device} may be invalid or busy, {err}")]
|
#[error("<AlsaSink> Device {device} Unsupported Format {alsa_format:?} ({format:?}), {e}")]
|
||||||
PcmSetUp { device: String, err: alsa::Error },
|
|
||||||
#[error("AlsaSink, device {device} unsupported access type RWInterleaved, {err}")]
|
|
||||||
UnsupportedAccessType { device: String, err: alsa::Error },
|
|
||||||
#[error("AlsaSink, device {device} unsupported format {format:?}, {err}")]
|
|
||||||
UnsupportedFormat {
|
UnsupportedFormat {
|
||||||
device: String,
|
device: String,
|
||||||
|
alsa_format: Format,
|
||||||
format: AudioFormat,
|
format: AudioFormat,
|
||||||
err: alsa::Error,
|
e: alsa::Error,
|
||||||
},
|
},
|
||||||
#[error("AlsaSink, device {device} unsupported sample rate {samplerate}, {err}")]
|
|
||||||
UnsupportedSampleRate {
|
#[error("<AlsaSink> Device {device} Unsupported Channel Count {channel_count}, {e}")]
|
||||||
device: String,
|
|
||||||
samplerate: u32,
|
|
||||||
err: alsa::Error,
|
|
||||||
},
|
|
||||||
#[error("AlsaSink, device {device} unsupported channel count {channel_count}, {err}")]
|
|
||||||
UnsupportedChannelCount {
|
UnsupportedChannelCount {
|
||||||
device: String,
|
device: String,
|
||||||
channel_count: u8,
|
channel_count: u8,
|
||||||
err: alsa::Error,
|
e: alsa::Error,
|
||||||
},
|
},
|
||||||
#[error("AlsaSink Hardware Parameters Error, {0}")]
|
|
||||||
|
#[error("<AlsaSink> Device {device} Unsupported Sample Rate {samplerate}, {e}")]
|
||||||
|
UnsupportedSampleRate {
|
||||||
|
device: String,
|
||||||
|
samplerate: u32,
|
||||||
|
e: alsa::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("<AlsaSink> Device {device} Unsupported Access Type RWInterleaved, {e}")]
|
||||||
|
UnsupportedAccessType { device: String, e: alsa::Error },
|
||||||
|
|
||||||
|
#[error("<AlsaSink> Device {device} May be Invalid, Busy, or Already in Use, {e}")]
|
||||||
|
PcmSetUp { device: String, e: alsa::Error },
|
||||||
|
|
||||||
|
#[error("<AlsaSink> Failed to Drain PCM Buffer, {0}")]
|
||||||
|
DrainFailure(alsa::Error),
|
||||||
|
|
||||||
|
#[error("<AlsaSink> {0}")]
|
||||||
|
OnWrite(alsa::Error),
|
||||||
|
|
||||||
|
#[error("<AlsaSink> Hardware, {0}")]
|
||||||
HwParams(alsa::Error),
|
HwParams(alsa::Error),
|
||||||
#[error("AlsaSink Software Parameters Error, {0}")]
|
|
||||||
|
#[error("<AlsaSink> Software, {0}")]
|
||||||
SwParams(alsa::Error),
|
SwParams(alsa::Error),
|
||||||
#[error("AlsaSink PCM Error, {0}")]
|
|
||||||
|
#[error("<AlsaSink> PCM, {0}")]
|
||||||
Pcm(alsa::Error),
|
Pcm(alsa::Error),
|
||||||
|
|
||||||
|
#[error("<AlsaSink> Could Not Parse Ouput Name(s) and/or Description(s)")]
|
||||||
|
Parsing,
|
||||||
|
|
||||||
|
#[error("<AlsaSink>")]
|
||||||
|
NotConnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlsaError> for SinkError {
|
||||||
|
fn from(e: AlsaError) -> SinkError {
|
||||||
|
use AlsaError::*;
|
||||||
|
let es = e.to_string();
|
||||||
|
match e {
|
||||||
|
DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
|
||||||
|
PcmSetUp { .. } => SinkError::ConnectionRefused(es),
|
||||||
|
NotConnected => SinkError::NotConnected(es),
|
||||||
|
_ => SinkError::InvalidParams(es),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AudioFormat> for Format {
|
||||||
|
fn from(f: AudioFormat) -> Format {
|
||||||
|
use AudioFormat::*;
|
||||||
|
match f {
|
||||||
|
F64 => Format::float64(),
|
||||||
|
F32 => Format::float(),
|
||||||
|
S32 => Format::s32(),
|
||||||
|
S24 => Format::s24(),
|
||||||
|
S16 => Format::s16(),
|
||||||
|
#[cfg(target_endian = "little")]
|
||||||
|
S24_3 => Format::S243LE,
|
||||||
|
#[cfg(target_endian = "big")]
|
||||||
|
S24_3 => Format::S243BE,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AlsaSink {
|
pub struct AlsaSink {
|
||||||
|
@ -55,26 +104,50 @@ pub struct AlsaSink {
|
||||||
period_buffer: Vec<u8>,
|
period_buffer: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_outputs() -> io::Result<()> {
|
fn list_compatible_devices() -> SinkResult<()> {
|
||||||
println!("Listing available Alsa outputs:");
|
println!("\n\n\tCompatible alsa device(s):\n");
|
||||||
for t in &["pcm", "ctl", "hwdep"] {
|
println!("\t------------------------------------------------------\n");
|
||||||
println!("{} devices:", t);
|
|
||||||
let i = match HintIter::new_str(None, &t) {
|
let i = HintIter::new_str(None, "pcm").map_err(|_| AlsaError::Parsing)?;
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::Other, e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for a in i {
|
for a in i {
|
||||||
if let Some(Direction::Playback) = a.direction {
|
if let Some(Direction::Playback) = a.direction {
|
||||||
// mimic aplay -L
|
let name = a.name.ok_or(AlsaError::Parsing)?;
|
||||||
let name = a
|
let desc = a.desc.ok_or(AlsaError::Parsing)?;
|
||||||
.name
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse name"))?;
|
if let Ok(pcm) = PCM::new(&name, Direction::Playback, false) {
|
||||||
let desc = a
|
if let Ok(hwp) = HwParams::any(&pcm) {
|
||||||
.desc
|
// Only show devices that support
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?;
|
// 2 ch 44.1 Interleaved.
|
||||||
println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t"));
|
if hwp.set_access(Access::RWInterleaved).is_ok()
|
||||||
|
&& hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).is_ok()
|
||||||
|
&& hwp.set_channels(NUM_CHANNELS as u32).is_ok()
|
||||||
|
{
|
||||||
|
println!("\tDevice:\n\n\t\t{}\n", name);
|
||||||
|
println!("\tDescription:\n\n\t\t{}\n", desc.replace("\n", "\n\t\t"));
|
||||||
|
|
||||||
|
let mut supported_formats = vec![];
|
||||||
|
|
||||||
|
for f in &[
|
||||||
|
AudioFormat::S16,
|
||||||
|
AudioFormat::S24,
|
||||||
|
AudioFormat::S24_3,
|
||||||
|
AudioFormat::S32,
|
||||||
|
AudioFormat::F32,
|
||||||
|
AudioFormat::F64,
|
||||||
|
] {
|
||||||
|
if hwp.test_format(Format::from(*f)).is_ok() {
|
||||||
|
supported_formats.push(format!("{:?}", f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\tSupported Format(s):\n\n\t\t{}\n",
|
||||||
|
supported_formats.join(" ")
|
||||||
|
);
|
||||||
|
println!("\t------------------------------------------------------\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,45 +155,36 @@ fn list_outputs() -> io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), AlsaError> {
|
fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> {
|
||||||
let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp {
|
let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp {
|
||||||
device: dev_name.to_string(),
|
device: dev_name.to_string(),
|
||||||
err: e,
|
e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let alsa_format = match format {
|
|
||||||
AudioFormat::F64 => Format::float64(),
|
|
||||||
AudioFormat::F32 => Format::float(),
|
|
||||||
AudioFormat::S32 => Format::s32(),
|
|
||||||
AudioFormat::S24 => Format::s24(),
|
|
||||||
AudioFormat::S16 => Format::s16(),
|
|
||||||
|
|
||||||
#[cfg(target_endian = "little")]
|
|
||||||
AudioFormat::S24_3 => Format::S243LE,
|
|
||||||
#[cfg(target_endian = "big")]
|
|
||||||
AudioFormat::S24_3 => Format::S243BE,
|
|
||||||
};
|
|
||||||
|
|
||||||
let bytes_per_period = {
|
let bytes_per_period = {
|
||||||
let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?;
|
let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?;
|
||||||
|
|
||||||
hwp.set_access(Access::RWInterleaved)
|
hwp.set_access(Access::RWInterleaved)
|
||||||
.map_err(|e| AlsaError::UnsupportedAccessType {
|
.map_err(|e| AlsaError::UnsupportedAccessType {
|
||||||
device: dev_name.to_string(),
|
device: dev_name.to_string(),
|
||||||
err: e,
|
e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let alsa_format = Format::from(format);
|
||||||
|
|
||||||
hwp.set_format(alsa_format)
|
hwp.set_format(alsa_format)
|
||||||
.map_err(|e| AlsaError::UnsupportedFormat {
|
.map_err(|e| AlsaError::UnsupportedFormat {
|
||||||
device: dev_name.to_string(),
|
device: dev_name.to_string(),
|
||||||
|
alsa_format,
|
||||||
format,
|
format,
|
||||||
err: e,
|
e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| {
|
hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| {
|
||||||
AlsaError::UnsupportedSampleRate {
|
AlsaError::UnsupportedSampleRate {
|
||||||
device: dev_name.to_string(),
|
device: dev_name.to_string(),
|
||||||
samplerate: SAMPLE_RATE,
|
samplerate: SAMPLE_RATE,
|
||||||
err: e,
|
e,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
@ -128,11 +192,10 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa
|
||||||
.map_err(|e| AlsaError::UnsupportedChannelCount {
|
.map_err(|e| AlsaError::UnsupportedChannelCount {
|
||||||
device: dev_name.to_string(),
|
device: dev_name.to_string(),
|
||||||
channel_count: NUM_CHANNELS,
|
channel_count: NUM_CHANNELS,
|
||||||
err: e,
|
e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Deal strictly in time and periods.
|
hwp.set_buffer_time_near(BUFFER_TIME.as_micros() as u32, ValueOr::Nearest)
|
||||||
hwp.set_periods(NUM_PERIODS, ValueOr::Nearest)
|
|
||||||
.map_err(AlsaError::HwParams)?;
|
.map_err(AlsaError::HwParams)?;
|
||||||
|
|
||||||
hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest)
|
hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest)
|
||||||
|
@ -142,8 +205,7 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa
|
||||||
|
|
||||||
let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?;
|
let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?;
|
||||||
|
|
||||||
// Don't assume we got what we wanted.
|
// Don't assume we got what we wanted. Ask to make sure.
|
||||||
// Ask to make sure.
|
|
||||||
let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?;
|
let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?;
|
||||||
|
|
||||||
let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?;
|
let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?;
|
||||||
|
@ -153,22 +215,27 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa
|
||||||
|
|
||||||
pcm.sw_params(&swp).map_err(AlsaError::Pcm)?;
|
pcm.sw_params(&swp).map_err(AlsaError::Pcm)?;
|
||||||
|
|
||||||
|
trace!("Frames per Buffer: {:?}", frames_per_buffer);
|
||||||
|
trace!("Frames per Period: {:?}", frames_per_period);
|
||||||
|
|
||||||
// Let ALSA do the math for us.
|
// Let ALSA do the math for us.
|
||||||
pcm.frames_to_bytes(frames_per_period) as usize
|
pcm.frames_to_bytes(frames_per_period) as usize
|
||||||
};
|
};
|
||||||
|
|
||||||
|
trace!("Period Buffer size in bytes: {:?}", bytes_per_period);
|
||||||
|
|
||||||
Ok((pcm, bytes_per_period))
|
Ok((pcm, bytes_per_period))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Open for AlsaSink {
|
impl Open for AlsaSink {
|
||||||
fn open(device: Option<String>, format: AudioFormat) -> Self {
|
fn open(device: Option<String>, format: AudioFormat) -> Self {
|
||||||
let name = match device.as_deref() {
|
let name = match device.as_deref() {
|
||||||
Some("?") => match list_outputs() {
|
Some("?") => match list_compatible_devices() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(e) => {
|
||||||
error!("Error listing Alsa outputs, {}", err);
|
error!("{}", e);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -189,38 +256,35 @@ impl Open for AlsaSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for AlsaSink {
|
impl Sink for AlsaSink {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
if self.pcm.is_none() {
|
if self.pcm.is_none() {
|
||||||
match open_device(&self.device, self.format) {
|
let (pcm, bytes_per_period) = open_device(&self.device, self.format)?;
|
||||||
Ok((pcm, bytes_per_period)) => {
|
|
||||||
self.pcm = Some(pcm);
|
self.pcm = Some(pcm);
|
||||||
|
|
||||||
|
if self.period_buffer.capacity() != bytes_per_period {
|
||||||
self.period_buffer = Vec::with_capacity(bytes_per_period);
|
self.period_buffer = Vec::with_capacity(bytes_per_period);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::Other, e));
|
// Should always match the "Period Buffer size in bytes: " trace! message.
|
||||||
}
|
trace!(
|
||||||
}
|
"Period Buffer capacity: {:?}",
|
||||||
|
self.period_buffer.capacity()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
{
|
// Zero fill the remainder of the period buffer and
|
||||||
// Write any leftover data in the period buffer
|
// write any leftover data before draining the actual PCM buffer.
|
||||||
// before draining the actual buffer
|
self.period_buffer.resize(self.period_buffer.capacity(), 0);
|
||||||
self.write_bytes(&[])?;
|
self.write_buf()?;
|
||||||
let pcm = self.pcm.as_mut().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::Other, "Error stopping AlsaSink, PCM is None")
|
let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?;
|
||||||
})?;
|
|
||||||
pcm.drain().map_err(|e| {
|
pcm.drain().map_err(AlsaError::DrainFailure)?;
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::Other,
|
|
||||||
format!("Error stopping AlsaSink {}", e),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
self.pcm = None;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,55 +292,51 @@ impl Sink for AlsaSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SinkAsBytes for AlsaSink {
|
impl SinkAsBytes for AlsaSink {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
|
||||||
let mut processed_data = 0;
|
let mut start_index = 0;
|
||||||
while processed_data < data.len() {
|
let data_len = data.len();
|
||||||
let data_to_buffer = min(
|
let capacity = self.period_buffer.capacity();
|
||||||
self.period_buffer.capacity() - self.period_buffer.len(),
|
|
||||||
data.len() - processed_data,
|
loop {
|
||||||
);
|
let data_left = data_len - start_index;
|
||||||
|
let space_left = capacity - self.period_buffer.len();
|
||||||
|
let data_to_buffer = min(data_left, space_left);
|
||||||
|
let end_index = start_index + data_to_buffer;
|
||||||
|
|
||||||
self.period_buffer
|
self.period_buffer
|
||||||
.extend_from_slice(&data[processed_data..processed_data + data_to_buffer]);
|
.extend_from_slice(&data[start_index..end_index]);
|
||||||
processed_data += data_to_buffer;
|
|
||||||
if self.period_buffer.len() == self.period_buffer.capacity() {
|
if self.period_buffer.len() == capacity {
|
||||||
self.write_buf()?;
|
self.write_buf()?;
|
||||||
self.period_buffer.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
if end_index == data_len {
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
start_index = end_index;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlsaSink {
|
impl AlsaSink {
|
||||||
pub const NAME: &'static str = "alsa";
|
pub const NAME: &'static str = "alsa";
|
||||||
|
|
||||||
fn write_buf(&mut self) -> io::Result<()> {
|
fn write_buf(&mut self) -> SinkResult<()> {
|
||||||
let pcm = self.pcm.as_mut().ok_or_else(|| {
|
let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?;
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::Other,
|
if let Err(e) = pcm.io_bytes().writei(&self.period_buffer) {
|
||||||
"Error writing from AlsaSink buffer to PCM, PCM is None",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let io = pcm.io_bytes();
|
|
||||||
if let Err(err) = io.writei(&self.period_buffer) {
|
|
||||||
// Capture and log the original error as a warning, and then try to recover.
|
// Capture and log the original error as a warning, and then try to recover.
|
||||||
// If recovery fails then forward that error back to player.
|
// If recovery fails then forward that error back to player.
|
||||||
warn!(
|
warn!(
|
||||||
"Error writing from AlsaSink buffer to PCM, trying to recover {}",
|
"Error writing from AlsaSink buffer to PCM, trying to recover, {}",
|
||||||
err
|
|
||||||
);
|
|
||||||
pcm.try_recover(err, false).map_err(|e| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::Other,
|
|
||||||
format!(
|
|
||||||
"Error writing from AlsaSink buffer to PCM, recovery failed {}",
|
|
||||||
e
|
e
|
||||||
),
|
);
|
||||||
)
|
|
||||||
})?
|
pcm.try_recover(e, false).map_err(AlsaError::OnWrite)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.period_buffer.clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{Open, Sink, SinkAsBytes};
|
use super::{Open, Sink, SinkAsBytes, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
|
@ -11,7 +11,7 @@ use gst::prelude::*;
|
||||||
use zerocopy::AsBytes;
|
use zerocopy::AsBytes;
|
||||||
|
|
||||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||||
use std::{io, thread};
|
use std::thread;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct GstreamerSink {
|
pub struct GstreamerSink {
|
||||||
|
@ -131,7 +131,7 @@ impl Sink for GstreamerSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SinkAsBytes for GstreamerSink {
|
impl SinkAsBytes for GstreamerSink {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
|
||||||
// Copy expensively (in to_vec()) to avoid thread synchronization
|
// Copy expensively (in to_vec()) to avoid thread synchronization
|
||||||
self.tx
|
self.tx
|
||||||
.send(data.to_vec())
|
.send(data.to_vec())
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{Open, Sink};
|
use super::{Open, Sink, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
|
@ -6,7 +6,6 @@ use crate::NUM_CHANNELS;
|
||||||
use jack::{
|
use jack::{
|
||||||
AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope,
|
AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope,
|
||||||
};
|
};
|
||||||
use std::io;
|
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
||||||
|
|
||||||
pub struct JackSink {
|
pub struct JackSink {
|
||||||
|
@ -25,15 +24,12 @@ pub struct JackData {
|
||||||
impl ProcessHandler for JackData {
|
impl ProcessHandler for JackData {
|
||||||
fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control {
|
fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control {
|
||||||
// get output port buffers
|
// get output port buffers
|
||||||
let mut out_r = self.port_r.as_mut_slice(ps);
|
let buf_r: &mut [f32] = self.port_r.as_mut_slice(ps);
|
||||||
let mut out_l = self.port_l.as_mut_slice(ps);
|
let buf_l: &mut [f32] = self.port_l.as_mut_slice(ps);
|
||||||
let buf_r: &mut [f32] = &mut out_r;
|
|
||||||
let buf_l: &mut [f32] = &mut out_l;
|
|
||||||
// get queue iterator
|
// get queue iterator
|
||||||
let mut queue_iter = self.rec.try_iter();
|
let mut queue_iter = self.rec.try_iter();
|
||||||
|
|
||||||
let buf_size = buf_r.len();
|
for i in 0..buf_r.len() {
|
||||||
for i in 0..buf_size {
|
|
||||||
buf_r[i] = queue_iter.next().unwrap_or(0.0);
|
buf_r[i] = queue_iter.next().unwrap_or(0.0);
|
||||||
buf_l[i] = queue_iter.next().unwrap_or(0.0);
|
buf_l[i] = queue_iter.next().unwrap_or(0.0);
|
||||||
}
|
}
|
||||||
|
@ -70,8 +66,12 @@ impl Open for JackSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for JackSink {
|
impl Sink for JackSink {
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
||||||
let samples_f32: &[f32] = &converter.f64_to_f32(packet.samples());
|
let samples = packet
|
||||||
|
.samples()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
|
|
||||||
|
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
||||||
for sample in samples_f32.iter() {
|
for sample in samples_f32.iter() {
|
||||||
let res = self.send.send(*sample);
|
let res = self.send.send(*sample);
|
||||||
if res.is_err() {
|
if res.is_err() {
|
||||||
|
|
|
@ -1,26 +1,40 @@
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
use std::io;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SinkError {
|
||||||
|
#[error("Audio Sink Error Not Connected: {0}")]
|
||||||
|
NotConnected(String),
|
||||||
|
#[error("Audio Sink Error Connection Refused: {0}")]
|
||||||
|
ConnectionRefused(String),
|
||||||
|
#[error("Audio Sink Error On Write: {0}")]
|
||||||
|
OnWrite(String),
|
||||||
|
#[error("Audio Sink Error Invalid Parameters: {0}")]
|
||||||
|
InvalidParams(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SinkResult<T> = Result<T, SinkError>;
|
||||||
|
|
||||||
pub trait Open {
|
pub trait Open {
|
||||||
fn open(_: Option<String>, format: AudioFormat) -> Self;
|
fn open(_: Option<String>, format: AudioFormat) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Sink {
|
pub trait Sink {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()>;
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SinkBuilder = fn(Option<String>, AudioFormat) -> Box<dyn Sink>;
|
pub type SinkBuilder = fn(Option<String>, AudioFormat) -> Box<dyn Sink>;
|
||||||
|
|
||||||
pub trait SinkAsBytes {
|
pub trait SinkAsBytes {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()>;
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {
|
fn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {
|
||||||
|
@ -30,7 +44,7 @@ fn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat
|
||||||
// reuse code for various backends
|
// reuse code for various backends
|
||||||
macro_rules! sink_as_bytes {
|
macro_rules! sink_as_bytes {
|
||||||
() => {
|
() => {
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
||||||
use crate::convert::i24;
|
use crate::convert::i24;
|
||||||
use zerocopy::AsBytes;
|
use zerocopy::AsBytes;
|
||||||
match packet {
|
match packet {
|
||||||
|
@ -90,7 +104,7 @@ use self::gstreamer::GstreamerSink;
|
||||||
|
|
||||||
#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))]
|
#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))]
|
||||||
mod rodio;
|
mod rodio;
|
||||||
#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))]
|
#[cfg(feature = "rodio-backend")]
|
||||||
use self::rodio::RodioSink;
|
use self::rodio::RodioSink;
|
||||||
|
|
||||||
#[cfg(feature = "sdl-backend")]
|
#[cfg(feature = "sdl-backend")]
|
||||||
|
@ -132,11 +146,6 @@ pub fn find(name: Option<String>) -> Option<SinkBuilder> {
|
||||||
.find(|backend| name == backend.0)
|
.find(|backend| name == backend.0)
|
||||||
.map(|backend| backend.1)
|
.map(|backend| backend.1)
|
||||||
} else {
|
} else {
|
||||||
Some(
|
BACKENDS.first().map(|backend| backend.1)
|
||||||
BACKENDS
|
|
||||||
.first()
|
|
||||||
.expect("No backends were enabled at build time")
|
|
||||||
.1,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{Open, Sink, SinkAsBytes};
|
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
|
@ -23,14 +23,14 @@ impl Open for StdoutSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for StdoutSink {
|
impl Sink for StdoutSink {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
if self.output.is_none() {
|
if self.output.is_none() {
|
||||||
let output: Box<dyn Write> = match self.path.as_deref() {
|
let output: Box<dyn Write> = match self.path.as_deref() {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let open_op = OpenOptions::new()
|
let open_op = OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(path)
|
.open(path)
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
|
||||||
Box::new(open_op)
|
Box::new(open_op)
|
||||||
}
|
}
|
||||||
None => Box::new(io::stdout()),
|
None => Box::new(io::stdout()),
|
||||||
|
@ -46,14 +46,18 @@ impl Sink for StdoutSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SinkAsBytes for StdoutSink {
|
impl SinkAsBytes for StdoutSink {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
|
||||||
match self.output.as_deref_mut() {
|
match self.output.as_deref_mut() {
|
||||||
Some(output) => {
|
Some(output) => {
|
||||||
output.write_all(data)?;
|
output
|
||||||
output.flush()?;
|
.write_all(data)
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
|
output
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
return Err(io::Error::new(io::ErrorKind::Other, "Output is None"));
|
return Err(SinkError::NotConnected("Output is None".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
use super::{Open, Sink};
|
use super::{Open, Sink, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
||||||
use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo};
|
use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo};
|
||||||
use portaudio_rs::stream::*;
|
use portaudio_rs::stream::*;
|
||||||
use std::io;
|
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
@ -96,7 +95,7 @@ impl<'a> Open for PortAudioSink<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Sink for PortAudioSink<'a> {
|
impl<'a> Sink for PortAudioSink<'a> {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
macro_rules! start_sink {
|
macro_rules! start_sink {
|
||||||
(ref mut $stream: ident, ref $parameters: ident) => {{
|
(ref mut $stream: ident, ref $parameters: ident) => {{
|
||||||
if $stream.is_none() {
|
if $stream.is_none() {
|
||||||
|
@ -125,7 +124,7 @@ impl<'a> Sink for PortAudioSink<'a> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
macro_rules! stop_sink {
|
macro_rules! stop_sink {
|
||||||
(ref mut $stream: ident) => {{
|
(ref mut $stream: ident) => {{
|
||||||
$stream.as_mut().unwrap().stop().unwrap();
|
$stream.as_mut().unwrap().stop().unwrap();
|
||||||
|
@ -141,14 +140,17 @@ impl<'a> Sink for PortAudioSink<'a> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
||||||
macro_rules! write_sink {
|
macro_rules! write_sink {
|
||||||
(ref mut $stream: expr, $samples: expr) => {
|
(ref mut $stream: expr, $samples: expr) => {
|
||||||
$stream.as_mut().unwrap().write($samples)
|
$stream.as_mut().unwrap().write($samples)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let samples = packet.samples();
|
let samples = packet
|
||||||
|
.samples()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
|
|
||||||
let result = match self {
|
let result = match self {
|
||||||
Self::F32(stream, _parameters) => {
|
Self::F32(stream, _parameters) => {
|
||||||
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
||||||
|
|
|
@ -1,36 +1,87 @@
|
||||||
use super::{Open, Sink, SinkAsBytes};
|
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
||||||
use libpulse_binding::{self as pulse, stream::Direction};
|
use libpulse_binding::{self as pulse, error::PAErr, stream::Direction};
|
||||||
use libpulse_simple_binding::Simple;
|
use libpulse_simple_binding::Simple;
|
||||||
use std::io;
|
use thiserror::Error;
|
||||||
|
|
||||||
const APP_NAME: &str = "librespot";
|
const APP_NAME: &str = "librespot";
|
||||||
const STREAM_NAME: &str = "Spotify endpoint";
|
const STREAM_NAME: &str = "Spotify endpoint";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
enum PulseError {
|
||||||
|
#[error("<PulseAudioSink> Unsupported Pulseaudio Sample Spec, Format {pulse_format:?} ({format:?}), Channels {channels}, Rate {rate}")]
|
||||||
|
InvalidSampleSpec {
|
||||||
|
pulse_format: pulse::sample::Format,
|
||||||
|
format: AudioFormat,
|
||||||
|
channels: u8,
|
||||||
|
rate: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("<PulseAudioSink> {0}")]
|
||||||
|
ConnectionRefused(PAErr),
|
||||||
|
|
||||||
|
#[error("<PulseAudioSink> Failed to Drain Pulseaudio Buffer, {0}")]
|
||||||
|
DrainFailure(PAErr),
|
||||||
|
|
||||||
|
#[error("<PulseAudioSink>")]
|
||||||
|
NotConnected,
|
||||||
|
|
||||||
|
#[error("<PulseAudioSink> {0}")]
|
||||||
|
OnWrite(PAErr),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PulseError> for SinkError {
|
||||||
|
fn from(e: PulseError) -> SinkError {
|
||||||
|
use PulseError::*;
|
||||||
|
let es = e.to_string();
|
||||||
|
match e {
|
||||||
|
DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
|
||||||
|
ConnectionRefused(_) => SinkError::ConnectionRefused(es),
|
||||||
|
NotConnected => SinkError::NotConnected(es),
|
||||||
|
InvalidSampleSpec { .. } => SinkError::InvalidParams(es),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PulseAudioSink {
|
pub struct PulseAudioSink {
|
||||||
s: Option<Simple>,
|
s: Option<Simple>,
|
||||||
ss: pulse::sample::Spec,
|
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
format: AudioFormat,
|
format: AudioFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Open for PulseAudioSink {
|
impl Open for PulseAudioSink {
|
||||||
fn open(device: Option<String>, format: AudioFormat) -> Self {
|
fn open(device: Option<String>, format: AudioFormat) -> Self {
|
||||||
info!("Using PulseAudio sink with format: {:?}", format);
|
let mut actual_format = format;
|
||||||
|
|
||||||
|
if actual_format == AudioFormat::F64 {
|
||||||
|
warn!("PulseAudio currently does not support F64 output");
|
||||||
|
actual_format = AudioFormat::F32;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Using PulseAudioSink with format: {:?}", actual_format);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
s: None,
|
||||||
|
device,
|
||||||
|
format: actual_format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sink for PulseAudioSink {
|
||||||
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
|
if self.s.is_none() {
|
||||||
// PulseAudio calls S24 and S24_3 different from the rest of the world
|
// PulseAudio calls S24 and S24_3 different from the rest of the world
|
||||||
let pulse_format = match format {
|
let pulse_format = match self.format {
|
||||||
AudioFormat::F32 => pulse::sample::Format::FLOAT32NE,
|
AudioFormat::F32 => pulse::sample::Format::FLOAT32NE,
|
||||||
AudioFormat::S32 => pulse::sample::Format::S32NE,
|
AudioFormat::S32 => pulse::sample::Format::S32NE,
|
||||||
AudioFormat::S24 => pulse::sample::Format::S24_32NE,
|
AudioFormat::S24 => pulse::sample::Format::S24_32NE,
|
||||||
AudioFormat::S24_3 => pulse::sample::Format::S24NE,
|
AudioFormat::S24_3 => pulse::sample::Format::S24NE,
|
||||||
AudioFormat::S16 => pulse::sample::Format::S16NE,
|
AudioFormat::S16 => pulse::sample::Format::S16NE,
|
||||||
_ => {
|
_ => unreachable!(),
|
||||||
unimplemented!("PulseAudio currently does not support {:?} output", format)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let ss = pulse::sample::Spec {
|
let ss = pulse::sample::Spec {
|
||||||
|
@ -38,47 +89,41 @@ impl Open for PulseAudioSink {
|
||||||
channels: NUM_CHANNELS,
|
channels: NUM_CHANNELS,
|
||||||
rate: SAMPLE_RATE,
|
rate: SAMPLE_RATE,
|
||||||
};
|
};
|
||||||
debug_assert!(ss.is_valid());
|
|
||||||
|
|
||||||
Self {
|
if !ss.is_valid() {
|
||||||
s: None,
|
let pulse_error = PulseError::InvalidSampleSpec {
|
||||||
ss,
|
pulse_format,
|
||||||
device,
|
format: self.format,
|
||||||
format,
|
channels: NUM_CHANNELS,
|
||||||
}
|
rate: SAMPLE_RATE,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
return Err(SinkError::from(pulse_error));
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for PulseAudioSink {
|
let s = Simple::new(
|
||||||
fn start(&mut self) -> io::Result<()> {
|
|
||||||
if self.s.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = self.device.as_deref();
|
|
||||||
let result = Simple::new(
|
|
||||||
None, // Use the default server.
|
None, // Use the default server.
|
||||||
APP_NAME, // Our application's name.
|
APP_NAME, // Our application's name.
|
||||||
Direction::Playback, // Direction.
|
Direction::Playback, // Direction.
|
||||||
device, // Our device (sink) name.
|
self.device.as_deref(), // Our device (sink) name.
|
||||||
STREAM_NAME, // Description of our stream.
|
STREAM_NAME, // Description of our stream.
|
||||||
&self.ss, // Our sample format.
|
&ss, // Our sample format.
|
||||||
None, // Use default channel map.
|
None, // Use default channel map.
|
||||||
None, // Use default buffering attributes.
|
None, // Use default buffering attributes.
|
||||||
);
|
)
|
||||||
match result {
|
.map_err(PulseError::ConnectionRefused)?;
|
||||||
Ok(s) => {
|
|
||||||
self.s = Some(s);
|
self.s = Some(s);
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(io::Error::new(
|
|
||||||
io::ErrorKind::ConnectionRefused,
|
|
||||||
e.to_string().unwrap(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
|
let s = self.s.as_mut().ok_or(PulseError::NotConnected)?;
|
||||||
|
|
||||||
|
s.drain().map_err(PulseError::DrainFailure)?;
|
||||||
|
|
||||||
self.s = None;
|
self.s = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -87,21 +132,12 @@ impl Sink for PulseAudioSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SinkAsBytes for PulseAudioSink {
|
impl SinkAsBytes for PulseAudioSink {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
|
||||||
if let Some(s) = &self.s {
|
let s = self.s.as_mut().ok_or(PulseError::NotConnected)?;
|
||||||
match s.write(data) {
|
|
||||||
Ok(_) => Ok(()),
|
s.write(data).map_err(PulseError::OnWrite)?;
|
||||||
Err(e) => Err(io::Error::new(
|
|
||||||
io::ErrorKind::BrokenPipe,
|
Ok(())
|
||||||
e.to_string().unwrap(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(io::Error::new(
|
|
||||||
io::ErrorKind::NotConnected,
|
|
||||||
"Not connected to PulseAudio",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{io, thread};
|
|
||||||
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use super::Sink;
|
use super::{Sink, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
|
@ -33,16 +33,30 @@ pub fn mk_rodiojack(device: Option<String>, format: AudioFormat) -> Box<dyn Sink
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RodioError {
|
pub enum RodioError {
|
||||||
#[error("Rodio: no device available")]
|
#[error("<RodioSink> No Device Available")]
|
||||||
NoDeviceAvailable,
|
NoDeviceAvailable,
|
||||||
#[error("Rodio: device \"{0}\" is not available")]
|
#[error("<RodioSink> device \"{0}\" is Not Available")]
|
||||||
DeviceNotAvailable(String),
|
DeviceNotAvailable(String),
|
||||||
#[error("Rodio play error: {0}")]
|
#[error("<RodioSink> Play Error: {0}")]
|
||||||
PlayError(#[from] rodio::PlayError),
|
PlayError(#[from] rodio::PlayError),
|
||||||
#[error("Rodio stream error: {0}")]
|
#[error("<RodioSink> Stream Error: {0}")]
|
||||||
StreamError(#[from] rodio::StreamError),
|
StreamError(#[from] rodio::StreamError),
|
||||||
#[error("Cannot get audio devices: {0}")]
|
#[error("<RodioSink> Cannot Get Audio Devices: {0}")]
|
||||||
DevicesError(#[from] cpal::DevicesError),
|
DevicesError(#[from] cpal::DevicesError),
|
||||||
|
#[error("<RodioSink> {0}")]
|
||||||
|
Samples(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RodioError> for SinkError {
|
||||||
|
fn from(e: RodioError) -> SinkError {
|
||||||
|
use RodioError::*;
|
||||||
|
let es = e.to_string();
|
||||||
|
match e {
|
||||||
|
StreamError(_) | PlayError(_) | Samples(_) => SinkError::OnWrite(es),
|
||||||
|
NoDeviceAvailable | DeviceNotAvailable(_) => SinkError::ConnectionRefused(es),
|
||||||
|
DevicesError(_) => SinkError::InvalidParams(es),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RodioSink {
|
pub struct RodioSink {
|
||||||
|
@ -175,8 +189,10 @@ pub fn open(host: cpal::Host, device: Option<String>, format: AudioFormat) -> Ro
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for RodioSink {
|
impl Sink for RodioSink {
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
||||||
let samples = packet.samples();
|
let samples = packet
|
||||||
|
.samples()
|
||||||
|
.map_err(|e| RodioError::Samples(e.to_string()))?;
|
||||||
match self.format {
|
match self.format {
|
||||||
AudioFormat::F32 => {
|
AudioFormat::F32 => {
|
||||||
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
||||||
|
@ -211,5 +227,6 @@ impl Sink for RodioSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RodioSink {
|
impl RodioSink {
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const NAME: &'static str = "rodio";
|
pub const NAME: &'static str = "rodio";
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use super::{Open, Sink};
|
use super::{Open, Sink, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
use crate::{NUM_CHANNELS, SAMPLE_RATE};
|
||||||
use sdl2::audio::{AudioQueue, AudioSpecDesired};
|
use sdl2::audio::{AudioQueue, AudioSpecDesired};
|
||||||
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{io, thread};
|
|
||||||
|
|
||||||
pub enum SdlSink {
|
pub enum SdlSink {
|
||||||
F32(AudioQueue<f32>),
|
F32(AudioQueue<f32>),
|
||||||
|
@ -52,7 +52,7 @@ impl Open for SdlSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for SdlSink {
|
impl Sink for SdlSink {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
macro_rules! start_sink {
|
macro_rules! start_sink {
|
||||||
($queue: expr) => {{
|
($queue: expr) => {{
|
||||||
$queue.clear();
|
$queue.clear();
|
||||||
|
@ -67,7 +67,7 @@ impl Sink for SdlSink {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
macro_rules! stop_sink {
|
macro_rules! stop_sink {
|
||||||
($queue: expr) => {{
|
($queue: expr) => {{
|
||||||
$queue.pause();
|
$queue.pause();
|
||||||
|
@ -82,7 +82,7 @@ impl Sink for SdlSink {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
|
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
||||||
macro_rules! drain_sink {
|
macro_rules! drain_sink {
|
||||||
($queue: expr, $size: expr) => {{
|
($queue: expr, $size: expr) => {{
|
||||||
// sleep and wait for sdl thread to drain the queue a bit
|
// sleep and wait for sdl thread to drain the queue a bit
|
||||||
|
@ -92,7 +92,9 @@ impl Sink for SdlSink {
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
let samples = packet.samples();
|
let samples = packet
|
||||||
|
.samples()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
match self {
|
match self {
|
||||||
Self::F32(queue) => {
|
Self::F32(queue) => {
|
||||||
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
use super::{Open, Sink, SinkAsBytes};
|
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
|
||||||
use crate::config::AudioFormat;
|
use crate::config::AudioFormat;
|
||||||
use crate::convert::Converter;
|
use crate::convert::Converter;
|
||||||
use crate::decoder::AudioPacket;
|
use crate::decoder::AudioPacket;
|
||||||
use shell_words::split;
|
use shell_words::split;
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::Write;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
pub struct SubprocessSink {
|
pub struct SubprocessSink {
|
||||||
|
@ -30,21 +30,25 @@ impl Open for SubprocessSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sink for SubprocessSink {
|
impl Sink for SubprocessSink {
|
||||||
fn start(&mut self) -> io::Result<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
let args = split(&self.shell_command).unwrap();
|
let args = split(&self.shell_command).unwrap();
|
||||||
self.child = Some(
|
let child = Command::new(&args[0])
|
||||||
Command::new(&args[0])
|
|
||||||
.args(&args[1..])
|
.args(&args[1..])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.spawn()?,
|
.spawn()
|
||||||
);
|
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
|
||||||
|
self.child = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> io::Result<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
if let Some(child) = &mut self.child.take() {
|
if let Some(child) = &mut self.child.take() {
|
||||||
child.kill()?;
|
child
|
||||||
child.wait()?;
|
.kill()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
|
child
|
||||||
|
.wait()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -53,11 +57,18 @@ impl Sink for SubprocessSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SinkAsBytes for SubprocessSink {
|
impl SinkAsBytes for SubprocessSink {
|
||||||
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
|
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
|
||||||
if let Some(child) = &mut self.child {
|
if let Some(child) = &mut self.child {
|
||||||
let child_stdin = child.stdin.as_mut().unwrap();
|
let child_stdin = child
|
||||||
child_stdin.write_all(data)?;
|
.stdin
|
||||||
child_stdin.flush()?;
|
.as_mut()
|
||||||
|
.ok_or_else(|| SinkError::NotConnected("Child is None".to_string()))?;
|
||||||
|
child_stdin
|
||||||
|
.write_all(data)
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
|
child_stdin
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,10 +76,11 @@ impl AudioFormat {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum NormalisationType {
|
pub enum NormalisationType {
|
||||||
Album,
|
Album,
|
||||||
Track,
|
Track,
|
||||||
|
Auto,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for NormalisationType {
|
impl FromStr for NormalisationType {
|
||||||
|
@ -88,6 +89,7 @@ impl FromStr for NormalisationType {
|
||||||
match s.to_lowercase().as_ref() {
|
match s.to_lowercase().as_ref() {
|
||||||
"album" => Ok(Self::Album),
|
"album" => Ok(Self::Album),
|
||||||
"track" => Ok(Self::Track),
|
"track" => Ok(Self::Track),
|
||||||
|
"auto" => Ok(Self::Auto),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,11 +97,11 @@ impl FromStr for NormalisationType {
|
||||||
|
|
||||||
impl Default for NormalisationType {
|
impl Default for NormalisationType {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Album
|
Self::Auto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum NormalisationMethod {
|
pub enum NormalisationMethod {
|
||||||
Basic,
|
Basic,
|
||||||
Dynamic,
|
Dynamic,
|
||||||
|
@ -151,7 +153,7 @@ impl Default for PlayerConfig {
|
||||||
normalisation_type: NormalisationType::default(),
|
normalisation_type: NormalisationType::default(),
|
||||||
normalisation_method: NormalisationMethod::default(),
|
normalisation_method: NormalisationMethod::default(),
|
||||||
normalisation_pregain: 0.0,
|
normalisation_pregain: 0.0,
|
||||||
normalisation_threshold: db_to_ratio(-1.0),
|
normalisation_threshold: db_to_ratio(-2.0),
|
||||||
normalisation_attack: Duration::from_millis(5),
|
normalisation_attack: Duration::from_millis(5),
|
||||||
normalisation_release: Duration::from_millis(100),
|
normalisation_release: Duration::from_millis(100),
|
||||||
normalisation_knee: 1.0,
|
normalisation_knee: 1.0,
|
||||||
|
|
|
@ -1,22 +1,23 @@
|
||||||
use super::{AudioDecoder, AudioError, AudioPacket};
|
use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult};
|
||||||
|
|
||||||
|
use lewton::audio::AudioReadError::AudioIsHeader;
|
||||||
use lewton::inside_ogg::OggStreamReader;
|
use lewton::inside_ogg::OggStreamReader;
|
||||||
use lewton::samples::InterleavedSamples;
|
use lewton::samples::InterleavedSamples;
|
||||||
|
use lewton::OggReadError::NoCapturePatternFound;
|
||||||
|
use lewton::VorbisError::{BadAudio, OggError};
|
||||||
|
|
||||||
use std::error;
|
|
||||||
use std::fmt;
|
|
||||||
use std::io::{Read, Seek};
|
use std::io::{Read, Seek};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub struct VorbisDecoder<R: Read + Seek>(OggStreamReader<R>);
|
pub struct VorbisDecoder<R: Read + Seek>(OggStreamReader<R>);
|
||||||
pub struct VorbisError(lewton::VorbisError);
|
|
||||||
|
|
||||||
impl<R> VorbisDecoder<R>
|
impl<R> VorbisDecoder<R>
|
||||||
where
|
where
|
||||||
R: Read + Seek,
|
R: Read + Seek,
|
||||||
{
|
{
|
||||||
pub fn new(input: R) -> Result<VorbisDecoder<R>, VorbisError> {
|
pub fn new(input: R) -> DecoderResult<VorbisDecoder<R>> {
|
||||||
Ok(VorbisDecoder(OggStreamReader::new(input)?))
|
let reader =
|
||||||
|
OggStreamReader::new(input).map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?;
|
||||||
|
Ok(VorbisDecoder(reader))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,51 +25,22 @@ impl<R> AudioDecoder for VorbisDecoder<R>
|
||||||
where
|
where
|
||||||
R: Read + Seek,
|
R: Read + Seek,
|
||||||
{
|
{
|
||||||
fn seek(&mut self, ms: i64) -> Result<(), AudioError> {
|
fn seek(&mut self, absgp: u64) -> DecoderResult<()> {
|
||||||
let absgp = Duration::from_millis(ms as u64 * crate::SAMPLE_RATE as u64).as_secs();
|
self.0
|
||||||
match self.0.seek_absgp_pg(absgp as u64) {
|
.seek_absgp_pg(absgp)
|
||||||
Ok(_) => Ok(()),
|
.map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?;
|
||||||
Err(err) => Err(AudioError::VorbisError(err.into())),
|
Ok(())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_packet(&mut self) -> Result<Option<AudioPacket>, AudioError> {
|
fn next_packet(&mut self) -> DecoderResult<Option<AudioPacket>> {
|
||||||
use lewton::audio::AudioReadError::AudioIsHeader;
|
|
||||||
use lewton::OggReadError::NoCapturePatternFound;
|
|
||||||
use lewton::VorbisError::{BadAudio, OggError};
|
|
||||||
loop {
|
loop {
|
||||||
match self.0.read_dec_packet_generic::<InterleavedSamples<f32>>() {
|
match self.0.read_dec_packet_generic::<InterleavedSamples<f32>>() {
|
||||||
Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))),
|
Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))),
|
||||||
Ok(None) => return Ok(None),
|
Ok(None) => return Ok(None),
|
||||||
|
|
||||||
Err(BadAudio(AudioIsHeader)) => (),
|
Err(BadAudio(AudioIsHeader)) => (),
|
||||||
Err(OggError(NoCapturePatternFound)) => (),
|
Err(OggError(NoCapturePatternFound)) => (),
|
||||||
Err(err) => return Err(AudioError::VorbisError(err.into())),
|
Err(e) => return Err(DecoderError::LewtonDecoder(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<lewton::VorbisError> for VorbisError {
|
|
||||||
fn from(err: lewton::VorbisError) -> VorbisError {
|
|
||||||
VorbisError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for VorbisError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
fmt::Debug::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for VorbisError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
fmt::Display::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl error::Error for VorbisError {
|
|
||||||
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
|
|
||||||
error::Error::source(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,30 @@
|
||||||
use std::fmt;
|
use thiserror::Error;
|
||||||
|
|
||||||
mod lewton_decoder;
|
mod lewton_decoder;
|
||||||
pub use lewton_decoder::{VorbisDecoder, VorbisError};
|
pub use lewton_decoder::VorbisDecoder;
|
||||||
|
|
||||||
mod passthrough_decoder;
|
mod passthrough_decoder;
|
||||||
pub use passthrough_decoder::{PassthroughDecoder, PassthroughError};
|
pub use passthrough_decoder::PassthroughDecoder;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DecoderError {
|
||||||
|
#[error("Lewton Decoder Error: {0}")]
|
||||||
|
LewtonDecoder(String),
|
||||||
|
#[error("Passthrough Decoder Error: {0}")]
|
||||||
|
PassthroughDecoder(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DecoderResult<T> = Result<T, DecoderError>;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AudioPacketError {
|
||||||
|
#[error("Decoder OggData Error: Can't return OggData on Samples")]
|
||||||
|
OggData,
|
||||||
|
#[error("Decoder Samples Error: Can't return Samples on OggData")]
|
||||||
|
Samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AudioPacketResult<T> = Result<T, AudioPacketError>;
|
||||||
|
|
||||||
pub enum AudioPacket {
|
pub enum AudioPacket {
|
||||||
Samples(Vec<f64>),
|
Samples(Vec<f64>),
|
||||||
|
@ -17,17 +37,17 @@ impl AudioPacket {
|
||||||
AudioPacket::Samples(f64_samples)
|
AudioPacket::Samples(f64_samples)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn samples(&self) -> &[f64] {
|
pub fn samples(&self) -> AudioPacketResult<&[f64]> {
|
||||||
match self {
|
match self {
|
||||||
AudioPacket::Samples(s) => s,
|
AudioPacket::Samples(s) => Ok(s),
|
||||||
AudioPacket::OggData(_) => panic!("can't return OggData on samples"),
|
AudioPacket::OggData(_) => Err(AudioPacketError::OggData),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oggdata(&self) -> &[u8] {
|
pub fn oggdata(&self) -> AudioPacketResult<&[u8]> {
|
||||||
match self {
|
match self {
|
||||||
AudioPacket::Samples(_) => panic!("can't return samples on OggData"),
|
AudioPacket::OggData(d) => Ok(d),
|
||||||
AudioPacket::OggData(d) => d,
|
AudioPacket::Samples(_) => Err(AudioPacketError::Samples),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,34 +59,7 @@ impl AudioPacket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AudioError {
|
|
||||||
PassthroughError(PassthroughError),
|
|
||||||
VorbisError(VorbisError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for AudioError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
AudioError::PassthroughError(err) => write!(f, "PassthroughError({})", err),
|
|
||||||
AudioError::VorbisError(err) => write!(f, "VorbisError({})", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<VorbisError> for AudioError {
|
|
||||||
fn from(err: VorbisError) -> AudioError {
|
|
||||||
AudioError::VorbisError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PassthroughError> for AudioError {
|
|
||||||
fn from(err: PassthroughError) -> AudioError {
|
|
||||||
AudioError::PassthroughError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait AudioDecoder {
|
pub trait AudioDecoder {
|
||||||
fn seek(&mut self, ms: i64) -> Result<(), AudioError>;
|
fn seek(&mut self, absgp: u64) -> DecoderResult<()>;
|
||||||
fn next_packet(&mut self) -> Result<Option<AudioPacket>, AudioError>;
|
fn next_packet(&mut self) -> DecoderResult<Option<AudioPacket>>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,22 @@
|
||||||
// Passthrough decoder for librespot
|
// Passthrough decoder for librespot
|
||||||
use super::{AudioDecoder, AudioError, AudioPacket};
|
use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult};
|
||||||
use crate::SAMPLE_RATE;
|
|
||||||
use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter};
|
use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter};
|
||||||
use std::fmt;
|
|
||||||
use std::io::{Read, Seek};
|
use std::io::{Read, Seek};
|
||||||
use std::time::Duration;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
fn get_header<T>(code: u8, rdr: &mut PacketReader<T>) -> Result<Box<[u8]>, PassthroughError>
|
fn get_header<T>(code: u8, rdr: &mut PacketReader<T>) -> DecoderResult<Box<[u8]>>
|
||||||
where
|
where
|
||||||
T: Read + Seek,
|
T: Read + Seek,
|
||||||
{
|
{
|
||||||
let pck: Packet = rdr.read_packet_expected()?;
|
let pck: Packet = rdr
|
||||||
|
.read_packet_expected()
|
||||||
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
|
|
||||||
let pkt_type = pck.data[0];
|
let pkt_type = pck.data[0];
|
||||||
debug!("Vorbis header type {}", &pkt_type);
|
debug!("Vorbis header type {}", &pkt_type);
|
||||||
|
|
||||||
if pkt_type != code {
|
if pkt_type != code {
|
||||||
return Err(PassthroughError(OggReadError::InvalidData));
|
return Err(DecoderError::PassthroughDecoder("Invalid Data".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(pck.data.into_boxed_slice())
|
Ok(pck.data.into_boxed_slice())
|
||||||
|
@ -35,16 +34,14 @@ pub struct PassthroughDecoder<R: Read + Seek> {
|
||||||
setup: Box<[u8]>,
|
setup: Box<[u8]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PassthroughError(ogg::OggReadError);
|
|
||||||
|
|
||||||
impl<R: Read + Seek> PassthroughDecoder<R> {
|
impl<R: Read + Seek> PassthroughDecoder<R> {
|
||||||
/// Constructs a new Decoder from a given implementation of `Read + Seek`.
|
/// Constructs a new Decoder from a given implementation of `Read + Seek`.
|
||||||
pub fn new(rdr: R) -> Result<Self, PassthroughError> {
|
pub fn new(rdr: R) -> DecoderResult<Self> {
|
||||||
let mut rdr = PacketReader::new(rdr);
|
let mut rdr = PacketReader::new(rdr);
|
||||||
let stream_serial = SystemTime::now()
|
let since_epoch = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
.as_millis() as u32;
|
let stream_serial = since_epoch.as_millis() as u32;
|
||||||
|
|
||||||
info!("Starting passthrough track with serial {}", stream_serial);
|
info!("Starting passthrough track with serial {}", stream_serial);
|
||||||
|
|
||||||
|
@ -71,9 +68,7 @@ impl<R: Read + Seek> PassthroughDecoder<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
fn seek(&mut self, ms: i64) -> Result<(), AudioError> {
|
fn seek(&mut self, absgp: u64) -> DecoderResult<()> {
|
||||||
info!("Seeking to {}", ms);
|
|
||||||
|
|
||||||
// add an eos to previous stream if missing
|
// add an eos to previous stream if missing
|
||||||
if self.bos && !self.eos {
|
if self.bos && !self.eos {
|
||||||
match self.rdr.read_packet() {
|
match self.rdr.read_packet() {
|
||||||
|
@ -86,7 +81,7 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
PacketWriteEndInfo::EndStream,
|
PacketWriteEndInfo::EndStream,
|
||||||
absgp_page,
|
absgp_page,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
}
|
}
|
||||||
_ => warn! {"Cannot write EoS after seeking"},
|
_ => warn! {"Cannot write EoS after seeking"},
|
||||||
};
|
};
|
||||||
|
@ -97,23 +92,29 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
self.ofsgp_page = 0;
|
self.ofsgp_page = 0;
|
||||||
self.stream_serial += 1;
|
self.stream_serial += 1;
|
||||||
|
|
||||||
// hard-coded to 44.1 kHz
|
match self.rdr.seek_absgp(None, absgp) {
|
||||||
match self.rdr.seek_absgp(
|
|
||||||
None,
|
|
||||||
Duration::from_millis(ms as u64 * SAMPLE_RATE as u64).as_secs(),
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// need to set some offset for next_page()
|
// need to set some offset for next_page()
|
||||||
let pck = self.rdr.read_packet().unwrap().unwrap();
|
let pck = self
|
||||||
|
.rdr
|
||||||
|
.read_packet()
|
||||||
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
|
match pck {
|
||||||
|
Some(pck) => {
|
||||||
self.ofsgp_page = pck.absgp_page();
|
self.ofsgp_page = pck.absgp_page();
|
||||||
debug!("Seek to offset page {}", self.ofsgp_page);
|
debug!("Seek to offset page {}", self.ofsgp_page);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => Err(AudioError::PassthroughError(err.into())),
|
None => Err(DecoderError::PassthroughDecoder(
|
||||||
|
"Packet is None".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_packet(&mut self) -> Result<Option<AudioPacket>, AudioError> {
|
fn next_packet(&mut self) -> DecoderResult<Option<AudioPacket>> {
|
||||||
// write headers if we are (re)starting
|
// write headers if we are (re)starting
|
||||||
if !self.bos {
|
if !self.bos {
|
||||||
self.wtr
|
self.wtr
|
||||||
|
@ -123,7 +124,7 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
PacketWriteEndInfo::EndPage,
|
PacketWriteEndInfo::EndPage,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
self.wtr
|
self.wtr
|
||||||
.write_packet(
|
.write_packet(
|
||||||
self.comment.clone(),
|
self.comment.clone(),
|
||||||
|
@ -131,7 +132,7 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
PacketWriteEndInfo::NormalPacket,
|
PacketWriteEndInfo::NormalPacket,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
self.wtr
|
self.wtr
|
||||||
.write_packet(
|
.write_packet(
|
||||||
self.setup.clone(),
|
self.setup.clone(),
|
||||||
|
@ -139,7 +140,7 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
PacketWriteEndInfo::EndPage,
|
PacketWriteEndInfo::EndPage,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
self.bos = true;
|
self.bos = true;
|
||||||
debug!("Wrote Ogg headers");
|
debug!("Wrote Ogg headers");
|
||||||
}
|
}
|
||||||
|
@ -151,7 +152,7 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
info!("end of streaming");
|
info!("end of streaming");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(err) => return Err(AudioError::PassthroughError(err.into())),
|
Err(e) => return Err(DecoderError::PassthroughDecoder(e.to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let pckgp_page = pck.absgp_page();
|
let pckgp_page = pck.absgp_page();
|
||||||
|
@ -178,32 +179,14 @@ impl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {
|
||||||
inf,
|
inf,
|
||||||
pckgp_page - self.ofsgp_page,
|
pckgp_page - self.ofsgp_page,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;
|
||||||
|
|
||||||
let data = self.wtr.inner_mut();
|
let data = self.wtr.inner_mut();
|
||||||
|
|
||||||
if !data.is_empty() {
|
if !data.is_empty() {
|
||||||
let result = AudioPacket::OggData(std::mem::take(data));
|
let ogg_data = AudioPacket::OggData(std::mem::take(data));
|
||||||
return Ok(Some(result));
|
return Ok(Some(ogg_data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for PassthroughError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
fmt::Debug::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ogg::OggReadError> for PassthroughError {
|
|
||||||
fn from(err: OggReadError) -> PassthroughError {
|
|
||||||
PassthroughError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PassthroughError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
fmt::Display::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
use rand::rngs::ThreadRng;
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::SeedableRng;
|
||||||
use rand_distr::{Distribution, Normal, Triangular, Uniform};
|
use rand_distr::{Distribution, Normal, Triangular, Uniform};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
@ -41,20 +42,19 @@ impl fmt::Display for dyn Ditherer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementation note: we save the handle to ThreadRng so it doesn't require
|
fn create_rng() -> SmallRng {
|
||||||
// a lookup on each call (which is on each sample!). This is ~2.5x as fast.
|
SmallRng::from_entropy()
|
||||||
// Downside is that it is not Send so we cannot move it around player threads.
|
}
|
||||||
//
|
|
||||||
|
|
||||||
pub struct TriangularDitherer {
|
pub struct TriangularDitherer {
|
||||||
cached_rng: ThreadRng,
|
cached_rng: SmallRng,
|
||||||
distribution: Triangular<f64>,
|
distribution: Triangular<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ditherer for TriangularDitherer {
|
impl Ditherer for TriangularDitherer {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cached_rng: rand::thread_rng(),
|
cached_rng: create_rng(),
|
||||||
// 2 LSB peak-to-peak needed to linearize the response:
|
// 2 LSB peak-to-peak needed to linearize the response:
|
||||||
distribution: Triangular::new(-1.0, 1.0, 0.0).unwrap(),
|
distribution: Triangular::new(-1.0, 1.0, 0.0).unwrap(),
|
||||||
}
|
}
|
||||||
|
@ -74,14 +74,14 @@ impl TriangularDitherer {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GaussianDitherer {
|
pub struct GaussianDitherer {
|
||||||
cached_rng: ThreadRng,
|
cached_rng: SmallRng,
|
||||||
distribution: Normal<f64>,
|
distribution: Normal<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ditherer for GaussianDitherer {
|
impl Ditherer for GaussianDitherer {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cached_rng: rand::thread_rng(),
|
cached_rng: create_rng(),
|
||||||
// 1/2 LSB RMS needed to linearize the response:
|
// 1/2 LSB RMS needed to linearize the response:
|
||||||
distribution: Normal::new(0.0, 0.5).unwrap(),
|
distribution: Normal::new(0.0, 0.5).unwrap(),
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ impl GaussianDitherer {
|
||||||
pub struct HighPassDitherer {
|
pub struct HighPassDitherer {
|
||||||
active_channel: usize,
|
active_channel: usize,
|
||||||
previous_noises: [f64; NUM_CHANNELS],
|
previous_noises: [f64; NUM_CHANNELS],
|
||||||
cached_rng: ThreadRng,
|
cached_rng: SmallRng,
|
||||||
distribution: Uniform<f64>,
|
distribution: Uniform<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +112,7 @@ impl Ditherer for HighPassDitherer {
|
||||||
Self {
|
Self {
|
||||||
active_channel: 0,
|
active_channel: 0,
|
||||||
previous_noises: [0.0; NUM_CHANNELS],
|
previous_noises: [0.0; NUM_CHANNELS],
|
||||||
cached_rng: rand::thread_rng(),
|
cached_rng: create_rng(),
|
||||||
distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB
|
distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,8 @@ use librespot_metadata as metadata;
|
||||||
|
|
||||||
pub mod audio_backend;
|
pub mod audio_backend;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
mod convert;
|
pub mod convert;
|
||||||
mod decoder;
|
pub mod decoder;
|
||||||
pub mod dither;
|
pub mod dither;
|
||||||
pub mod mixer;
|
pub mod mixer;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
@ -16,3 +16,5 @@ pub mod player;
|
||||||
pub const SAMPLE_RATE: u32 = 44100;
|
pub const SAMPLE_RATE: u32 = 44100;
|
||||||
pub const NUM_CHANNELS: u8 = 2;
|
pub const NUM_CHANNELS: u8 = 2;
|
||||||
pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32;
|
pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32;
|
||||||
|
pub const PAGES_PER_MS: f64 = SAMPLE_RATE as f64 / 1000.0;
|
||||||
|
pub const MS_PER_PAGE: f64 = 1000.0 / SAMPLE_RATE as f64;
|
||||||
|
|
|
@ -10,6 +10,7 @@ use alsa::{Ctl, Round};
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct AlsaMixer {
|
pub struct AlsaMixer {
|
||||||
config: MixerConfig,
|
config: MixerConfig,
|
||||||
min: i64,
|
min: i64,
|
||||||
|
@ -31,14 +32,14 @@ const ZERO_DB: MilliBel = MilliBel(0);
|
||||||
impl Mixer for AlsaMixer {
|
impl Mixer for AlsaMixer {
|
||||||
fn open(config: MixerConfig) -> Self {
|
fn open(config: MixerConfig) -> Self {
|
||||||
info!(
|
info!(
|
||||||
"Mixing with alsa and volume control: {:?} for card: {} with mixer control: {},{}",
|
"Mixing with Alsa and volume control: {:?} for device: {} with mixer control: {},{}",
|
||||||
config.volume_ctrl, config.card, config.control, config.index,
|
config.volume_ctrl, config.device, config.control, config.index,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut config = config; // clone
|
let mut config = config; // clone
|
||||||
|
|
||||||
let mixer =
|
let mixer =
|
||||||
alsa::mixer::Mixer::new(&config.card, false).expect("Could not open Alsa mixer");
|
alsa::mixer::Mixer::new(&config.device, false).expect("Could not open Alsa mixer");
|
||||||
let simple_element = mixer
|
let simple_element = mixer
|
||||||
.find_selem(&SelemId::new(&config.control, config.index))
|
.find_selem(&SelemId::new(&config.control, config.index))
|
||||||
.expect("Could not find Alsa mixer control");
|
.expect("Could not find Alsa mixer control");
|
||||||
|
@ -56,8 +57,8 @@ impl Mixer for AlsaMixer {
|
||||||
// Query dB volume range -- note that Alsa exposes a different
|
// Query dB volume range -- note that Alsa exposes a different
|
||||||
// API for hardware and software mixers
|
// API for hardware and software mixers
|
||||||
let (min_millibel, max_millibel) = if is_softvol {
|
let (min_millibel, max_millibel) = if is_softvol {
|
||||||
let control =
|
let control = Ctl::new(&config.device, false)
|
||||||
Ctl::new(&config.card, false).expect("Could not open Alsa softvol with that card");
|
.expect("Could not open Alsa softvol with that device");
|
||||||
let mut element_id = ElemId::new(ElemIface::Mixer);
|
let mut element_id = ElemId::new(ElemIface::Mixer);
|
||||||
element_id.set_name(
|
element_id.set_name(
|
||||||
&CString::new(config.control.as_str())
|
&CString::new(config.control.as_str())
|
||||||
|
@ -144,7 +145,7 @@ impl Mixer for AlsaMixer {
|
||||||
|
|
||||||
fn volume(&self) -> u16 {
|
fn volume(&self) -> u16 {
|
||||||
let mixer =
|
let mixer =
|
||||||
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer");
|
||||||
let simple_element = mixer
|
let simple_element = mixer
|
||||||
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
.expect("Could not find Alsa mixer control");
|
.expect("Could not find Alsa mixer control");
|
||||||
|
@ -184,7 +185,7 @@ impl Mixer for AlsaMixer {
|
||||||
|
|
||||||
fn set_volume(&self, volume: u16) {
|
fn set_volume(&self, volume: u16) {
|
||||||
let mixer =
|
let mixer =
|
||||||
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer");
|
||||||
let simple_element = mixer
|
let simple_element = mixer
|
||||||
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
.expect("Could not find Alsa mixer control");
|
.expect("Could not find Alsa mixer control");
|
||||||
|
@ -249,7 +250,7 @@ impl AlsaMixer {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mixer =
|
let mixer =
|
||||||
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
alsa::mixer::Mixer::new(&self.config.device, false).expect("Could not open Alsa mixer");
|
||||||
let simple_element = mixer
|
let simple_element = mixer
|
||||||
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
.expect("Could not find Alsa mixer control");
|
.expect("Could not find Alsa mixer control");
|
||||||
|
|
|
@ -30,7 +30,7 @@ use self::alsamixer::AlsaMixer;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MixerConfig {
|
pub struct MixerConfig {
|
||||||
pub card: String,
|
pub device: String,
|
||||||
pub control: String,
|
pub control: String,
|
||||||
pub index: u32,
|
pub index: u32,
|
||||||
pub volume_ctrl: VolumeCtrl,
|
pub volume_ctrl: VolumeCtrl,
|
||||||
|
@ -39,7 +39,7 @@ pub struct MixerConfig {
|
||||||
impl Default for MixerConfig {
|
impl Default for MixerConfig {
|
||||||
fn default() -> MixerConfig {
|
fn default() -> MixerConfig {
|
||||||
MixerConfig {
|
MixerConfig {
|
||||||
card: String::from("default"),
|
device: String::from("default"),
|
||||||
control: String::from("PCM"),
|
control: String::from("PCM"),
|
||||||
index: 0,
|
index: 0,
|
||||||
volume_ctrl: VolumeCtrl::default(),
|
volume_ctrl: VolumeCtrl::default(),
|
||||||
|
@ -53,11 +53,19 @@ fn mk_sink<M: Mixer + 'static>(config: MixerConfig) -> Box<dyn Mixer> {
|
||||||
Box::new(M::open(config))
|
Box::new(M::open(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find(name: Option<&str>) -> Option<MixerFn> {
|
pub const MIXERS: &[(&str, MixerFn)] = &[
|
||||||
match name {
|
(SoftMixer::NAME, mk_sink::<SoftMixer>), // default goes first
|
||||||
None | Some(SoftMixer::NAME) => Some(mk_sink::<SoftMixer>),
|
|
||||||
#[cfg(feature = "alsa-backend")]
|
#[cfg(feature = "alsa-backend")]
|
||||||
Some(AlsaMixer::NAME) => Some(mk_sink::<AlsaMixer>),
|
(AlsaMixer::NAME, mk_sink::<AlsaMixer>),
|
||||||
_ => None,
|
];
|
||||||
|
|
||||||
|
pub fn find(name: Option<&str>) -> Option<MixerFn> {
|
||||||
|
if let Some(name) = name {
|
||||||
|
MIXERS
|
||||||
|
.iter()
|
||||||
|
.find(|mixer| name == mixer.0)
|
||||||
|
.map(|mixer| mixer.1)
|
||||||
|
} else {
|
||||||
|
MIXERS.first().map(|mixer| mixer.1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@ impl Mixer for SoftMixer {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoftMixer {
|
impl SoftMixer {
|
||||||
pub const NAME: &'static str = "softmixer";
|
pub const NAME: &'static str = "softvol";
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SoftVolumeApplier {
|
struct SoftVolumeApplier {
|
||||||
|
|
|
@ -23,11 +23,11 @@ use crate::convert::Converter;
|
||||||
use crate::core::session::Session;
|
use crate::core::session::Session;
|
||||||
use crate::core::spotify_id::SpotifyId;
|
use crate::core::spotify_id::SpotifyId;
|
||||||
use crate::core::util::SeqGenerator;
|
use crate::core::util::SeqGenerator;
|
||||||
use crate::decoder::{AudioDecoder, AudioError, AudioPacket, PassthroughDecoder, VorbisDecoder};
|
use crate::decoder::{AudioDecoder, AudioPacket, DecoderError, PassthroughDecoder, VorbisDecoder};
|
||||||
use crate::metadata::audio::{AudioFileFormat, AudioItem};
|
use crate::metadata::audio::{AudioFileFormat, AudioItem};
|
||||||
use crate::mixer::AudioFilter;
|
use crate::mixer::AudioFilter;
|
||||||
|
|
||||||
use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND};
|
use crate::{MS_PER_PAGE, NUM_CHANNELS, PAGES_PER_MS, SAMPLES_PER_SECOND};
|
||||||
|
|
||||||
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
||||||
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
|
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
|
||||||
|
@ -67,6 +67,8 @@ struct PlayerInternal {
|
||||||
limiter_peak_sample: f64,
|
limiter_peak_sample: f64,
|
||||||
limiter_factor: f64,
|
limiter_factor: f64,
|
||||||
limiter_strength: f64,
|
limiter_strength: f64,
|
||||||
|
|
||||||
|
auto_normalise_as_album: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PlayerCommand {
|
enum PlayerCommand {
|
||||||
|
@ -86,6 +88,7 @@ enum PlayerCommand {
|
||||||
AddEventSender(mpsc::UnboundedSender<PlayerEvent>),
|
AddEventSender(mpsc::UnboundedSender<PlayerEvent>),
|
||||||
SetSinkEventCallback(Option<SinkEventCallback>),
|
SetSinkEventCallback(Option<SinkEventCallback>),
|
||||||
EmitVolumeSetEvent(u16),
|
EmitVolumeSetEvent(u16),
|
||||||
|
SetAutoNormaliseAsAlbum(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -238,9 +241,10 @@ impl NormalisationData {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let [gain_db, gain_peak] = match config.normalisation_type {
|
let [gain_db, gain_peak] = if config.normalisation_type == NormalisationType::Album {
|
||||||
NormalisationType::Album => [data.album_gain_db, data.album_peak],
|
[data.album_gain_db, data.album_peak]
|
||||||
NormalisationType::Track => [data.track_gain_db, data.track_peak],
|
} else {
|
||||||
|
[data.track_gain_db, data.track_peak]
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalisation_power = gain_db as f64 + config.normalisation_pregain;
|
let normalisation_power = gain_db as f64 + config.normalisation_pregain;
|
||||||
|
@ -264,7 +268,11 @@ impl NormalisationData {
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Normalisation Data: {:?}", data);
|
debug!("Normalisation Data: {:?}", data);
|
||||||
debug!("Normalisation Factor: {:.2}%", normalisation_factor * 100.0);
|
debug!(
|
||||||
|
"Calculated Normalisation Factor for {:?}: {:.2}%",
|
||||||
|
config.normalisation_type,
|
||||||
|
normalisation_factor * 100.0
|
||||||
|
);
|
||||||
|
|
||||||
normalisation_factor as f64
|
normalisation_factor as f64
|
||||||
}
|
}
|
||||||
|
@ -327,6 +335,8 @@ impl Player {
|
||||||
limiter_peak_sample: 0.0,
|
limiter_peak_sample: 0.0,
|
||||||
limiter_factor: 1.0,
|
limiter_factor: 1.0,
|
||||||
limiter_strength: 0.0,
|
limiter_strength: 0.0,
|
||||||
|
|
||||||
|
auto_normalise_as_album: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// While PlayerInternal is written as a future, it still contains blocking code.
|
// While PlayerInternal is written as a future, it still contains blocking code.
|
||||||
|
@ -350,7 +360,11 @@ impl Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command(&self, cmd: PlayerCommand) {
|
fn command(&self, cmd: PlayerCommand) {
|
||||||
self.commands.as_ref().unwrap().send(cmd).unwrap();
|
if let Some(commands) = self.commands.as_ref() {
|
||||||
|
if let Err(e) = commands.send(cmd) {
|
||||||
|
error!("Player Commands Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self, track_id: SpotifyId, start_playing: bool, position_ms: u32) -> u64 {
|
pub fn load(&mut self, track_id: SpotifyId, start_playing: bool, position_ms: u32) -> u64 {
|
||||||
|
@ -410,6 +424,10 @@ impl Player {
|
||||||
pub fn emit_volume_set_event(&self, volume: u16) {
|
pub fn emit_volume_set_event(&self, volume: u16) {
|
||||||
self.command(PlayerCommand::EmitVolumeSetEvent(volume));
|
self.command(PlayerCommand::EmitVolumeSetEvent(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_auto_normalise_as_album(&self, setting: bool) {
|
||||||
|
self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Player {
|
impl Drop for Player {
|
||||||
|
@ -419,7 +437,7 @@ impl Drop for Player {
|
||||||
if let Some(handle) = self.thread_handle.take() {
|
if let Some(handle) = self.thread_handle.take() {
|
||||||
match handle.join() {
|
match handle.join() {
|
||||||
Ok(_) => (),
|
Ok(_) => (),
|
||||||
Err(_) => error!("Player thread panicked!"),
|
Err(e) => error!("Player thread Error: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -427,7 +445,7 @@ impl Drop for Player {
|
||||||
|
|
||||||
struct PlayerLoadedTrackData {
|
struct PlayerLoadedTrackData {
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
normalisation_factor: f64,
|
normalisation_data: NormalisationData,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
bytes_per_second: usize,
|
bytes_per_second: usize,
|
||||||
duration_ms: u32,
|
duration_ms: u32,
|
||||||
|
@ -460,6 +478,7 @@ enum PlayerState {
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
|
normalisation_data: NormalisationData,
|
||||||
normalisation_factor: f64,
|
normalisation_factor: f64,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
bytes_per_second: usize,
|
bytes_per_second: usize,
|
||||||
|
@ -471,6 +490,7 @@ enum PlayerState {
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
|
normalisation_data: NormalisationData,
|
||||||
normalisation_factor: f64,
|
normalisation_factor: f64,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
bytes_per_second: usize,
|
bytes_per_second: usize,
|
||||||
|
@ -493,7 +513,10 @@ impl PlayerState {
|
||||||
match *self {
|
match *self {
|
||||||
Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false,
|
Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false,
|
||||||
Playing { .. } => true,
|
Playing { .. } => true,
|
||||||
Invalid => panic!("invalid state"),
|
Invalid => {
|
||||||
|
error!("PlayerState is_playing: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -518,7 +541,10 @@ impl PlayerState {
|
||||||
| Playing {
|
| Playing {
|
||||||
ref mut decoder, ..
|
ref mut decoder, ..
|
||||||
} => Some(decoder),
|
} => Some(decoder),
|
||||||
Invalid => panic!("invalid state"),
|
Invalid => {
|
||||||
|
error!("PlayerState decoder: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -534,7 +560,10 @@ impl PlayerState {
|
||||||
ref mut stream_loader_controller,
|
ref mut stream_loader_controller,
|
||||||
..
|
..
|
||||||
} => Some(stream_loader_controller),
|
} => Some(stream_loader_controller),
|
||||||
Invalid => panic!("invalid state"),
|
Invalid => {
|
||||||
|
error!("PlayerState stream_loader_controller: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -547,7 +576,7 @@ impl PlayerState {
|
||||||
decoder,
|
decoder,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
stream_position_pcm,
|
stream_position_pcm,
|
||||||
..
|
..
|
||||||
|
@ -557,7 +586,7 @@ impl PlayerState {
|
||||||
play_request_id,
|
play_request_id,
|
||||||
loaded_track: PlayerLoadedTrackData {
|
loaded_track: PlayerLoadedTrackData {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -565,7 +594,10 @@ impl PlayerState {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
_ => panic!("Called playing_to_end_of_track in non-playing state."),
|
_ => {
|
||||||
|
error!("Called playing_to_end_of_track in non-playing state.");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -576,6 +608,7 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -587,6 +620,7 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -596,7 +630,10 @@ impl PlayerState {
|
||||||
suggested_to_preload_next_track,
|
suggested_to_preload_next_track,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
_ => panic!("invalid state"),
|
_ => {
|
||||||
|
error!("PlayerState paused_to_playing: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -607,6 +644,7 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -619,6 +657,7 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -627,7 +666,10 @@ impl PlayerState {
|
||||||
suggested_to_preload_next_track,
|
suggested_to_preload_next_track,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
_ => panic!("invalid state"),
|
_ => {
|
||||||
|
error!("PlayerState playing_to_paused: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -680,8 +722,8 @@ impl PlayerTrackLoader {
|
||||||
) -> Option<PlayerLoadedTrackData> {
|
) -> Option<PlayerLoadedTrackData> {
|
||||||
let audio = match AudioItem::get_file(&self.session, spotify_id).await {
|
let audio = match AudioItem::get_file(&self.session, spotify_id).await {
|
||||||
Ok(audio) => audio,
|
Ok(audio) => audio,
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
error!("Unable to load audio item.");
|
error!("Unable to load audio item: {:?}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -753,8 +795,8 @@ impl PlayerTrackLoader {
|
||||||
|
|
||||||
let encrypted_file = match encrypted_file.await {
|
let encrypted_file = match encrypted_file.await {
|
||||||
Ok(encrypted_file) => encrypted_file,
|
Ok(encrypted_file) => encrypted_file,
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
error!("Unable to load encrypted file.");
|
error!("Unable to load encrypted file: {:?}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -772,22 +814,24 @@ impl PlayerTrackLoader {
|
||||||
|
|
||||||
let key = match self.session.audio_key().request(spotify_id, file_id).await {
|
let key = match self.session.audio_key().request(spotify_id, file_id).await {
|
||||||
Ok(key) => key,
|
Ok(key) => key,
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
error!("Unable to load decryption key");
|
error!("Unable to load decryption key: {:?}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut decrypted_file = AudioDecrypt::new(key, encrypted_file);
|
let mut decrypted_file = AudioDecrypt::new(key, encrypted_file);
|
||||||
|
|
||||||
let normalisation_factor = match NormalisationData::parse_from_file(&mut decrypted_file)
|
let normalisation_data = match NormalisationData::parse_from_file(&mut decrypted_file) {
|
||||||
{
|
Ok(data) => data,
|
||||||
Ok(normalisation_data) => {
|
|
||||||
NormalisationData::get_factor(&self.config, normalisation_data)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warn!("Unable to extract normalisation data, using default value.");
|
warn!("Unable to extract normalisation data, using default value.");
|
||||||
1.0
|
NormalisationData {
|
||||||
|
track_gain_db: 0.0,
|
||||||
|
track_peak: 1.0,
|
||||||
|
album_gain_db: 0.0,
|
||||||
|
album_peak: 1.0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -796,12 +840,12 @@ impl PlayerTrackLoader {
|
||||||
let result = if self.config.passthrough {
|
let result = if self.config.passthrough {
|
||||||
match PassthroughDecoder::new(audio_file) {
|
match PassthroughDecoder::new(audio_file) {
|
||||||
Ok(result) => Ok(Box::new(result) as Decoder),
|
Ok(result) => Ok(Box::new(result) as Decoder),
|
||||||
Err(e) => Err(AudioError::PassthroughError(e)),
|
Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match VorbisDecoder::new(audio_file) {
|
match VorbisDecoder::new(audio_file) {
|
||||||
Ok(result) => Ok(Box::new(result) as Decoder),
|
Ok(result) => Ok(Box::new(result) as Decoder),
|
||||||
Err(e) => Err(AudioError::VorbisError(e)),
|
Err(e) => Err(DecoderError::LewtonDecoder(e.to_string())),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -813,15 +857,18 @@ impl PlayerTrackLoader {
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
|
|
||||||
if self
|
match self.session.cache() {
|
||||||
.session
|
Some(cache) => {
|
||||||
.cache()
|
if cache.remove_file(file_id).is_err() {
|
||||||
.expect("If the audio file is cached, a cache should exist")
|
error!("Error removing file from cache");
|
||||||
.remove_file(file_id)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!("If the audio file is cached, a cache should exist");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Just try it again
|
// Just try it again
|
||||||
continue;
|
continue;
|
||||||
|
@ -832,18 +879,20 @@ impl PlayerTrackLoader {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if position_ms != 0 {
|
let position_pcm = PlayerInternal::position_ms_to_pcm(position_ms);
|
||||||
if let Err(err) = decoder.seek(position_ms as i64) {
|
|
||||||
error!("Vorbis error: {}", err);
|
if position_pcm != 0 {
|
||||||
|
if let Err(e) = decoder.seek(position_pcm) {
|
||||||
|
error!("PlayerTrackLoader load_track: {}", e);
|
||||||
}
|
}
|
||||||
stream_loader_controller.set_stream_mode();
|
stream_loader_controller.set_stream_mode();
|
||||||
}
|
}
|
||||||
let stream_position_pcm = PlayerInternal::position_ms_to_pcm(position_ms);
|
let stream_position_pcm = position_pcm;
|
||||||
info!("<{}> ({} ms) loaded", audio.name, audio.duration);
|
info!("<{}> ({} ms) loaded", audio.name, audio.duration);
|
||||||
|
|
||||||
return Some(PlayerLoadedTrackData {
|
return Some(PlayerLoadedTrackData {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -895,7 +944,8 @@ impl Future for PlayerInternal {
|
||||||
start_playback,
|
start_playback,
|
||||||
);
|
);
|
||||||
if let PlayerState::Loading { .. } = self.state {
|
if let PlayerState::Loading { .. } = self.state {
|
||||||
panic!("The state wasn't changed by start_playback()");
|
error!("The state wasn't changed by start_playback()");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Poll::Ready(Err(_)) => {
|
Poll::Ready(Err(_)) => {
|
||||||
|
@ -959,30 +1009,37 @@ impl Future for PlayerInternal {
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
let packet = decoder.next_packet().expect("Vorbis error");
|
match decoder.next_packet() {
|
||||||
|
Ok(packet) => {
|
||||||
if !passthrough {
|
if !passthrough {
|
||||||
if let Some(ref packet) = packet {
|
if let Some(ref packet) = packet {
|
||||||
|
match packet.samples() {
|
||||||
|
Ok(samples) => {
|
||||||
*stream_position_pcm +=
|
*stream_position_pcm +=
|
||||||
(packet.samples().len() / NUM_CHANNELS as usize) as u64;
|
(samples.len() / NUM_CHANNELS as usize) as u64;
|
||||||
let stream_position_millis =
|
let stream_position_millis =
|
||||||
Self::position_pcm_to_ms(*stream_position_pcm);
|
Self::position_pcm_to_ms(*stream_position_pcm);
|
||||||
|
|
||||||
let notify_about_position = match *reported_nominal_start_time {
|
let notify_about_position =
|
||||||
|
match *reported_nominal_start_time {
|
||||||
None => true,
|
None => true,
|
||||||
Some(reported_nominal_start_time) => {
|
Some(reported_nominal_start_time) => {
|
||||||
// only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we're actually in time.
|
// only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we're actually in time.
|
||||||
let lag = (Instant::now() - reported_nominal_start_time)
|
let lag = (Instant::now()
|
||||||
|
- reported_nominal_start_time)
|
||||||
.as_millis()
|
.as_millis()
|
||||||
as i64
|
as i64
|
||||||
- stream_position_millis as i64;
|
- stream_position_millis as i64;
|
||||||
lag > Duration::from_secs(1).as_millis() as i64
|
lag > Duration::from_secs(1).as_millis()
|
||||||
|
as i64
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if notify_about_position {
|
if notify_about_position {
|
||||||
*reported_nominal_start_time = Some(
|
*reported_nominal_start_time = Some(
|
||||||
Instant::now()
|
Instant::now()
|
||||||
- Duration::from_millis(stream_position_millis as u64),
|
- Duration::from_millis(
|
||||||
|
stream_position_millis as u64,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
self.send_event(PlayerEvent::Playing {
|
self.send_event(PlayerEvent::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
|
@ -992,14 +1049,27 @@ impl Future for PlayerInternal {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("PlayerInternal poll: {}", e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// position, even if irrelevant, must be set so that seek() is called
|
// position, even if irrelevant, must be set so that seek() is called
|
||||||
*stream_position_pcm = duration_ms.into();
|
*stream_position_pcm = duration_ms.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.handle_packet(packet, normalisation_factor);
|
self.handle_packet(packet, normalisation_factor);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("PlayerInternal poll: {}", e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
error!("PlayerInternal poll: Invalid PlayerState");
|
||||||
|
exit(1);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1048,11 +1118,11 @@ impl Future for PlayerInternal {
|
||||||
|
|
||||||
impl PlayerInternal {
|
impl PlayerInternal {
|
||||||
fn position_pcm_to_ms(position_pcm: u64) -> u32 {
|
fn position_pcm_to_ms(position_pcm: u64) -> u32 {
|
||||||
(position_pcm * 10 / 441) as u32
|
(position_pcm as f64 * MS_PER_PAGE) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
fn position_ms_to_pcm(position_ms: u32) -> u64 {
|
fn position_ms_to_pcm(position_ms: u32) -> u64 {
|
||||||
position_ms as u64 * 441 / 10
|
(position_ms as f64 * PAGES_PER_MS) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_sink_running(&mut self) {
|
fn ensure_sink_running(&mut self) {
|
||||||
|
@ -1063,8 +1133,8 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
match self.sink.start() {
|
match self.sink.start() {
|
||||||
Ok(()) => self.sink_status = SinkStatus::Running,
|
Ok(()) => self.sink_status = SinkStatus::Running,
|
||||||
Err(err) => {
|
Err(e) => {
|
||||||
error!("Fatal error, could not start audio sink: {}", err);
|
error!("{}", e);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1086,8 +1156,8 @@ impl PlayerInternal {
|
||||||
callback(self.sink_status);
|
callback(self.sink_status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(e) => {
|
||||||
error!("Fatal error, could not stop audio sink: {}", err);
|
error!("{}", e);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1134,7 +1204,10 @@ impl PlayerInternal {
|
||||||
self.state = PlayerState::Stopped;
|
self.state = PlayerState::Stopped;
|
||||||
}
|
}
|
||||||
PlayerState::Stopped => (),
|
PlayerState::Stopped => (),
|
||||||
PlayerState::Invalid => panic!("invalid state"),
|
PlayerState::Invalid => {
|
||||||
|
error!("PlayerInternal handle_player_stop: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1191,10 +1264,6 @@ impl PlayerInternal {
|
||||||
Some(mut packet) => {
|
Some(mut packet) => {
|
||||||
if !packet.is_empty() {
|
if !packet.is_empty() {
|
||||||
if let AudioPacket::Samples(ref mut data) = packet {
|
if let AudioPacket::Samples(ref mut data) = packet {
|
||||||
if let Some(ref editor) = self.audio_filter {
|
|
||||||
editor.modify_stream(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.config.normalisation
|
if self.config.normalisation
|
||||||
&& !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON
|
&& !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON
|
||||||
&& self.config.normalisation_method == NormalisationMethod::Basic)
|
&& self.config.normalisation_method == NormalisationMethod::Basic)
|
||||||
|
@ -1295,22 +1364,17 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*sample *= actual_normalisation_factor;
|
*sample *= actual_normalisation_factor;
|
||||||
|
|
||||||
// Extremely sharp attacks, however unlikely, *may* still clip and provide
|
|
||||||
// undefined results, so strictly enforce output within [-1.0, 1.0].
|
|
||||||
if *sample < -1.0 {
|
|
||||||
*sample = -1.0;
|
|
||||||
} else if *sample > 1.0 {
|
|
||||||
*sample = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = self.sink.write(&packet, &mut self.converter) {
|
if let Some(ref editor) = self.audio_filter {
|
||||||
error!("Fatal error, could not write audio to audio sink: {}", err);
|
editor.modify_stream(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.sink.write(&packet, &mut self.converter) {
|
||||||
|
error!("{}", e);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1329,7 +1393,8 @@ impl PlayerInternal {
|
||||||
play_request_id,
|
play_request_id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
error!("PlayerInternal handle_packet: Invalid PlayerState");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1353,6 +1418,17 @@ impl PlayerInternal {
|
||||||
) {
|
) {
|
||||||
let position_ms = Self::position_pcm_to_ms(loaded_track.stream_position_pcm);
|
let position_ms = Self::position_pcm_to_ms(loaded_track.stream_position_pcm);
|
||||||
|
|
||||||
|
let mut config = self.config.clone();
|
||||||
|
if config.normalisation_type == NormalisationType::Auto {
|
||||||
|
if self.auto_normalise_as_album {
|
||||||
|
config.normalisation_type = NormalisationType::Album;
|
||||||
|
} else {
|
||||||
|
config.normalisation_type = NormalisationType::Track;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let normalisation_factor =
|
||||||
|
NormalisationData::get_factor(&config, loaded_track.normalisation_data);
|
||||||
|
|
||||||
if start_playback {
|
if start_playback {
|
||||||
self.ensure_sink_running();
|
self.ensure_sink_running();
|
||||||
|
|
||||||
|
@ -1367,7 +1443,8 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder: loaded_track.decoder,
|
decoder: loaded_track.decoder,
|
||||||
normalisation_factor: loaded_track.normalisation_factor,
|
normalisation_data: loaded_track.normalisation_data,
|
||||||
|
normalisation_factor,
|
||||||
stream_loader_controller: loaded_track.stream_loader_controller,
|
stream_loader_controller: loaded_track.stream_loader_controller,
|
||||||
duration_ms: loaded_track.duration_ms,
|
duration_ms: loaded_track.duration_ms,
|
||||||
bytes_per_second: loaded_track.bytes_per_second,
|
bytes_per_second: loaded_track.bytes_per_second,
|
||||||
|
@ -1384,7 +1461,8 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder: loaded_track.decoder,
|
decoder: loaded_track.decoder,
|
||||||
normalisation_factor: loaded_track.normalisation_factor,
|
normalisation_data: loaded_track.normalisation_data,
|
||||||
|
normalisation_factor,
|
||||||
stream_loader_controller: loaded_track.stream_loader_controller,
|
stream_loader_controller: loaded_track.stream_loader_controller,
|
||||||
duration_ms: loaded_track.duration_ms,
|
duration_ms: loaded_track.duration_ms,
|
||||||
bytes_per_second: loaded_track.bytes_per_second,
|
bytes_per_second: loaded_track.bytes_per_second,
|
||||||
|
@ -1437,7 +1515,10 @@ impl PlayerInternal {
|
||||||
play_request_id,
|
play_request_id,
|
||||||
position_ms,
|
position_ms,
|
||||||
}),
|
}),
|
||||||
PlayerState::Invalid { .. } => panic!("Player is in an invalid state."),
|
PlayerState::Invalid { .. } => {
|
||||||
|
error!("PlayerInternal handle_command_load: invalid state");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now we check at different positions whether we already have a pre-loaded version
|
// Now we check at different positions whether we already have a pre-loaded version
|
||||||
|
@ -1453,24 +1534,30 @@ impl PlayerInternal {
|
||||||
if previous_track_id == track_id {
|
if previous_track_id == track_id {
|
||||||
let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) {
|
let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) {
|
||||||
PlayerState::EndOfTrack { loaded_track, .. } => loaded_track,
|
PlayerState::EndOfTrack { loaded_track, .. } => loaded_track,
|
||||||
_ => unreachable!(),
|
_ => {
|
||||||
|
error!("PlayerInternal handle_command_load: Invalid PlayerState");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if Self::position_ms_to_pcm(position_ms) != loaded_track.stream_position_pcm {
|
let position_pcm = Self::position_ms_to_pcm(position_ms);
|
||||||
|
|
||||||
|
if position_pcm != loaded_track.stream_position_pcm {
|
||||||
loaded_track
|
loaded_track
|
||||||
.stream_loader_controller
|
.stream_loader_controller
|
||||||
.set_random_access_mode();
|
.set_random_access_mode();
|
||||||
let _ = loaded_track.decoder.seek(position_ms as i64); // This may be blocking.
|
if let Err(e) = loaded_track.decoder.seek(position_pcm) {
|
||||||
// But most likely the track is fully
|
// This may be blocking.
|
||||||
// loaded already because we played
|
error!("PlayerInternal handle_command_load: {}", e);
|
||||||
// to the end of it.
|
}
|
||||||
loaded_track.stream_loader_controller.set_stream_mode();
|
loaded_track.stream_loader_controller.set_stream_mode();
|
||||||
loaded_track.stream_position_pcm = Self::position_ms_to_pcm(position_ms);
|
loaded_track.stream_position_pcm = position_pcm;
|
||||||
}
|
}
|
||||||
self.preload = PlayerPreload::None;
|
self.preload = PlayerPreload::None;
|
||||||
self.start_playback(track_id, play_request_id, loaded_track, play);
|
self.start_playback(track_id, play_request_id, loaded_track, play);
|
||||||
if let PlayerState::Invalid = self.state {
|
if let PlayerState::Invalid = self.state {
|
||||||
panic!("start_playback() hasn't set a valid player state.");
|
error!("start_playback() hasn't set a valid player state.");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1494,11 +1581,16 @@ impl PlayerInternal {
|
||||||
{
|
{
|
||||||
if current_track_id == track_id {
|
if current_track_id == track_id {
|
||||||
// we can use the current decoder. Ensure it's at the correct position.
|
// we can use the current decoder. Ensure it's at the correct position.
|
||||||
if Self::position_ms_to_pcm(position_ms) != *stream_position_pcm {
|
let position_pcm = Self::position_ms_to_pcm(position_ms);
|
||||||
|
|
||||||
|
if position_pcm != *stream_position_pcm {
|
||||||
stream_loader_controller.set_random_access_mode();
|
stream_loader_controller.set_random_access_mode();
|
||||||
let _ = decoder.seek(position_ms as i64); // This may be blocking.
|
if let Err(e) = decoder.seek(position_pcm) {
|
||||||
|
// This may be blocking.
|
||||||
|
error!("PlayerInternal handle_command_load: {}", e);
|
||||||
|
}
|
||||||
stream_loader_controller.set_stream_mode();
|
stream_loader_controller.set_stream_mode();
|
||||||
*stream_position_pcm = Self::position_ms_to_pcm(position_ms);
|
*stream_position_pcm = position_pcm;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the info from the current state into a PlayerLoadedTrackData so we can use
|
// Move the info from the current state into a PlayerLoadedTrackData so we can use
|
||||||
|
@ -1511,7 +1603,7 @@ impl PlayerInternal {
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
| PlayerState::Paused {
|
| PlayerState::Paused {
|
||||||
|
@ -1520,13 +1612,13 @@ impl PlayerInternal {
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
..
|
..
|
||||||
} = old_state
|
} = old_state
|
||||||
{
|
{
|
||||||
let loaded_track = PlayerLoadedTrackData {
|
let loaded_track = PlayerLoadedTrackData {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_factor,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -1537,12 +1629,14 @@ impl PlayerInternal {
|
||||||
self.start_playback(track_id, play_request_id, loaded_track, play);
|
self.start_playback(track_id, play_request_id, loaded_track, play);
|
||||||
|
|
||||||
if let PlayerState::Invalid = self.state {
|
if let PlayerState::Invalid = self.state {
|
||||||
panic!("start_playback() hasn't set a valid player state.");
|
error!("start_playback() hasn't set a valid player state.");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
error!("PlayerInternal handle_command_load: Invalid PlayerState");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1560,17 +1654,23 @@ impl PlayerInternal {
|
||||||
mut loaded_track,
|
mut loaded_track,
|
||||||
} = preload
|
} = preload
|
||||||
{
|
{
|
||||||
if Self::position_ms_to_pcm(position_ms) != loaded_track.stream_position_pcm {
|
let position_pcm = Self::position_ms_to_pcm(position_ms);
|
||||||
|
|
||||||
|
if position_pcm != loaded_track.stream_position_pcm {
|
||||||
loaded_track
|
loaded_track
|
||||||
.stream_loader_controller
|
.stream_loader_controller
|
||||||
.set_random_access_mode();
|
.set_random_access_mode();
|
||||||
let _ = loaded_track.decoder.seek(position_ms as i64); // This may be blocking
|
if let Err(e) = loaded_track.decoder.seek(position_pcm) {
|
||||||
|
// This may be blocking
|
||||||
|
error!("PlayerInternal handle_command_load: {}", e);
|
||||||
|
}
|
||||||
loaded_track.stream_loader_controller.set_stream_mode();
|
loaded_track.stream_loader_controller.set_stream_mode();
|
||||||
}
|
}
|
||||||
self.start_playback(track_id, play_request_id, *loaded_track, play);
|
self.start_playback(track_id, play_request_id, *loaded_track, play);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
error!("PlayerInternal handle_command_load: Invalid PlayerState");
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1676,7 +1776,9 @@ impl PlayerInternal {
|
||||||
stream_loader_controller.set_random_access_mode();
|
stream_loader_controller.set_random_access_mode();
|
||||||
}
|
}
|
||||||
if let Some(decoder) = self.state.decoder() {
|
if let Some(decoder) = self.state.decoder() {
|
||||||
match decoder.seek(position_ms as i64) {
|
let position_pcm = Self::position_ms_to_pcm(position_ms);
|
||||||
|
|
||||||
|
match decoder.seek(position_pcm) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if let PlayerState::Playing {
|
if let PlayerState::Playing {
|
||||||
ref mut stream_position_pcm,
|
ref mut stream_position_pcm,
|
||||||
|
@ -1687,10 +1789,10 @@ impl PlayerInternal {
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
*stream_position_pcm = Self::position_ms_to_pcm(position_ms);
|
*stream_position_pcm = position_pcm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => error!("Vorbis error: {:?}", err),
|
Err(e) => error!("PlayerInternal handle_command_seek: {}", e),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Player::seek called from invalid state");
|
warn!("Player::seek called from invalid state");
|
||||||
|
@ -1764,6 +1866,10 @@ impl PlayerInternal {
|
||||||
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
||||||
self.send_event(PlayerEvent::VolumeSet { volume })
|
self.send_event(PlayerEvent::VolumeSet { volume })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => {
|
||||||
|
self.auto_normalise_as_album = setting
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1870,6 +1976,10 @@ impl ::std::fmt::Debug for PlayerCommand {
|
||||||
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
||||||
f.debug_tuple("VolumeSet").field(&volume).finish()
|
f.debug_tuple("VolumeSet").field(&volume).finish()
|
||||||
}
|
}
|
||||||
|
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f
|
||||||
|
.debug_tuple("SetAutoNormaliseAsAlbum")
|
||||||
|
.field(&setting)
|
||||||
|
.finish(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1926,7 +2036,9 @@ struct Subfile<T: Read + Seek> {
|
||||||
|
|
||||||
impl<T: Read + Seek> Subfile<T> {
|
impl<T: Read + Seek> Subfile<T> {
|
||||||
pub fn new(mut stream: T, offset: u64) -> Subfile<T> {
|
pub fn new(mut stream: T, offset: u64) -> Subfile<T> {
|
||||||
stream.seek(SeekFrom::Start(offset)).unwrap();
|
if let Err(e) = stream.seek(SeekFrom::Start(offset)) {
|
||||||
|
error!("Subfile new Error: {}", e);
|
||||||
|
}
|
||||||
Subfile { stream, offset }
|
Subfile { stream, offset }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "librespot-protocol"
|
name = "librespot-protocol"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Paul Liétar <paul@lietar.net>"]
|
authors = ["Paul Liétar <paul@lietar.net>"]
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
description = "The protobuf logic for communicating with Spotify servers"
|
description = "The protobuf logic for communicating with Spotify servers"
|
||||||
|
|
|
@ -6,7 +6,7 @@ DRY_RUN='false'
|
||||||
WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )"
|
WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )"
|
||||||
cd $WORKINGDIR
|
cd $WORKINGDIR
|
||||||
|
|
||||||
crates=( "protocol" "core" "audio" "metadata" "playback" "connect" "librespot" )
|
crates=( "protocol" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" )
|
||||||
|
|
||||||
function switchBranch {
|
function switchBranch {
|
||||||
if [ "$SKIP_MERGE" = 'false' ] ; then
|
if [ "$SKIP_MERGE" = 'false' ] ; then
|
||||||
|
|
1332
src/main.rs
1332
src/main.rs
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue