New upstream version 0.22.0+dfsg1
Felix Geyer
3 years ago
0 | --- | |
1 | name: Bug report | |
2 | about: 'Report a bug' | |
3 | title: '' | |
4 | labels: bug | |
5 | assignees: '' | |
6 | ||
7 | --- | |
8 | ||
9 | **Describe the bug** | |
10 | A clear and concise description of what the bug is. | |
11 | ||
12 | **Steps to Reproduce** | |
13 | 1. | |
14 | 2. | |
15 | 3. | |
16 | ||
17 | **Environment details** | |
18 | - OS: [e.g. Raspbian, debian, Windows] | |
19 | - Snapcast version [e.g. 0.21.0] | |
20 | - Installed from a package, self compiled, ... | |
21 | ||
22 | **Attach logfile if applicable** | |
23 | Generate logs with `snapclient --logfilter debug` or `snapserver --logging.filter debug` if possible and paste them in the following codeblock | |
24 | ||
25 | ```log | |
26 | # Replace this with your logs | |
27 | ``` |
0 | --- | |
1 | name: Feature request | |
2 | about: Suggest an idea or enhancement for snapcast | |
3 | title: '' | |
4 | labels: feature request | |
5 | assignees: '' | |
6 | ||
7 | --- | |
8 | ||
9 | **Is your feature request related to a problem? Please describe.** | |
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] | |
11 | ||
12 | **Describe the solution you'd like** | |
13 | A clear and concise description of what you want to happen. | |
14 | ||
15 | **Describe alternatives you've considered** | |
16 | A clear and concise description of any alternative solutions or features you've considered. | |
17 | ||
18 | **Additional context** | |
19 | Add any other context or screenshots about the feature request here. |
0 | [Describe your pull request here. Please read the text below the line, and make sure you follow the checklist.] | |
1 | ||
2 | * * * | |
3 | ||
4 | ## Pull Request Checklist | |
5 | ||
6 | * Contributions must be licensed under the [GPL-3.0 License](LICENSE) | |
7 | ||
8 | * This project loosely follows the [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html) | |
9 | ||
10 | * For better compatibility with embedded toolchains, the used C++ standard should be limited to C++14 | |
11 | ||
12 | * Code should be formatted by running `make reformat` | |
13 | ||
14 | * Branch from the `develop` branch and ensure it is up to date with the current `develop` branch before submitting your pull request. If it doesn't merge cleanly with `develop`, you may be asked to resolve the conflicts. Pull requests to master will be closed. | |
15 | ||
16 | * Commits should be as small as possible while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests). | |
17 | ||
18 | * Pull requests must not contain compiled sources (already set by the default .gitignore) or binary files | |
19 | ||
20 | * Test your changes as thoroughly as possible before you commit them. Preferably, automate your test by unit/integration tests. If tested manually, provide information about the test scope in the PR description (e.g. “Test passed: Upgrade version from 0.42 to 0.42.23.”). | |
21 | ||
22 | * Create _Work In Progress [WIP]_ pull requests only if you need clarification or an explicit review before you can continue your work item. | |
23 | ||
24 | * If your patch is not getting reviewed or you need a specific person to review it, you can @-reply a reviewer asking for a review in the pull request or a comment, or you can ask for a review by contacting us via [email](mailto:snapcast@badaix.de). | |
25 | ||
26 | * Post review: | |
27 | * If a review requires you to change your commit(s), please test the changes again. | |
28 | * Amend the affected commit(s) and force push onto your branch. | |
29 | * Set respective comments in your GitHub review to resolved. | |
30 | * Create a general PR comment to notify the reviewers that your amendments are ready for another round of review. |
0 | name: macOS | |
1 | ||
2 | on: [push, pull_request] | |
3 | ||
4 | jobs: | |
5 | build: | |
6 | ||
7 | runs-on: macos-latest | |
8 | ||
9 | steps: | |
10 | - uses: actions/checkout@v2 | |
11 | - name: dependencies | |
12 | run: brew install pkgconfig libsoxr ccache expat | |
13 | - name: cache boost | |
14 | id: cache-boost | |
15 | uses: actions/cache@v2 | |
16 | with: | |
17 | path: boost_1_74_0 | |
18 | key: ${{ runner.os }}-boost | |
19 | - name: get boost | |
20 | if: steps.cache-boost.outputs.cache-hit != 'true' | |
21 | run: wget https://dl.bintray.com/boostorg/release/1.74.0/source/boost_1_74_0.tar.bz2 && tar xjf boost_1_74_0.tar.bz2 | |
22 | - name: cache ccache | |
23 | id: cache-ccache | |
24 | uses: actions/cache@v2 | |
25 | with: | |
26 | path: /Users/runner/.ccache | |
27 | key: ${{ runner.os }}-ccache-${{ github.sha }} | |
28 | restore-keys: ${{ runner.os }}-ccache- | |
29 | #- name: ccache dump config | |
30 | # run: ccache -p | |
31 | - name: cmake build | |
32 | run: cmake -S . -B build -DBOOST_ROOT=boost_1_74_0 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_FLAGS="$CXXFLAGS -Werror -Wall -Wextra -pedantic -Wno-unused-function -I/usr/local/include" | |
33 | - name: cmake make | |
34 | run: cmake --build build --parallel 3 |
0 | name: self-hosted | |
1 | ||
2 | on: [push, pull_request] | |
3 | ||
4 | jobs: | |
5 | build: | |
6 | ||
7 | runs-on: self-hosted-rpi3 | |
8 | ||
9 | steps: | |
10 | - name: clean-up | |
11 | run: rm -rf /home/pi/actions-runner/_work/snapcast/snap*_armhf.deb | |
12 | - uses: actions/checkout@v2 | |
13 | - name: cmake build | |
14 | run: mkdir build && cd build && cmake -DBOOST_ROOT=/home/pi/Develop/boost_1_74_0 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_FLAGS="$CXXFLAGS -Wall -Wextra -pedantic -Wno-unused-function" .. && cd .. | |
15 | - name: cmake make | |
16 | run: cmake --build build | |
17 | - name: debian package | |
18 | run: fakeroot make -f debian/rules CMAKEFLAGS="-DBOOST_ROOT=/home/pi/Develop/boost_1_74_0 -DCMAKE_CXX_COMPILER_LAUNCHER=ccache" binary | |
19 | - name: Archive artifacts | |
20 | uses: actions/upload-artifact@v2 | |
21 | with: | |
22 | name: develop_snapshot_armhf-${{github.sha}} | |
23 | path: /home/pi/actions-runner/_work/snapcast/snap??????_*_armhf.deb |
0 | name: Ubuntu | |
1 | ||
2 | on: [push, pull_request] | |
3 | ||
4 | jobs: | |
5 | build: | |
6 | ||
7 | runs-on: ubuntu-16.04 | |
8 | #ubuntu-latest | |
9 | ||
10 | steps: | |
11 | - uses: actions/checkout@v2 | |
12 | - name: dependencies | |
13 | run: sudo apt-get update && sudo apt-get install -yq libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon debhelper ccache expat | |
14 | - name: cache boost | |
15 | id: cache-boost | |
16 | uses: actions/cache@v2 | |
17 | with: | |
18 | path: boost_1_74_0 | |
19 | key: ${{ runner.os }}-boost | |
20 | - name: get boost | |
21 | if: steps.cache-boost.outputs.cache-hit != 'true' | |
22 | run: wget https://dl.bintray.com/boostorg/release/1.74.0/source/boost_1_74_0.tar.bz2 && tar xjf boost_1_74_0.tar.bz2 | |
23 | - name: cache ccache | |
24 | id: cache-ccache | |
25 | uses: actions/cache@v2 | |
26 | with: | |
27 | path: /home/runner/.ccache | |
28 | key: ${{ runner.os }}-ccache-${{ github.sha }} | |
29 | restore-keys: ${{ runner.os }}-ccache- | |
30 | #- name: ccache dump config | |
31 | # run: ccache -p | |
32 | - name: cmake build | |
33 | run: cmake -S . -B build -DBOOST_ROOT=boost_1_74_0 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_FLAGS="$CXXFLAGS -Werror -Wall -Wextra -pedantic -Wno-unused-function" | |
34 | - name: cmake make | |
35 | run: cmake --build build --parallel 3 | |
36 | - name: debian package | |
37 | run: fakeroot make -f debian/rules CMAKEFLAGS="-DBOOST_ROOT=boost_1_74_0 -DCMAKE_CXX_COMPILER_LAUNCHER=ccache" binary | |
38 | - name: Archive artifacts | |
39 | uses: actions/upload-artifact@v2 | |
40 | with: | |
41 | name: develop_snapshot_amd64-${{github.sha}} | |
42 | path: /home/runner/work/snapcast/snap*_amd64.deb |
0 | name: Windows | |
1 | ||
2 | on: [push, pull_request] | |
3 | ||
4 | jobs: | |
5 | build: | |
6 | ||
7 | runs-on: windows-latest | |
8 | ||
9 | steps: | |
10 | - uses: actions/checkout@v2 | |
11 | - name: cache dependencies | |
12 | id: cache-dependencies | |
13 | uses: actions/cache@v2 | |
14 | with: | |
15 | #path: ${VCPKG_INSTALLATION_ROOT}\installed | |
16 | path: c:\vcpkg\installed | |
17 | key: ${{ runner.os }}-dependencies | |
18 | - name: dependencies | |
19 | if: steps.cache-dependencies.outputs.cache-hit != 'true' | |
20 | run: vcpkg.exe install libflac libvorbis soxr opus boost-asio --triplet x64-windows | |
21 | - name: cmake build | |
22 | run: | | |
23 | echo vcpkg installation root: ${env:VCPKG_INSTALLATION_ROOT} | |
24 | cmake -S . -B build -G "Visual Studio 16 2019" -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET="x64-windows" -DCMAKE_BUILD_TYPE="Release" | |
25 | - name: cmake make | |
26 | run: cmake --build build --config Release --parallel 3 --verbose | |
27 | - name: Archive artifacts | |
28 | uses: actions/upload-artifact@v2 | |
29 | with: | |
30 | name: develop_snapshot_win64-${{github.sha}} | |
31 | path: bin\Release\snapclient.exe | |
32 |
54 | 54 | debian/snapserver.postrm.debhelper |
55 | 55 | debian/snapserver.prerm.debhelper |
56 | 56 | debian/snapserver.substvars |
57 | debian/tmp | |
58 | obj-x86_64-linux-gnu⏎ |
0 | 0 | [submodule "externals/flac"] |
1 | 1 | path = externals/flac |
2 | url = https://git.xiph.org/flac.git | |
2 | url = https://gitlab.xiph.org/xiph/flac.git | |
3 | 3 | [submodule "externals/ogg"] |
4 | 4 | path = externals/ogg |
5 | url = https://git.xiph.org/ogg.git | |
5 | url = https://gitlab.xiph.org/xiph/ogg.git | |
6 | 6 | [submodule "externals/tremor"] |
7 | 7 | path = externals/tremor |
8 | url = https://git.xiph.org/tremor.git | |
8 | url = https://gitlab.xiph.org/xiph/tremor.git | |
9 | 9 | [submodule "externals/opus"] |
10 | 10 | path = externals/opus |
11 | url = https://git.xiph.org/opus.git | |
11 | url = https://gitlab.xiph.org/xiph/opus.git | |
12 | 12 | [submodule "externals/oboe"] |
13 | 13 | path = externals/oboe |
14 | 14 | url = https://github.com/google/oboe.git |
15 | [submodule "externals/soxr"] | |
16 | path = externals/soxr | |
17 | url = git://git.code.sf.net/p/soxr/code |
2 | 2 | sudo: required |
3 | 3 | group: edge |
4 | 4 | |
5 | git: | |
6 | submodules: false | |
7 | ||
5 | 8 | matrix: |
6 | 9 | include: |
7 | - os: linux | |
8 | compiler: gcc | |
9 | env: | |
10 | - COMPILER=g++-4.9 | |
11 | addons: | |
12 | apt: | |
13 | sources: | |
14 | - sourceline: 'ppa:mhier/libboost-latest' | |
15 | - ubuntu-toolchain-r-test | |
16 | packages: | |
17 | - g++-4.9 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
10 | # - os: linux | |
11 | # compiler: gcc | |
12 | # env: | |
13 | # - COMPILER=g++-4.9 | |
14 | # addons: | |
15 | # apt: | |
16 | # sources: | |
17 | # - sourceline: 'ppa:mhier/libboost-latest' | |
18 | # - ubuntu-toolchain-r-test | |
19 | # packages: | |
20 | # - g++-4.9 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
18 | 21 | |
19 | 22 | - os: linux |
20 | 23 | compiler: gcc |
26 | 29 | - sourceline: 'ppa:mhier/libboost-latest' |
27 | 30 | - ubuntu-toolchain-r-test |
28 | 31 | packages: |
29 | - g++-5 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
32 | - g++-5 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
30 | 33 | |
31 | 34 | - os: linux |
32 | 35 | compiler: gcc |
38 | 41 | - sourceline: 'ppa:mhier/libboost-latest' |
39 | 42 | - ubuntu-toolchain-r-test |
40 | 43 | packages: |
41 | - g++-6 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
44 | - g++-6 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
42 | 45 | |
43 | 46 | - os: linux |
44 | 47 | compiler: gcc |
50 | 53 | - sourceline: 'ppa:mhier/libboost-latest' |
51 | 54 | - ubuntu-toolchain-r-test |
52 | 55 | packages: |
53 | - g++-7 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
56 | - g++-7 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
54 | 57 | |
55 | 58 | - os: linux |
56 | 59 | compiler: gcc |
62 | 65 | - sourceline: 'ppa:mhier/libboost-latest' |
63 | 66 | - ubuntu-toolchain-r-test |
64 | 67 | packages: |
65 | - g++-8 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
68 | - g++-8 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
66 | 69 | |
67 | 70 | - os: linux |
68 | 71 | compiler: gcc |
74 | 77 | - sourceline: 'ppa:mhier/libboost-latest' |
75 | 78 | - ubuntu-toolchain-r-test |
76 | 79 | packages: |
77 | - g++-9 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
80 | - g++-9 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
78 | 81 | |
79 | 82 | |
80 | 83 | - os: linux |
89 | 92 | - sourceline: 'ppa:mhier/libboost-latest' |
90 | 93 | - llvm-toolchain-trusty-3.9 |
91 | 94 | packages: |
92 | - clang-3.9 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
95 | - clang-3.9 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
93 | 96 | |
94 | 97 | - os: linux |
95 | 98 | compiler: clang |
103 | 106 | - sourceline: 'ppa:mhier/libboost-latest' |
104 | 107 | - llvm-toolchain-trusty-4.0 |
105 | 108 | packages: |
106 | - clang-4.0 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
109 | - clang-4.0 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
107 | 110 | |
108 | 111 | - os: linux |
109 | 112 | compiler: clang |
117 | 120 | - sourceline: 'ppa:mhier/libboost-latest' |
118 | 121 | - llvm-toolchain-trusty-5.0 |
119 | 122 | packages: |
120 | - clang-5.0 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
123 | - clang-5.0 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
121 | 124 | |
122 | 125 | - os: linux |
123 | 126 | compiler: clang |
131 | 134 | - llvm-toolchain-trusty-6.0 |
132 | 135 | - ubuntu-toolchain-r-test |
133 | 136 | packages: |
134 | - clang-6.0 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
137 | - clang-6.0 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
135 | 138 | |
136 | 139 | - os: linux |
137 | 140 | compiler: clang |
145 | 148 | - llvm-toolchain-trusty-7 |
146 | 149 | - ubuntu-toolchain-r-test |
147 | 150 | packages: |
148 | - clang-7 boost1.70 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon | |
151 | - clang-7 boost1.74 libasound2-dev libsoxr-dev libvorbisidec-dev libvorbis-dev libflac-dev libopus-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
149 | 152 | |
150 | 153 | # build on osx |
151 | 154 | - os: osx |
152 | 155 | osx_image: xcode9.4 |
153 | 156 | env: |
154 | - MATRIX_EVAL="brew update && brew unlink python@2 && brew upgrade boost && brew install flac opus libvorbis libsoxr" | |
157 | - MATRIX_EVAL="brew update && brew unlink python@2 && brew upgrade boost && brew install flac opus libvorbis libsoxr expat" | |
155 | 158 | |
156 | 159 | - os: osx |
157 | 160 | osx_image: xcode10.3 |
158 | 161 | env: |
159 | - MATRIX_EVAL="brew update && brew install flac opus libvorbis libsoxr" | |
162 | - MATRIX_EVAL="brew update && brew install flac opus libvorbis libsoxr expat" | |
160 | 163 | |
161 | 164 | - os: osx |
162 | 165 | osx_image: xcode11 |
163 | 166 | env: |
164 | - MATRIX_EVAL="brew update && brew install flac opus libvorbis libsoxr" | |
167 | - MATRIX_EVAL="brew update && brew install flac opus libvorbis libsoxr expat" | |
168 | ||
169 | # build on windows | |
170 | - os: windows | |
165 | 171 | |
166 | 172 | before_install: |
167 | 173 | - eval "${MATRIX_EVAL}" |
174 | - | | |
175 | ( | |
176 | if [ "$TRAVIS_OS_NAME" = 'windows' ]; then | |
177 | curl -LfsS -o /tmp/travis-wait-enhanced.zip "https://github.com/crazy-max/travis-wait-enhanced/releases/download/v1.1.0/travis-wait-enhanced_1.1.0_windows_x86_64.zip" | |
178 | 7z x /tmp/travis-wait-enhanced.zip -y -o/usr/bin/ travis-wait-enhanced.exe -r | |
179 | travis-wait-enhanced --version # we use this tool so travis doesn't timeout when there's no output for > 10 minutes (vcpkg has no verbose option). travis_wait also fixes this issue, but it swallows the output | |
180 | if [ ! -f "vcpkg/vcpkg.exe" ]; then # if not in cache from a previous run | |
181 | git clone https://github.com/Microsoft/vcpkg.git | |
182 | ./vcpkg/bootstrap-vcpkg.bat | |
183 | travis-wait-enhanced --interval=1m --timeout=30m -- ./vcpkg/vcpkg.exe install libflac libvorbis soxr opus boost-asio --triplet x64-windows | |
184 | else | |
185 | ./vcpkg/vcpkg.exe update # make sure dependencies are up to date | |
186 | fi | |
187 | fi | |
188 | ) | |
168 | 189 | |
169 | 190 | script: |
170 | 191 | # make sure CXX is correctly set |
172 | 193 | |
173 | 194 | - mkdir build |
174 | 195 | - cd build |
175 | - cmake -DCMAKE_CXX_FLAGS="$CXXFLAGS -Werror -Wall -Wextra -pedantic -Wno-unused-parameter -Wno-unused-function -O2" .. && make && sudo make install | |
196 | - | | |
197 | ( | |
198 | if [ "$TRAVIS_OS_NAME" != 'windows' ]; then | |
199 | cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="$CXXFLAGS -Werror -Wall -Wextra -pedantic -Wno-unused-function" .. && make && sudo make install | |
200 | else | |
201 | cmake -G "Visual Studio 15 2017 Win64" -DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake -DVCPKG_TARGET_TRIPLET=x64-windows .. | |
202 | cmake --build . --config Release | |
203 | fi | |
204 | ) | |
205 | cache: | |
206 | directories: | |
207 | - vcpkg⏎ |
0 | 0 | cmake_minimum_required(VERSION 3.2) |
1 | 1 | |
2 | project(snapcast LANGUAGES CXX VERSION 0.19.0) | |
2 | project(snapcast LANGUAGES CXX VERSION 0.22.0) | |
3 | 3 | set(PROJECT_DESCRIPTION "Multiroom client-server audio player") |
4 | 4 | set(PROJECT_URL "https://github.com/badaix/snapcast") |
5 | 5 | |
7 | 7 | option(BUILD_STATIC_LIBS "Build snapcast in a static context" ON) |
8 | 8 | option(BUILD_TESTS "Build tests (run tests with make test)" ON) |
9 | 9 | |
10 | option(BUILD_SERVER "Build Snapserver" ON) | |
10 | include(GNUInstallDirs) | |
11 | ||
12 | if(NOT WIN32) | |
13 | option(BUILD_SERVER "Build Snapserver" ON) # no Windows server for now | |
14 | endif() | |
15 | ||
11 | 16 | option(BUILD_CLIENT "Build Snapclient" ON) |
12 | 17 | |
13 | 18 | option(BUILD_WITH_FLAC "Build with FLAC support" ON) |
87 | 92 | ENDIF(${BIGENDIAN}) |
88 | 93 | |
89 | 94 | # Check dependencies |
90 | find_package(PkgConfig REQUIRED) | |
95 | ||
96 | if(NOT WIN32) # no PkgConfig on Windows... | |
97 | find_package(PkgConfig REQUIRED) | |
98 | endif() | |
99 | ||
91 | 100 | find_package(Threads REQUIRED) |
92 | 101 | |
93 | 102 | include(CMakePushCheckState) |
94 | 103 | include(CheckIncludeFileCXX) |
95 | ||
96 | if(MACOSX) | |
97 | set(BONJOUR_FOUND true) | |
98 | if (BONJOUR_FOUND) | |
99 | add_definitions(-DHAS_BONJOUR) | |
100 | endif (BONJOUR_FOUND) | |
101 | ||
102 | add_definitions(-DFREEBSD -DHAS_DAEMON) | |
103 | link_directories("/usr/local/lib") | |
104 | list(APPEND INCLUDE_DIRS "/usr/local/include") | |
105 | list(APPEND CMAKE_REQUIRED_INCLUDES "${INCLUDE_DIRS}") | |
106 | elseif(ANDROID) | |
107 | # add_definitions("-DNO_CPP11_STRING") | |
108 | else() | |
109 | if (BUILD_CLIENT) | |
104 | include_directories(${INCLUDE_DIRS}) | |
105 | ||
106 | include(${CMAKE_SOURCE_DIR}/cmake/CheckCXX11StringSupport.cmake) | |
107 | ||
108 | CHECK_CXX11_STRING_SUPPORT(HAS_CXX11_STRING_SUPPORT) | |
109 | if(NOT HAS_CXX11_STRING_SUPPORT) | |
110 | add_definitions("-DNO_CPP11_STRING") | |
111 | endif() | |
112 | ||
113 | ||
114 | if(NOT WIN32) | |
115 | ||
116 | if(MACOSX) | |
117 | set(BONJOUR_FOUND true) | |
118 | if (BONJOUR_FOUND) | |
119 | add_definitions(-DHAS_BONJOUR) | |
120 | endif (BONJOUR_FOUND) | |
121 | ||
122 | add_definitions(-DFREEBSD -DMACOS -DHAS_DAEMON) | |
123 | link_directories("/usr/local/lib") | |
124 | list(APPEND INCLUDE_DIRS "/usr/local/include") | |
125 | elseif(ANDROID) | |
126 | # add_definitions("-DNO_CPP11_STRING") | |
127 | else() | |
128 | ||
110 | 129 | pkg_search_module(ALSA REQUIRED alsa) |
111 | 130 | if (ALSA_FOUND) |
112 | 131 | add_definitions(-DHAS_ALSA) |
113 | 132 | endif (ALSA_FOUND) |
114 | endif() | |
115 | ||
116 | if(BUILD_WITH_AVAHI) | |
117 | pkg_search_module(AVAHI avahi-client) | |
118 | if (AVAHI_FOUND) | |
119 | add_definitions(-DHAS_AVAHI) | |
120 | endif (AVAHI_FOUND) | |
121 | endif(BUILD_WITH_AVAHI) | |
122 | ||
123 | add_definitions(-DHAS_DAEMON) | |
124 | ||
125 | if(FREEBSD) | |
126 | add_definitions(-DFREEBSD) | |
127 | link_directories("/usr/local/lib") | |
128 | list(APPEND INCLUDE_DIRS "/usr/local/include") | |
129 | list(APPEND CMAKE_REQUIRED_INCLUDES "${INCLUDE_DIRS}") | |
130 | endif() | |
131 | endif() | |
132 | ||
133 | include_directories(${INCLUDE_DIRS}) | |
134 | ||
135 | ||
136 | include(${CMAKE_SOURCE_DIR}/cmake/CheckCXX11StringSupport.cmake) | |
137 | CHECK_CXX11_STRING_SUPPORT(HAS_CXX11_STRING_SUPPORT) | |
138 | if(NOT HAS_CXX11_STRING_SUPPORT) | |
139 | add_definitions("-DNO_CPP11_STRING") | |
140 | endif() | |
141 | ||
142 | if(BUILD_WITH_FLAC) | |
143 | pkg_search_module(FLAC flac) | |
144 | if (FLAC_FOUND) | |
145 | add_definitions("-DHAS_FLAC") | |
146 | endif (FLAC_FOUND) | |
147 | endif() | |
148 | ||
149 | if(BUILD_WITH_VORBIS OR BUILD_WITH_TREMOR) | |
150 | pkg_search_module(OGG ogg) | |
151 | if (OGG_FOUND) | |
152 | add_definitions("-DHAS_OGG") | |
153 | endif (OGG_FOUND) | |
154 | endif() | |
155 | ||
156 | if(BUILD_WITH_VORBIS) | |
157 | pkg_search_module(VORBIS vorbis) | |
158 | if (VORBIS_FOUND) | |
159 | add_definitions("-DHAS_VORBIS") | |
160 | endif (VORBIS_FOUND) | |
161 | endif() | |
162 | ||
163 | if(BUILD_WITH_TREMOR) | |
164 | pkg_search_module(TREMOR vorbisidec) | |
165 | if (TREMOR_FOUND) | |
166 | add_definitions("-DHAS_TREMOR") | |
167 | endif (TREMOR_FOUND) | |
168 | endif() | |
169 | ||
170 | if(BUILD_WITH_VORBIS) | |
171 | pkg_search_module(VORBISENC vorbisenc) | |
172 | if (VORBISENC_FOUND) | |
173 | add_definitions("-DHAS_VORBIS_ENC") | |
174 | endif(VORBISENC_FOUND) | |
175 | endif() | |
176 | ||
177 | if(BUILD_WITH_OPUS) | |
178 | pkg_search_module(OPUS opus) | |
179 | if (OPUS_FOUND) | |
180 | add_definitions("-DHAS_OPUS") | |
181 | endif (OPUS_FOUND) | |
182 | endif() | |
183 | ||
184 | if(BUILD_WITH_EXPAT) | |
185 | pkg_search_module(EXPAT expat) | |
186 | if (EXPAT_FOUND) | |
187 | add_definitions("-DHAS_EXPAT") | |
188 | endif (EXPAT_FOUND) | |
133 | ||
134 | if(BUILD_WITH_AVAHI) | |
135 | pkg_search_module(AVAHI avahi-client) | |
136 | if (AVAHI_FOUND) | |
137 | add_definitions(-DHAS_AVAHI) | |
138 | else() | |
139 | message(STATUS "avahi-client not found") | |
140 | endif (AVAHI_FOUND) | |
141 | endif(BUILD_WITH_AVAHI) | |
142 | ||
143 | add_definitions(-DHAS_DAEMON) | |
144 | ||
145 | if(FREEBSD) | |
146 | add_definitions(-DFREEBSD) | |
147 | link_directories("/usr/local/lib") | |
148 | list(APPEND INCLUDE_DIRS "/usr/local/include") | |
149 | endif() | |
150 | endif() | |
151 | ||
152 | pkg_search_module(SOXR soxr) | |
153 | if (SOXR_FOUND) | |
154 | add_definitions("-DHAS_SOXR") | |
155 | else() | |
156 | message(STATUS "soxr not found") | |
157 | endif (SOXR_FOUND) | |
158 | ||
159 | if(BUILD_WITH_FLAC) | |
160 | pkg_search_module(FLAC flac) | |
161 | if (FLAC_FOUND) | |
162 | add_definitions("-DHAS_FLAC") | |
163 | else() | |
164 | message(STATUS "flac not found") | |
165 | endif (FLAC_FOUND) | |
166 | endif() | |
167 | ||
168 | if(BUILD_WITH_VORBIS OR BUILD_WITH_TREMOR) | |
169 | pkg_search_module(OGG ogg) | |
170 | if (OGG_FOUND) | |
171 | add_definitions("-DHAS_OGG") | |
172 | else() | |
173 | message(STATUS "ogg not found") | |
174 | endif (OGG_FOUND) | |
175 | endif() | |
176 | ||
177 | if(BUILD_WITH_VORBIS) | |
178 | pkg_search_module(VORBIS vorbis) | |
179 | if (VORBIS_FOUND) | |
180 | add_definitions("-DHAS_VORBIS") | |
181 | endif (VORBIS_FOUND) | |
182 | endif() | |
183 | ||
184 | if(BUILD_WITH_TREMOR) | |
185 | pkg_search_module(TREMOR vorbisidec) | |
186 | if (TREMOR_FOUND) | |
187 | add_definitions("-DHAS_TREMOR") | |
188 | endif (TREMOR_FOUND) | |
189 | endif() | |
190 | ||
191 | if ((BUILD_WITH_VORBIS OR BUILD_WITH_TREMOR) AND NOT VORBIS_FOUND AND NOT TREMOR_FOUND) | |
192 | message(STATUS "tremor and vorbis not found") | |
193 | endif() | |
194 | ||
195 | if(BUILD_WITH_VORBIS) | |
196 | pkg_search_module(VORBISENC vorbisenc) | |
197 | if (VORBISENC_FOUND) | |
198 | add_definitions("-DHAS_VORBIS_ENC") | |
199 | else() | |
200 | message(STATUS "vorbisenc not found") | |
201 | endif(VORBISENC_FOUND) | |
202 | endif() | |
203 | ||
204 | if(BUILD_WITH_OPUS) | |
205 | pkg_search_module(OPUS opus) | |
206 | if (OPUS_FOUND) | |
207 | add_definitions("-DHAS_OPUS") | |
208 | else() | |
209 | message(STATUS "opus not found") | |
210 | endif (OPUS_FOUND) | |
211 | endif() | |
212 | ||
213 | if(BUILD_WITH_EXPAT) | |
214 | pkg_search_module(EXPAT expat) | |
215 | if (EXPAT_FOUND) | |
216 | add_definitions("-DHAS_EXPAT") | |
217 | else() | |
218 | message(STATUS "expat not found") | |
219 | endif (EXPAT_FOUND) | |
220 | endif() | |
189 | 221 | endif() |
190 | 222 | |
191 | 223 | find_package(Boost 1.70 REQUIRED) |
192 | 224 | add_definitions("-DBOOST_ERROR_CODE_HEADER_ONLY") |
193 | 225 | |
226 | if(WIN32) | |
227 | include(FindPackageHandleStandardArgs) | |
228 | SET(CMAKE_FIND_LIBRARY_SUFFIXES .lib .a ${CMAKE_FIND_LIBRARY_SUFFIXES}) | |
229 | ||
230 | find_path(FLAC_INCLUDE_DIRS FLAC/all.h) | |
231 | find_library(FLAC_LIBRARIES FLAC) | |
232 | find_package_handle_standard_args(FLAC REQUIRED FLAC_INCLUDE_DIRS FLAC_LIBRARIES) | |
233 | ||
234 | find_path(OGG_INCLUDE_DIRS ogg/ogg.h) | |
235 | find_library(OGG_LIBRARIES ogg) | |
236 | find_package_handle_standard_args(Ogg REQUIRED OGG_INCLUDE_DIRS OGG_LIBRARIES) | |
237 | ||
238 | find_path(VORBIS_INCLUDE_DIRS vorbis/vorbisenc.h) | |
239 | find_library(VORBIS_LIBRARIES vorbis) | |
240 | find_package_handle_standard_args(Vorbis REQUIRED VORBIS_INCLUDE_DIRS VORBIS_LIBRARIES) | |
241 | ||
242 | find_path(OPUS_INCLUDE_DIRS opus/opus.h) | |
243 | find_library(OPUS_LIBRARIES opus) | |
244 | find_package_handle_standard_args(Opus REQUIRED OPUS_INCLUDE_DIRS OPUS_LIBRARIES) | |
245 | ||
246 | find_path(SOXR_INCLUDE_DIRS soxr.h) | |
247 | find_library(SOXR_LIBRARIES soxr) | |
248 | find_package_handle_standard_args(Soxr REQUIRED SOXR_INCLUDE_DIRS SOXR_LIBRARIES) | |
249 | ||
250 | add_definitions(-DNTDDI_VERSION=0x06020000 -D_WIN32_WINNT=0x0602 -DWINVER=0x0602 -DWINDOWS -DWIN32_LEAN_AND_MEAN -DUNICODE -D_UNICODE -D_CRT_SECURE_NO_WARNINGS ) | |
251 | add_definitions(-DHAS_OGG -DHAS_VORBIS -DHAS_FLAC -DHAS_VORBIS_ENC -DHAS_OPUS -DHAS_WASAPI -DHAS_SOXR) | |
252 | endif() | |
253 | ||
254 | list(APPEND CMAKE_REQUIRED_INCLUDES "${INCLUDE_DIRS}") | |
255 | ||
256 | #include(${CMAKE_SOURCE_DIR}/cmake/SystemdService.cmake) | |
257 | ||
194 | 258 | add_subdirectory(common) |
195 | 259 | |
196 | 260 | if (BUILD_SERVER) |
200 | 264 | if (BUILD_CLIENT) |
201 | 265 | add_subdirectory(client) |
202 | 266 | endif() |
267 | ||
268 | ||
269 | FIND_PROGRAM(CLANG_FORMAT "clang-format") | |
270 | IF(CLANG_FORMAT) | |
271 | FILE(GLOB_RECURSE | |
272 | CHECK_CXX_SOURCE_FILES | |
273 | common/*.[ch]pp | |
274 | client/*.[ch]pp | |
275 | server/*.[ch]pp | |
276 | ) | |
277 | ||
278 | ADD_CUSTOM_TARGET( | |
279 | reformat | |
280 | COMMAND | |
281 | ${CLANG_FORMAT} | |
282 | -i | |
283 | -style=file | |
284 | ${CHECK_CXX_SOURCE_FILES} | |
285 | COMMENT "Auto formatting of all source files" | |
286 | ) | |
287 | ENDIF() |
0 | # Contributing | |
1 | ||
2 | ## Engaging in Our Project | |
3 | ||
4 | We use GitHub to manage reviews of pull requests. | |
5 | ||
6 | * If you are a new contributor, see: [Steps to Contribute](#steps-to-contribute) | |
7 | ||
8 | * Before implementing your change, create an issue that describes the problem you would like to solve or the code that should be enhanced. Please note that you are willing to work on that issue. | |
9 | ||
10 | * The team will review the issue and decide whether it should be implemented as a Pull Request. In that case, they will assign the issue to you. If the team decides against picking up the issue, it will be closed with a proper explanation. | |
11 | ||
12 | ## Steps to Contribute | |
13 | ||
14 | Should you wish to work on an issue, please claim it first by commenting on the GitHub issue that you want to work on. This is to prevent duplicated efforts from other contributors on the same issue. | |
15 | ||
16 | Only start working on the Pull Request after the team assigned the issue to you to avoid unnecessary efforts. | |
17 | ||
18 | If you have questions about one of the issues, please comment on them, and one of the maintainers will clarify. | |
19 | ||
20 | We kindly ask you to follow the [Pull Request Checklist](#Pull-Request-Checklist) to ensure reviews can happen accordingly. | |
21 | ||
22 | ## Contributing Code | |
23 | ||
24 | You are welcome to contribute code in order to fix a bug or to implement a new feature that is logged as an issue. | |
25 | ||
26 | Only start working on the Pull Request after the team assigned the issue to you to avoid unnecessary efforts. | |
27 | ||
28 | The following rule governs code contributions: | |
29 | ||
30 | * Contributions must be licensed under the [GPL-3.0 License](LICENSE) | |
31 | ||
32 | * This project loosely follows the [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html) | |
33 | ||
34 | * For better compatibility with embedded toolchains, the used C++ standard should be limited to C++14 | |
35 | ||
36 | * Code should be formatted by running `make reformat` | |
37 | ||
38 | ## Contributing Documentation | |
39 | ||
40 | You are welcome to contribute documentation to the project. | |
41 | ||
42 | The following rule governs documentation contributions: | |
43 | ||
44 | * Contributions must be licensed under the same license as code, the [GPL-3.0 License](LICENSE) | |
45 | ||
46 | ## Pull Request Checklist | |
47 | ||
48 | * Branch from the `develop` branch and ensure it is up to date with the current `develop` branch before submitting your pull request. If it doesn't merge cleanly with `develop`, you may be asked to resolve the conflicts. Pull requests to master will be closed. | |
49 | ||
50 | * Commits should be as small as possible while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests). | |
51 | ||
52 | * Pull requests must not contain compiled sources (already set by the default .gitignore) or binary files | |
53 | ||
54 | * Test your changes as thoroughly as possible before you commit them. Preferably, automate your test by unit/integration tests. If tested manually, provide information about the test scope in the PR description (e.g. “Test passed: Upgrade version from 0.42 to 0.42.23.”). | |
55 | ||
56 | * Create _Work In Progress [WIP]_ pull requests only if you need clarification or an explicit review before you can continue your work item. | |
57 | ||
58 | * If your patch is not getting reviewed or you need a specific person to review it, you can @-reply a reviewer asking for a review in the pull request or a comment, or you can ask for a review by contacting us via [email](mailto:snapcast@badaix.de). | |
59 | ||
60 | * Post review: | |
61 | * If a review requires you to change your commit(s), please test the changes again. | |
62 | * Amend the affected commit(s) and force push onto your branch. | |
63 | * Set respective comments in your GitHub review to resolved. | |
64 | * Create a general PR comment to notify the reviewers that your amendments are ready for another round of review. | |
65 | ||
66 | ## Issues and Planning | |
67 | ||
68 | * We use GitHub issues to track bugs and enhancement requests. | |
69 | ||
70 | * Please provide as much context as possible when you open an issue. The information you provide must be comprehensive enough to reproduce that issue for the assignee. | |
71 | ||
72 | * Attach a log file (preferably inline as code block) if neccessary. Use `debug` log level (`snapclient --logfilter debug`, `snapserver --logging.filter debug`). | |
73 | ||
74 | * Please apply one or more applicable [labels](https://github.com/badaix/snapcast/labels) to your issue so that all community members are able to cluster the issues better. |
0 | Snapcast | |
1 | ======== | |
0 | # Snapcast | |
2 | 1 | |
3 | 2 | ![Snapcast](https://raw.githubusercontent.com/badaix/snapcast/master/doc/Snapcast_800.png) |
4 | 3 | |
9 | 8 | [![Github Releases](https://img.shields.io/github/release/badaix/snapcast.svg)](https://github.com/badaix/snapcast/releases) |
10 | 9 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/badaix) |
11 | 10 | |
12 | Snapcast is a multiroom client-server audio player, where all clients are time synchronized with the server to play perfectly synced audio. It's not a standalone player, but an extension that turns your existing audio player into a Sonos-like multiroom solution. | |
13 | The server's audio input is a named pipe `/tmp/snapfifo`. All data that is fed into this file will be sent to the connected clients. One of the most generic ways to use Snapcast is in conjunction with the music player daemon ([MPD](http://www.musicpd.org/)) or [Mopidy](https://www.mopidy.com/), which can be configured to use a named pipe as audio output. | |
14 | ||
15 | How does it work | |
16 | ---------------- | |
17 | The Snapserver reads PCM chunks from the pipe `/tmp/snapfifo`. The chunk is encoded and tagged with the local time. Supported codecs are: | |
18 | * **PCM** lossless uncompressed | |
19 | * **FLAC** lossless compressed [default] | |
20 | * **Vorbis** lossy compression | |
21 | * **Opus** lossy low-latency compression | |
22 | ||
23 | The encoded chunk is sent via a TCP connection to the Snapclients. | |
11 | Snapcast is a multiroom client-server audio player, where all clients are time synchronized with the server to play perfectly synced audio. It's not a standalone player, but an extension that turns your existing audio player into a Sonos-like multiroom solution. | |
12 | Audio is captured by the server and routed to the connected clients. Several players can feed audio to the server in parallel and clients can be grouped to play the same audio stream. | |
13 | One of the most generic ways to use Snapcast is in conjunction with the music player daemon ([MPD](http://www.musicpd.org/)) or [Mopidy](https://www.mopidy.com/). | |
14 | ||
15 | ![Overview](https://raw.githubusercontent.com/badaix/snapcast/master/doc/Overview.png) | |
16 | ||
17 | ## How does it work | |
18 | ||
19 | The Snapserver reads PCM chunks from configurable stream sources: | |
20 | ||
21 | - **Named pipe**, e.g. `/tmp/snapfifo` | |
22 | - **ALSA** to capture line-in, microphone, alsa-loop (to capture audio from other players) | |
23 | - **TCP** | |
24 | - **stdout** of a process | |
25 | - Many more | |
26 | ||
27 | The chunks are encoded and tagged with the local time. Supported codecs are: | |
28 | ||
29 | - **PCM** lossless uncompressed | |
30 | - **FLAC** lossless compressed [default] | |
31 | - **Vorbis** lossy compression | |
32 | - **Opus** lossy low-latency compression | |
33 | ||
34 | The encoded chunks are sent via a TCP connection to the Snapclients. | |
24 | 35 | Each client does continuous time synchronization with the server, so that the client is always aware of the local server time. |
25 | 36 | Every received chunk is first decoded and added to the client's chunk-buffer. Knowing the server's time, the chunk is played out using a system dependend low level audio API (e.g. ALSA) at the appropriate time. Time deviations are corrected by playing faster/slower, which is done by removing/duplicating single samples (a sample at 48kHz has a duration of ~0.02ms). |
26 | 37 | |
28 | 39 | |
29 | 40 | For more information on the binary protocol, please see the [documentation](doc/binary_protocol.md). |
30 | 41 | |
31 | Installation | |
32 | ------------ | |
42 | ## Installation | |
43 | ||
33 | 44 | You can either install Snapcast from a prebuilt package (recommended for new users), or build and install snapcast from source. |
34 | 45 | |
35 | 46 | ### Install Linux packages (recommended for beginners) |
36 | Snapcast packages are available for several Linux distributions. | |
37 | ||
38 | #### Debian | |
39 | For Debian (and Debian-based systems, such as Ubuntu, Linux Mint, ElementaryOS) download the package for your CPU architecture from the [latest release page](https://github.com/badaix/snapcast/releases/latest). | |
40 | ||
41 | e.g. for Raspberry Pi `snapclient_0.x.x_armhf.deb`, for laptops `snapclient_0.x.x_amd64.deb` | |
42 | ||
43 | Install the package: | |
44 | ||
45 | $ sudo dpkg -i snapclient_0.x.x_armhf.deb | |
46 | or | |
47 | $ sudo dpkg -i snapclient_0.x.x_amd64.deb | |
48 | ||
49 | Install missing dependencies: | |
50 | ||
51 | $ sudo apt-get -f install | |
52 | ||
53 | #### OpenWrt | |
54 | On OpenWrt do: | |
55 | ||
56 | $ opkg install snapclient_0.x.x_ar71xx.ipk | |
57 | ||
58 | #### Alpine Linux | |
59 | On Alpine Linux do: | |
60 | ||
61 | $ apk add snapcast | |
62 | ||
63 | Or, for just the client: | |
64 | ||
65 | $ apk add snapcast-client | |
66 | ||
67 | Or, for just the server: | |
68 | ||
69 | $ apk add snapcast-server | |
70 | ||
71 | #### Gentoo Linux | |
72 | On Gentoo Linux do: | |
73 | ||
74 | $ emerge --ask media-sound/snapcast | |
75 | ||
76 | #### Archlinux | |
77 | On Archlinux, Snapcast is available through the AUR. To install, use your favorite AUR helper, or do: | |
78 | ||
79 | $ git clone https://aur.archlinux.org/snapcast | |
80 | $ cd snapcast | |
81 | $ makepkg -si | |
82 | ||
83 | #### Void Linux | |
84 | To install the client: | |
85 | ||
86 | # xbps-install snapclient | |
87 | ||
88 | To install the server: | |
89 | ||
90 | # xbps-install snapserver | |
91 | ||
47 | ||
48 | Snapcast packages are available for several Linux distributions: | |
49 | ||
50 | - [Debian](doc/install.md#debian) | |
51 | - [OpenWrt](doc/install.md#openwrt) | |
52 | - [Alpine Linux](doc/install.md#alpine-linux) | |
53 | - [Archlinux](doc/install.md#archlinux) | |
54 | - [Void Linux](doc/install.md#void-linux) | |
55 | ||
56 | ### Nightly builds | |
57 | ||
58 | There are debian packages of automated builds for [armhf](https://github.com/badaix/snapcast/actions?query=workflow%3Aself-hosted) and [amd64](https://github.com/badaix/snapcast/actions?query=workflow%3AUbuntu) available in [Actions](https://github.com/badaix/snapcast/actions). | |
59 | Download and extract the archive for your architecture and follow the [debian installation instructions](doc/install.md#debian). | |
60 | ||
92 | 61 | ### Installation from source |
93 | 62 | |
94 | 63 | Please follow this [guide](doc/build.md) to build Snapcast for |
95 | 64 | |
96 | * [Linux](doc/build.md#linux-native) | |
97 | ||
98 | * [FreeBSD](doc/build.md#freebsd-native) | |
99 | ||
100 | * [macOS](doc/build.md#macos-native) | |
101 | ||
102 | * [Android](doc/build.md#android-cross-compile) | |
103 | ||
104 | * [OpenWrt](doc/build.md#openwrtlede-cross-compile) | |
105 | ||
106 | * [Buildroot](doc/build.md#buildroot-cross-compile) | |
107 | ||
108 | * [Raspberry Pi](doc/build.md#raspberry-pi-cross-compile) | |
109 | ||
110 | SnapOS | |
111 | ------ | |
65 | - [Linux](doc/build.md#linux-native) | |
66 | - [FreeBSD](doc/build.md#freebsd-native) | |
67 | - [macOS](doc/build.md#macos-native) | |
68 | - [Android](doc/build.md#android-cross-compile) | |
69 | - [OpenWrt](doc/build.md#openwrtlede-cross-compile) | |
70 | - [Buildroot](doc/build.md#buildroot-cross-compile) | |
71 | - [Raspberry Pi](doc/build.md#raspberry-pi-cross-compile) | |
72 | - [Windows](doc/build.md#windows-vcpkg) | |
73 | ||
74 | ## SnapOS | |
75 | ||
112 | 76 | The bravest among you may be interested in [SnapOS](https://github.com/badaix/snapos), a small and fast-booting "just enough" OS to run Snapcast as an appliance. |
113 | 77 | |
114 | 78 | There is a guide (with the necessary buildfiles) available to build SnapOS, which comes in two flavors: |
79 | ||
115 | 80 | - [Buildroot](https://github.com/badaix/snapos/blob/master/buildroot-external/README.md) based, or |
116 | 81 | - [OpenWrt](https://github.com/badaix/snapos/tree/master/openwrt) based. |
117 | 82 | |
118 | 83 | Please note that there are no pre-built firmware packages available. |
119 | 84 | |
120 | Configuration | |
121 | ------------- | |
85 | ## Configuration | |
86 | ||
122 | 87 | After installation, Snapserver and Snapclient are started with the command line arguments that are configured in `/etc/default/snapserver` and `/etc/default/snapclient`. |
123 | 88 | Allowed options are listed in the man pages (`man snapserver`, `man snapclient`) or by invoking the snapserver or snapclient with the `-h` option. |
124 | 89 | |
125 | The server configuration is done in `/etc/snapserver.conf`. Different streams can by configured in the `[stream]` section with a list of `stream` options, e.g.: | |
126 | ||
127 | ``` | |
128 | [stream] | |
129 | stream = pipe:///tmp/snapfifo?name=Radio&sampleformat=48000:16:2&codec=flac | |
130 | stream = file:///home/user/Musik/Some%20wave%20file.wav?name=File | |
131 | ``` | |
132 | ||
133 | The pipe stream (`stream = pipe`) will per default create the pipe. Sometimes your audio source might insist in creating the pipe itself. So the pipe creation mode can by changed to "not create, but only read mode", using the `mode` option set to `create` or `read`: | |
134 | ||
135 | stream = pipe:///tmp/snapfifo?name=Radio&mode=read" | |
136 | ||
137 | Test | |
138 | ---- | |
90 | The server configuration is done in `/etc/snapserver.conf`. Different audio sources can by configured in the `[stream]` section with a list of `source` options, e.g.: | |
91 | ||
92 | [stream] | |
93 | source = pipe:///tmp/snapfifo?name=Radio&sampleformat=48000:16:2&codec=flac | |
94 | source = file:///home/user/Musik/Some%20wave%20file.wav?name=File | |
95 | ||
96 | Available stream sources are: | |
97 | ||
98 | - [pipe](doc/configuration.md#pipe): read audio from a named pipe | |
99 | - [alsa](doc/configuration.md#alsa): read audio from an alsa device | |
100 | - [librespot](doc/configuration.md#librespot): launches librespot and reads audio from stdout | |
101 | - [airplay](doc/configuration.md#airplay): launches airplay and read audio from stdout | |
102 | - [file](doc/configuration.md#file): read PCM audio from a file | |
103 | - [process](doc/configuration.md#process): launches a process and reads audio from stdout | |
104 | - [tcp](doc/configuration.md#tcp-server): receives audio from a TCP socket, can act as client or server | |
105 | - [meta](doc/configuration.md#meta): read and mix audio from other stream sources | |
106 | ||
107 | The client will use as audio backend the system's low level audio API to have the best possible control and most precise timing to achieve perfectly synced playback. On Linux `alsa` is used, on Android `oboe` or `opensl`, on macOS `coreaudio` and on Windows `wasapi`. | |
108 | There is also a `file` backend available that will write the raw PCM data to a file (or stdout, stderr). The backend can be configured using the `--player` command line parameter. | |
109 | ||
110 | ## Test | |
111 | ||
139 | 112 | You can test your installation by copying random data into the server's fifo file |
140 | 113 | |
141 | $ sudo cat /dev/urandom > /tmp/snapfifo | |
114 | sudo cat /dev/urandom > /tmp/snapfifo | |
142 | 115 | |
143 | 116 | All connected clients should play random noise now. You might raise the client's volume with "alsamixer". |
144 | 117 | It's also possible to let the server play a WAV file. Simply configure a `file` stream in `/etc/snapserver.conf`, and restart the server: |
145 | 118 | |
146 | ``` | |
147 | [stream] | |
148 | stream = file:///home/user/Musik/Some%20wave%20file.wav?name=test | |
149 | ``` | |
119 | [stream] | |
120 | stream = file:///home/user/Musik/Some%20wave%20file.wav?name=test | |
150 | 121 | |
151 | 122 | When you are using a Raspberry Pi, you might have to change your audio output to the 3.5mm jack: |
152 | 123 | |
153 | 124 | #The last number is the audio output with 1 being the 3.5 jack, 2 being HDMI and 0 being auto. |
154 | $ amixer cset numid=3 1 | |
155 | ||
156 | To setup WiFi on a Raspberry Pi, you can follow this guide: | |
157 | https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md | |
158 | ||
159 | Control | |
160 | ------- | |
161 | Snapcast can be controlled using a [JSON-RPC API](doc/json_rpc_api/v2_0_0.md): | |
162 | * Set client's volume | |
163 | * Mute clients | |
164 | * Rename clients | |
165 | * Assign a client to a stream | |
166 | * ... | |
125 | amixer cset numid=3 1 | |
126 | ||
127 | To setup WiFi on a Raspberry Pi, you can follow this [guide](https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md) | |
128 | ||
129 | ## Control | |
130 | ||
131 | Snapcast can be controlled using a [JSON-RPC API](doc/json_rpc_api/v2_0_0.md) over plain TCP, HTTP, or Websockets: | |
132 | ||
133 | - Set client's volume | |
134 | - Mute clients | |
135 | - Rename clients | |
136 | - Assign a client to a stream | |
137 | - Manage groups | |
138 | - ... | |
139 | ||
140 | ### WebApp | |
141 | ||
142 | The server is shipped with [Snapweb](https://github.com/badaix/snapweb), this WebApp can be reached under `http://<snapserver host>:1780`. | |
143 | ||
144 | ![Snapweb](https://raw.githubusercontent.com/badaix/snapweb/master/snapweb.png) | |
145 | ||
146 | ### Android client | |
167 | 147 | |
168 | 148 | There is an Android client [snapdroid](https://github.com/badaix/snapdroid) available in [Releases](https://github.com/badaix/snapdroid/releases/latest) and on [Google Play](https://play.google.com/store/apps/details?id=de.badaix.snapcast) |
169 | 149 | |
170 | 150 | ![Snapcast for Android](https://raw.githubusercontent.com/badaix/snapcast/master/doc/snapcast_android_scaled.png) |
151 | ||
152 | ### Contributions | |
171 | 153 | |
172 | 154 | There is also an unofficial WebApp from @atoomic [atoomic/snapcast-volume-ui](https://github.com/atoomic/snapcast-volume-ui). |
173 | 155 | This app lists all clients connected to a server and allows you to control individually the volume of each client. |
183 | 165 | |
184 | 166 | A web interface called [HydraPlay](https://github.com/mariolukas/HydraPlay) integrates Snapcast and multiple Mopidy instances. It is JavaScript based and uses Angular 7. A Snapcast web socket proxy server is needed to connect Snapcast to HydraPlay over web sockets. |
185 | 167 | |
186 | Setup of audio players/server | |
187 | ----------------------------- | |
168 | For Windows, there's [Snap.Net](https://github.com/stijnvdb88/snap.net), a control client and player. It runs in the tray and lets you adjust client volumes with just a few clicks. The player simplifies setting up snapclient to play your music through multiple Windows sound devices simultaneously: pc speakers, hdmi audio, any usb audio devices you may have, etc. Snap.Net also runs on Android, and has limited support for iOS. | |
169 | ||
170 | ## Setup of audio players/server | |
171 | ||
188 | 172 | Snapcast can be used with a number of different audio players and servers, and so it can be integrated into your favorite audio-player solution and make it synced-multiroom capable. |
189 | 173 | The only requirement is that the player's audio can be redirected into the Snapserver's fifo `/tmp/snapfifo`. In the following configuration hints for [MPD](http://www.musicpd.org/) and [Mopidy](https://www.mopidy.com/) are given, which are base of other audio player solutions, like [Volumio](https://volumio.org/) or [RuneAudio](http://www.runeaudio.com/) (both MPD) or [Pi MusicBox](http://www.pimusicbox.com/) (Mopidy). |
190 | 174 | |
193 | 177 | audio player software -> snapfifo -> snapserver -> network -> snapclient -> alsa |
194 | 178 | |
195 | 179 | This [guide](doc/player_setup.md) shows how to configure different players/audio sources to redirect their audio signal into the Snapserver's fifo: |
196 | * [MPD](doc/player_setup.md#mpd) | |
197 | * [Mopidy](doc/player_setup.md#mopidy) | |
198 | * [FFmpeg](doc/player_setup.md#ffmpeg) | |
199 | * [mpv](doc/player_setup.md#mpv) | |
200 | * [MPlayer](doc/player_setup.md#mplayer) | |
201 | * [Alsa](doc/player_setup.md#alsa) | |
202 | * [PulseAudio](doc/player_setup.md#pulseaudio) | |
203 | * [AirPlay](doc/player_setup.md#airplay) | |
204 | * [Spotify](doc/player_setup.md#spotify) | |
205 | * [Process](doc/player_setup.md#process) | |
206 | * [Line-in](doc/player_setup.md#line-in) | |
207 | ||
208 | Roadmap | |
209 | ------- | |
180 | ||
181 | - [MPD](doc/player_setup.md#mpd) | |
182 | - [Mopidy](doc/player_setup.md#mopidy) | |
183 | - [FFmpeg](doc/player_setup.md#ffmpeg) | |
184 | - [mpv](doc/player_setup.md#mpv) | |
185 | - [MPlayer](doc/player_setup.md#mplayer) | |
186 | - [Alsa](doc/player_setup.md#alsa) | |
187 | - [PulseAudio](doc/player_setup.md#pulseaudio) | |
188 | - [AirPlay](doc/player_setup.md#airplay) | |
189 | - [Spotify](doc/player_setup.md#spotify) | |
190 | - [Process](doc/player_setup.md#process) | |
191 | - [Line-in](doc/player_setup.md#line-in) | |
192 | - [VLC](doc/player_setup.md#vlc) | |
193 | ||
194 | ## Roadmap | |
195 | ||
210 | 196 | Unordered list of features that should make it into the v1.0 |
197 | ||
211 | 198 | - [X] **Remote control** JSON-RPC API to change client latency, volume, zone,... |
212 | 199 | - [X] **Android client** JSON-RPC client and Snapclient |
213 | 200 | - [X] **Streams** Support multiple streams |
214 | 201 | - [X] **Debian packages** prebuild deb packages |
215 | 202 | - [X] **Endian** independent code |
216 | 203 | - [X] **OpenWrt** port Snapclient to OpenWrt |
217 | - [X] **Hi-Res audio** support (like 192kHz 24bit) | |
204 | - [X] **Hi-Res audio** support (like 96kHz 24bit) | |
218 | 205 | - [X] **Groups** support multiple Groups of clients ("Zones") |
206 | - [X] **Ports** Snapclient for Windows, Mac OS X,... | |
219 | 207 | - [ ] **JSON-RPC** Possibility to add, remove, rename streams |
220 | 208 | - [ ] **Protocol specification** Snapcast binary streaming protocol, JSON-RPC protocol |
221 | - [ ] **Ports** Snapclient for Windows, ~~Mac OS X~~,... |
0 | 0 | # Snapcast changelog |
1 | ||
2 | ## Version 0.22.0 | |
3 | ||
4 | ### Features | |
5 | ||
6 | - Server: Add Meta stream source (Issue #402, #569, #666) | |
7 | - Client: Add file audio backend (Issue #681) | |
8 | ||
9 | ### Bugfixes | |
10 | ||
11 | - Add missing define for alsa stream to makefile (Issue #692) | |
12 | - Fix playback when plugging the headset on Android (Issue #699) | |
13 | - Server discards old chunks if not consumed (Issue #708) | |
14 | ||
15 | ### General | |
16 | ||
17 | - Less verbose logging during pipe reconnects (Issue #696) | |
18 | - Add null encoder for streams used only as input for meta streams | |
19 | - Snapweb: Change latency range to [-10s, 10s] (Issue #695) | |
20 | - Update Snapweb, including PR #11, #12, #13, Issues #16, #17 | |
21 | ||
22 | _Johannes Pohl <snapcast@badaix.de> Thu, 15 Oct 2020 00:13:37 +0200_ | |
23 | ||
24 | ## Version 0.21.0 | |
25 | ||
26 | ### Features | |
27 | ||
28 | - Server: Support for WebSocket streaming clients | |
29 | - Server: Install Snapweb web client (Issue #579) | |
30 | - Server: Resample input to 48000:16:2 when using opus codec | |
31 | - Server: Add Alsa stream source | |
32 | ||
33 | ### Bugfixes | |
34 | ||
35 | - make install will setup the snapserver home dir (Issue #643) | |
36 | - Client retries to open a blocked alsa device (Issue #652) | |
37 | ||
38 | ### General | |
39 | ||
40 | - debian packag generation switched from make to CMake buildsystem | |
41 | - Reintroduce MACOS define, hopefully not breaking anything on macOS | |
42 | - Snapcast uses GitHub actions for automated CI/CD | |
43 | - CMake installs man files (Issue #507) | |
44 | - Update documentation (Issue #615, #617) | |
45 | ||
46 | _Johannes Pohl <snapcast@badaix.de> Sun, 13 Sep 2020 00:13:37 +0200_ | |
47 | ||
48 | ## Version 0.20.0 | |
49 | ||
50 | ### Features | |
51 | ||
52 | - Client: Windows support (Issue #24) | |
53 | - Client: add hardware mixer (Issue #318) | |
54 | - Client: add "script" and "none" mixer (Issue #302) | |
55 | - Client: add sharingmode for audio device (if supported) | |
56 | - Logging: configurable sink and filters (Issue #30, #561, #122, #559) | |
57 | - Librespot: add option "disable-audio-cache=[false|true]" | |
58 | ||
59 | ### Bugfixes | |
60 | ||
61 | - Fix build failure on FreeBSD (Issue #565) | |
62 | - Fix calling lsb_release multiple times (Issue #470) | |
63 | - Client: high CPU load and crash during playback (Issue #609, #628) | |
64 | - Client: improved handling of USB audio disconnects (Issue #64) | |
65 | - Client: latency is forgotten (Issue #476, #588, Snapdroid #11) | |
66 | - Client: fix segfault on mac when playback is paused (Issue #560) | |
67 | - Client: fix bonjour on mac reports empty IP (Issue #632) | |
68 | - Client: fix buzzing tone on Android (Issue #23, #24) | |
69 | - Server: fix crash if client disconnects during connect (Issue #639) | |
70 | - Server: fix reading metadata from shairport-sync (Issue #624) | |
71 | - Server: fix crash on FreeBSD if settings.json is empty (Issue #620) | |
72 | - Server: fix warning about unknown command line options (Issue #635) | |
73 | - Readme: openWrt documentation (Issue #633) | |
74 | - Fix setting the daemon's process priority (PR #448) | |
75 | ||
76 | ### General | |
77 | ||
78 | - Client: use less threads and thus less ressources | |
79 | - Update links to xiph externals (Issue #637, PR #616) | |
80 | ||
81 | _Johannes Pohl <snapcast@badaix.de> Sat, 13 Jun 2020 00:13:37 +0200_ | |
1 | 82 | |
2 | 83 | ## Version 0.19.0 |
3 | 84 |
4 | 4 | stream.cpp |
5 | 5 | time_provider.cpp |
6 | 6 | decoder/pcm_decoder.cpp |
7 | player/player.cpp) | |
7 | player/player.cpp | |
8 | player/file_player.cpp) | |
8 | 9 | |
9 | 10 | set(CLIENT_LIBRARIES ${CMAKE_THREAD_LIBS_INIT} ${ATOMIC_LIBRARY} common) |
10 | 11 | |
11 | 12 | set(CLIENT_INCLUDE |
12 | 13 | ${Boost_INCLUDE_DIR} |
13 | 14 | ${CMAKE_SOURCE_DIR}/client |
14 | ${CMAKE_SOURCE_DIR}/common | |
15 | ${ASIO_INCLUDE_DIRS} | |
16 | ${POPL_INCLUDE_DIRS}) | |
15 | ${CMAKE_SOURCE_DIR}/common) | |
17 | 16 | |
18 | 17 | |
19 | 18 | if(MACOSX) |
27 | 26 | list(APPEND CLIENT_SOURCES player/coreaudio_player.cpp) |
28 | 27 | find_library(COREAUDIO_LIB CoreAudio) |
29 | 28 | find_library(COREFOUNDATION_LIB CoreFoundation) |
29 | find_library(IOKIT_LIB IOKit) | |
30 | 30 | find_library(AUDIOTOOLBOX_LIB AudioToolbox) |
31 | list(APPEND CLIENT_LIBRARIES ${COREAUDIO_LIB} ${COREFOUNDATION_LIB} ${AUDIOTOOLBOX_LIB}) | |
31 | list(APPEND CLIENT_LIBRARIES ${COREAUDIO_LIB} ${COREFOUNDATION_LIB} ${IOKIT_LIB} ${AUDIOTOOLBOX_LIB}) | |
32 | elseif (WIN32) | |
33 | list(APPEND CLIENT_SOURCES player/wasapi_player.cpp) | |
34 | list(APPEND CLIENT_LIBRARIES wsock32 ws2_32 avrt ksuser iphlpapi) | |
32 | 35 | else() |
33 | 36 | # Avahi |
34 | 37 | if (AVAHI_FOUND) |
44 | 47 | list(APPEND CLIENT_INCLUDE ${ALSA_INCLUDE_DIRS}) |
45 | 48 | endif (ALSA_FOUND) |
46 | 49 | endif (MACOSX) |
47 | ||
48 | #pkg_search_module(SOXR soxr) | |
49 | find_package(soxr) | |
50 | if (SOXR_FOUND) | |
51 | add_definitions("-DHAS_SOXR") | |
52 | list(APPEND CLIENT_LIBRARIES ${SOXR_LIBRARIES}) | |
53 | list(APPEND CLIENT_INCLUDE ${SOXR_INCLUDE_DIRS}) | |
54 | endif (SOXR_FOUND) | |
55 | 50 | |
56 | 51 | # if OGG then tremor or vorbis |
57 | 52 | if (OGG_FOUND) |
86 | 81 | target_link_libraries(snapclient ${CLIENT_LIBRARIES}) |
87 | 82 | |
88 | 83 | install(TARGETS snapclient COMPONENT client DESTINATION "${CMAKE_INSTALL_BINDIR}") |
84 | install(FILES snapclient.1 COMPONENT client DESTINATION "${CMAKE_INSTALL_MANDIR}/man1") | |
85 |
13 | 13 | # You should have received a copy of the GNU General Public License |
14 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 | VERSION = 0.19.0 | |
16 | VERSION = 0.22.0 | |
17 | 17 | BIN = snapclient |
18 | 18 | |
19 | 19 | ifeq ($(TARGET), FREEBSD) |
41 | 41 | LDFLAGS += -fsanitize=$(SANITIZE) |
42 | 42 | endif |
43 | 43 | |
44 | CXXFLAGS += $(ADD_CFLAGS) -std=c++14 -Wall -Wextra -Wpedantic -Wno-unused-function -DBOOST_ERROR_CODE_HEADER_ONLY -DHAS_FLAC -DHAS_OGG -DHAS_OPUS -DVERSION=\"$(VERSION)\" -I. -I.. -I../common | |
44 | CXXFLAGS += $(ADD_CFLAGS) -std=c++14 -Wall -Wextra -Wpedantic -Wno-unused-function -DBOOST_ERROR_CODE_HEADER_ONLY -DHAS_FLAC -DHAS_OGG -DHAS_OPUS -DHAS_SOXR -DVERSION=\"$(VERSION)\" -I. -I.. -I../common | |
45 | 45 | LDFLAGS += $(ADD_LDFLAGS) -logg -lFLAC -lopus -lsoxr |
46 | OBJ = snapclient.o stream.o client_connection.o time_provider.o player/player.o decoder/pcm_decoder.o decoder/ogg_decoder.o decoder/flac_decoder.o decoder/opus_decoder.o controller.o ../common/sample_format.o | |
46 | OBJ = snapclient.o stream.o client_connection.o time_provider.o player/player.o player/file_player.o decoder/pcm_decoder.o decoder/ogg_decoder.o decoder/flac_decoder.o decoder/opus_decoder.o controller.o ../common/sample_format.o ../common/resampler.o | |
47 | 47 | |
48 | 48 | |
49 | 49 | ifneq (,$(TARGET)) |
57 | 57 | ifeq ($(TARGET), ANDROID) |
58 | 58 | |
59 | 59 | CXX = $(PROGRAM_PREFIX)clang++ |
60 | CXXFLAGS += -pthread -fPIC -DHAS_TREMOR -DHAS_OPENSL -DHAS_OBOE -I$(NDK_DIR)/usr/local/include | |
61 | LDFLAGS = -L$(NDK_DIR)/usr/local/lib -pie -lvorbisidec -logg -lopus -lFLAC -lOpenSLES -loboe -latomic -llog -lsoxr -static-libstdc++ | |
60 | CXXFLAGS += -pthread -fPIC -DHAS_TREMOR -DHAS_OPENSL -DHAS_OBOE -I$(TOOLCHAIN)/sysroot/usr/include -I$(TOOLCHAIN)/sysroot/$(NDK_TARGET)/usr/local/include | |
61 | LDFLAGS = -L$(TOOLCHAIN)/sysroot/$(NDK_TARGET)/usr/local/lib -pie -lvorbisidec -logg -lFLAC -lopus -lsoxr -lOpenSLES -loboe -latomic -llog -static-libstdc++ | |
62 | 62 | OBJ += player/opensl_player.o player/oboe_player.o |
63 | 63 | |
64 | 64 | else ifeq ($(TARGET), OPENWRT) |
65 | 65 | |
66 | CXXFLAGS += -pthread -DNO_CPP11_STRING -DHAS_TREMOR -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON -DHAS_SOXR | |
66 | CXXFLAGS += -pthread -DNO_CPP11_STRING -DHAS_TREMOR -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON | |
67 | 67 | LDFLAGS += -lasound -lvorbisidec -lavahi-client -lavahi-common -latomic |
68 | 68 | OBJ += ../common/daemon.o player/alsa_player.o browseZeroConf/browse_avahi.o |
69 | 69 | |
70 | 70 | else ifeq ($(TARGET), BUILDROOT) |
71 | 71 | |
72 | CXXFLAGS += -pthread -DNO_CPP11_STRING -DHAS_TREMOR -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON -DHAS_SOXR | |
72 | CXXFLAGS += -pthread -DNO_CPP11_STRING -DHAS_TREMOR -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON | |
73 | 73 | LDFLAGS += -lasound -lvorbisidec -lavahi-client -lavahi-common -latomic |
74 | 74 | OBJ += ../common/daemon.o player/alsa_player.o browseZeroConf/browse_avahi.o |
75 | 75 | |
76 | 76 | else ifeq ($(TARGET), MACOS) |
77 | 77 | |
78 | 78 | CXX = g++ |
79 | CXXFLAGS += -DHAS_COREAUDIO -DHAS_VORBIS -DFREEBSD -DHAS_BONJOUR -DHAS_DAEMON -DHAS_SOXR -I/usr/local/include -Wno-unused-local-typedef -Wno-deprecated | |
79 | CXXFLAGS += -DHAS_COREAUDIO -DHAS_VORBIS -DFREEBSD -DMACOS -DHAS_BONJOUR -DHAS_DAEMON -I/usr/local/include -Wno-unused-local-typedef -Wno-deprecated | |
80 | 80 | LDFLAGS += -lvorbis -lFLAC -L/usr/local/lib -framework AudioToolbox -framework CoreAudio -framework CoreFoundation -framework IOKit |
81 | 81 | OBJ += ../common/daemon.o player/coreaudio_player.o browseZeroConf/browse_bonjour.o |
82 | 82 | |
83 | 83 | else |
84 | 84 | |
85 | 85 | CXX = g++ |
86 | CXXFLAGS += -pthread -DHAS_VORBIS -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON -DHAS_SOXR | |
86 | CXXFLAGS += -pthread -DHAS_VORBIS -DHAS_ALSA -DHAS_AVAHI -DHAS_DAEMON | |
87 | 87 | LDFLAGS += -lrt -lasound -lvorbis -lavahi-client -lavahi-common -latomic |
88 | 88 | OBJ += ../common/daemon.o player/alsa_player.o browseZeroConf/browse_avahi.o |
89 | 89 | |
94 | 94 | |
95 | 95 | check-env: |
96 | 96 | ifeq ($(TARGET), ANDROID) |
97 | $(eval TOOLCHAIN:=$(NDK_DIR)/toolchains/llvm/prebuilt/linux-x86_64) | |
97 | 98 | ifndef NDK_DIR |
98 | 99 | $(error android NDK_DIR is not set) |
99 | 100 | endif |
100 | 101 | ifndef ARCH |
101 | $(error ARCH is not set (arm, mips, x86)) | |
102 | $(error ARCH is not set (arm, aarch64, x86)) | |
102 | 103 | endif |
103 | 104 | ifeq ($(ARCH), x86) |
104 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/i686-linux-android-) | |
105 | else ifeq ($(ARCH), mips) | |
106 | $(eval CXXFLAGS:=$(CXXFLAGS) -DIS_BIG_ENDIAN) | |
107 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/mipsel-linux-android-) | |
105 | $(eval NDK_TARGET:=x86_64-linux-android) | |
106 | $(eval API:=21) | |
108 | 107 | else ifeq ($(ARCH), arm) |
108 | $(eval NDK_TARGET:=armv7a-linux-androideabi) | |
109 | $(eval API:=16) | |
109 | 110 | $(eval CXXFLAGS:=$(CXXFLAGS) -march=armv7) |
110 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/arm-linux-androideabi-) | |
111 | else ifeq ($(ARCH), arm64) | |
112 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/aarch64-linux-android-) | |
113 | endif | |
111 | else ifeq ($(ARCH), aarch64) | |
112 | $(eval NDK_TARGET:=aarch64-linux-android) | |
113 | $(eval API:=21) | |
114 | endif | |
115 | $(eval PROGRAM_PREFIX:=$(TOOLCHAIN)/bin/$(NDK_TARGET)$(API)-) | |
114 | 116 | endif |
115 | 117 | |
116 | 118 | $(BIN): $(OBJ) |
28 | 28 | |
29 | 29 | static AvahiSimplePoll* simple_poll = nullptr; |
30 | 30 | |
31 | static constexpr auto LOG_TAG = "Avahi"; | |
32 | ||
31 | 33 | |
32 | 34 | BrowseAvahi::BrowseAvahi() : client_(nullptr), sb_(nullptr) |
33 | 35 | { |
69 | 71 | switch (event) |
70 | 72 | { |
71 | 73 | case AVAHI_RESOLVER_FAILURE: |
72 | LOG(ERROR) << "(Resolver) Failed to resolve service '" << name << "' of type '" << type << "' in domain '" << domain | |
73 | << "': " << avahi_strerror(avahi_client_errno(avahi_service_resolver_get_client(r))) << "\n"; | |
74 | LOG(ERROR, LOG_TAG) << "(Resolver) Failed to resolve service '" << name << "' of type '" << type << "' in domain '" << domain | |
75 | << "': " << avahi_strerror(avahi_client_errno(avahi_service_resolver_get_client(r))) << "\n"; | |
74 | 76 | break; |
75 | 77 | |
76 | 78 | case AVAHI_RESOLVER_FOUND: |
77 | 79 | { |
78 | 80 | char a[AVAHI_ADDRESS_STR_MAX], *t; |
79 | 81 | |
80 | LOG(INFO) << "Service '" << name << "' of type '" << type << "' in domain '" << domain << "':\n"; | |
82 | LOG(INFO, LOG_TAG) << "Service '" << name << "' of type '" << type << "' in domain '" << domain << "':\n"; | |
81 | 83 | |
82 | 84 | avahi_address_snprint(a, sizeof(a), address); |
83 | 85 | browseAvahi->result_.host = host_name; |
89 | 91 | browseAvahi->result_.iface_idx = interface; |
90 | 92 | |
91 | 93 | t = avahi_string_list_to_string(txt); |
92 | LOG(INFO) << "\t" << host_name << ":" << port << " (" << a << ")\n"; | |
93 | LOG(DEBUG) << "\tTXT=" << t << "\n"; | |
94 | LOG(DEBUG) << "\tProto=" << (int)protocol << "\n"; | |
95 | LOG(DEBUG) << "\tcookie is " << avahi_string_list_get_service_cookie(txt) << "\n"; | |
96 | LOG(DEBUG) << "\tis_local: " << !!(flags & AVAHI_LOOKUP_RESULT_LOCAL) << "\n"; | |
97 | LOG(DEBUG) << "\tour_own: " << !!(flags & AVAHI_LOOKUP_RESULT_OUR_OWN) << "\n"; | |
98 | LOG(DEBUG) << "\twide_area: " << !!(flags & AVAHI_LOOKUP_RESULT_WIDE_AREA) << "\n"; | |
99 | LOG(DEBUG) << "\tmulticast: " << !!(flags & AVAHI_LOOKUP_RESULT_MULTICAST) << "\n"; | |
100 | LOG(DEBUG) << "\tcached: " << !!(flags & AVAHI_LOOKUP_RESULT_CACHED) << "\n"; | |
94 | LOG(INFO, LOG_TAG) << "\t" << host_name << ":" << port << " (" << a << ")\n"; | |
95 | LOG(DEBUG, LOG_TAG) << "\tTXT=" << t << "\n"; | |
96 | LOG(DEBUG, LOG_TAG) << "\tProto=" << (int)protocol << "\n"; | |
97 | LOG(DEBUG, LOG_TAG) << "\tcookie is " << avahi_string_list_get_service_cookie(txt) << "\n"; | |
98 | LOG(DEBUG, LOG_TAG) << "\tis_local: " << !!(flags & AVAHI_LOOKUP_RESULT_LOCAL) << "\n"; | |
99 | LOG(DEBUG, LOG_TAG) << "\tour_own: " << !!(flags & AVAHI_LOOKUP_RESULT_OUR_OWN) << "\n"; | |
100 | LOG(DEBUG, LOG_TAG) << "\twide_area: " << !!(flags & AVAHI_LOOKUP_RESULT_WIDE_AREA) << "\n"; | |
101 | LOG(DEBUG, LOG_TAG) << "\tmulticast: " << !!(flags & AVAHI_LOOKUP_RESULT_MULTICAST) << "\n"; | |
102 | LOG(DEBUG, LOG_TAG) << "\tcached: " << !!(flags & AVAHI_LOOKUP_RESULT_CACHED) << "\n"; | |
101 | 103 | avahi_free(t); |
102 | 104 | } |
103 | 105 | } |
119 | 121 | switch (event) |
120 | 122 | { |
121 | 123 | case AVAHI_BROWSER_FAILURE: |
122 | LOG(ERROR) << "(Browser) " << avahi_strerror(avahi_client_errno(avahi_service_browser_get_client(b))) << "\n"; | |
124 | LOG(ERROR, LOG_TAG) << "(Browser) " << avahi_strerror(avahi_client_errno(avahi_service_browser_get_client(b))) << "\n"; | |
123 | 125 | avahi_simple_poll_quit(simple_poll); |
124 | 126 | return; |
125 | 127 | |
126 | 128 | case AVAHI_BROWSER_NEW: |
127 | LOG(INFO) << "(Browser) NEW: service '" << name << "' of type '" << type << "' in domain '" << domain << "'\n"; | |
129 | LOG(INFO, LOG_TAG) << "(Browser) NEW: service '" << name << "' of type '" << type << "' in domain '" << domain << "'\n"; | |
128 | 130 | |
129 | 131 | /* We ignore the returned resolver object. In the callback |
130 | 132 | function we free it. If the server is terminated before |
133 | 135 | |
134 | 136 | if (!(avahi_service_resolver_new(browseAvahi->client_, interface, protocol, name, type, domain, AVAHI_PROTO_UNSPEC, (AvahiLookupFlags)0, |
135 | 137 | resolve_callback, userdata))) |
136 | LOG(ERROR) << "Failed to resolve service '" << name << "': " << avahi_strerror(avahi_client_errno(browseAvahi->client_)) << "\n"; | |
138 | LOG(ERROR, LOG_TAG) << "Failed to resolve service '" << name << "': " << avahi_strerror(avahi_client_errno(browseAvahi->client_)) << "\n"; | |
137 | 139 | |
138 | 140 | break; |
139 | 141 | |
140 | 142 | case AVAHI_BROWSER_REMOVE: |
141 | LOG(INFO) << "(Browser) REMOVE: service '" << name << "' of type '" << type << "' in domain '" << domain << "'\n"; | |
143 | LOG(INFO, LOG_TAG) << "(Browser) REMOVE: service '" << name << "' of type '" << type << "' in domain '" << domain << "'\n"; | |
142 | 144 | break; |
143 | 145 | |
144 | 146 | case AVAHI_BROWSER_ALL_FOR_NOW: |
145 | 147 | case AVAHI_BROWSER_CACHE_EXHAUSTED: |
146 | LOG(INFO) << "(Browser) " << (event == AVAHI_BROWSER_CACHE_EXHAUSTED ? "CACHE_EXHAUSTED" : "ALL_FOR_NOW") << "\n"; | |
148 | LOG(INFO, LOG_TAG) << "(Browser) " << (event == AVAHI_BROWSER_CACHE_EXHAUSTED ? "CACHE_EXHAUSTED" : "ALL_FOR_NOW") << "\n"; | |
147 | 149 | break; |
148 | 150 | } |
149 | 151 | } |
158 | 160 | |
159 | 161 | if (state == AVAHI_CLIENT_FAILURE) |
160 | 162 | { |
161 | LOG(ERROR) << "Server connection failure: " << avahi_strerror(avahi_client_errno(c)) << "\n"; | |
163 | LOG(ERROR, LOG_TAG) << "Server connection failure: " << avahi_strerror(avahi_client_errno(c)) << "\n"; | |
162 | 164 | avahi_simple_poll_quit(simple_poll); |
163 | 165 | } |
164 | 166 | } |
15 | 15 | |
16 | 16 | using namespace std; |
17 | 17 | |
18 | static constexpr auto LOG_TAG = "Bonjour"; | |
19 | ||
18 | 20 | struct DNSServiceRefDeleter |
19 | 21 | { |
20 | 22 | void operator()(DNSServiceRef* ref) |
24 | 26 | } |
25 | 27 | }; |
26 | 28 | |
27 | typedef std::unique_ptr<DNSServiceRef, DNSServiceRefDeleter> DNSServiceHandle; | |
29 | using DNSServiceHandle = std::unique_ptr<DNSServiceRef, DNSServiceRefDeleter>; | |
28 | 30 | |
29 | 31 | string BonjourGetError(DNSServiceErrorType error) |
30 | 32 | { |
236 | 238 | runService(service); |
237 | 239 | } |
238 | 240 | |
239 | if (resultCollection.size() == 0) | |
241 | resultCollection.erase(std::remove_if(resultCollection.begin(), resultCollection.end(), [](const mDNSResult& res) { return res.ip.empty(); }), | |
242 | resultCollection.end()); | |
243 | ||
244 | if (resultCollection.empty()) | |
240 | 245 | return false; |
241 | 246 | |
242 | if (resultCollection.size() != 1) | |
243 | LOG(NOTICE) << "Multiple servers found. Using first" << endl; | |
244 | ||
245 | result = resultCollection[0]; | |
247 | if (resultCollection.size() > 1) | |
248 | LOG(NOTICE, LOG_TAG) << "Multiple servers found. Using first" << endl; | |
249 | ||
250 | result = resultCollection.front(); | |
246 | 251 | |
247 | 252 | return true; |
248 | 253 | } |
27 | 27 | |
28 | 28 | #if defined(HAS_AVAHI) |
29 | 29 | #include "browse_avahi.hpp" |
30 | typedef BrowseAvahi BrowseZeroConf; | |
30 | using BrowseZeroConf = BrowseAvahi; | |
31 | 31 | #elif defined(HAS_BONJOUR) |
32 | 32 | #include "browse_bonjour.hpp" |
33 | typedef BrowseBonjour BrowseZeroConf; | |
33 | using BrowseZeroConf = BrowseBonjour; | |
34 | 34 | #endif |
35 | 35 | |
36 | 36 | #endif |
0 | 0 | #/bin/sh |
1 | 1 | |
2 | if [ -z "$NDK_DIR_ARM" ] && [ -z "$NDK_DIR_ARM64" ] && [ -z "$NDK_DIR_X86" ]; then | |
3 | echo "Specify at least one NDK_DIR_[ARM|ARM64|X86]" | |
4 | exit | |
5 | fi | |
2 | export NDK_DIR="$1" | |
3 | export JNI_LIBS_DIR="$2" | |
4 | export TOOLCHAIN="$NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64" | |
6 | 5 | |
7 | if [ -z "$JNI_LIBS_DIR" ]; then | |
8 | echo "Specify the snapdroid jniLibs dir JNI_LIBS_DIR" | |
9 | exit | |
10 | fi | |
6 | export ARCH=x86 | |
7 | make clean; | |
8 | make TARGET=ANDROID -j 4; $TOOLCHAIN/x86_64-linux-android/bin/strip ./snapclient; cp ./snapclient "$JNI_LIBS_DIR/x86_64/libsnapclient.so"; mv ./snapclient "$JNI_LIBS_DIR/x86/libsnapclient.so" | |
11 | 9 | |
12 | if [ -n "$NDK_DIR_ARM" ]; then | |
13 | export NDK_DIR="$NDK_DIR_ARM" | |
14 | export ARCH=arm | |
15 | make clean; | |
16 | make TARGET=ANDROID -j 4; $NDK_DIR/bin/arm-linux-androideabi-strip ./snapclient; mv ./snapclient "$JNI_LIBS_DIR/armeabi-v7a/libsnapclient.so" | |
17 | fi | |
10 | export ARCH=arm | |
11 | make clean; | |
12 | make TARGET=ANDROID -j 4; $TOOLCHAIN/arm-linux-androideabi/bin/strip ./snapclient; mv ./snapclient "$JNI_LIBS_DIR/armeabi-v7a/libsnapclient.so" | |
18 | 13 | |
19 | if [ -n "$NDK_DIR_ARM64" ]; then | |
20 | export NDK_DIR="$NDK_DIR_ARM64" | |
21 | export ARCH=arm64 | |
22 | make clean; | |
23 | make TARGET=ANDROID -j 4; $NDK_DIR/bin/aarch64-linux-android-strip ./snapclient; mv ./snapclient "$JNI_LIBS_DIR/arm64-v8a/libsnapclient.so" | |
24 | fi | |
25 | ||
26 | if [ -n "$NDK_DIR_X86" ]; then | |
27 | export NDK_DIR="$NDK_DIR_X86" | |
28 | export ARCH=x86 | |
29 | make clean; | |
30 | make TARGET=ANDROID -j 4; $NDK_DIR/bin/i686-linux-android-strip ./snapclient; mv ./snapclient "$JNI_LIBS_DIR/x86/libsnapclient.so" | |
31 | fi | |
14 | export ARCH=aarch64 | |
15 | make clean; | |
16 | make TARGET=ANDROID -j 4; $TOOLCHAIN/aarch64-linux-android/bin/strip ./snapclient; mv ./snapclient "$JNI_LIBS_DIR/arm64-v8a/libsnapclient.so" |
19 | 19 | #include "common/aixlog.hpp" |
20 | 20 | #include "common/snap_exception.hpp" |
21 | 21 | #include "common/str_compat.hpp" |
22 | #include "message/factory.hpp" | |
23 | 22 | #include "message/hello.hpp" |
24 | 23 | #include <iostream> |
25 | 24 | #include <mutex> |
27 | 26 | |
28 | 27 | using namespace std; |
29 | 28 | |
30 | ||
31 | ClientConnection::ClientConnection(MessageReceiver* receiver, const std::string& host, size_t port) | |
32 | : socket_(io_context_), active_(false), messageReceiver_(receiver), reqId_(1), host_(host), port_(port), readerThread_(nullptr), | |
33 | sumTimeout_(chronos::msec(0)) | |
29 | static constexpr auto LOG_TAG = "Connection"; | |
30 | ||
31 | ClientConnection::ClientConnection(boost::asio::io_context& io_context, const ClientSettings::Server& server) | |
32 | : io_context_(io_context), resolver_(io_context_), socket_(io_context_), reqId_(1), server_(server), strand_(io_context_) | |
34 | 33 | { |
35 | 34 | base_msg_size_ = base_message_.getSize(); |
36 | 35 | buffer_.resize(base_msg_size_); |
39 | 38 | |
40 | 39 | ClientConnection::~ClientConnection() |
41 | 40 | { |
42 | stop(); | |
43 | } | |
44 | ||
45 | ||
46 | void ClientConnection::socketRead(void* _to, size_t _bytes) | |
47 | { | |
48 | size_t toRead = _bytes; | |
49 | size_t len = 0; | |
50 | do | |
51 | { | |
52 | len += socket_.read_some(boost::asio::buffer((char*)_to + len, toRead)); | |
53 | // cout << "len: " << len << ", error: " << error << endl; | |
54 | toRead = _bytes - len; | |
55 | } while (toRead > 0); | |
41 | disconnect(); | |
56 | 42 | } |
57 | 43 | |
58 | 44 | |
59 | 45 | std::string ClientConnection::getMacAddress() |
60 | 46 | { |
61 | std::string mac = ::getMacAddress(socket_.native_handle()); | |
47 | std::string mac = | |
48 | #ifndef WINDOWS | |
49 | ::getMacAddress(socket_.native_handle()); | |
50 | #else | |
51 | ::getMacAddress(socket_.local_endpoint().address().to_string()); | |
52 | #endif | |
62 | 53 | if (mac.empty()) |
63 | 54 | mac = "00:00:00:00:00:00"; |
64 | LOG(INFO) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n"; | |
55 | LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n"; | |
65 | 56 | return mac; |
66 | 57 | } |
67 | 58 | |
68 | 59 | |
69 | void ClientConnection::start() | |
70 | { | |
71 | tcp::resolver resolver(io_context_); | |
72 | tcp::resolver::query query(host_, cpt::to_string(port_), boost::asio::ip::resolver_query_base::numeric_service); | |
73 | auto iterator = resolver.resolve(query); | |
74 | LOG(DEBUG) << "Connecting\n"; | |
75 | // struct timeval tv; | |
76 | // tv.tv_sec = 5; | |
77 | // tv.tv_usec = 0; | |
78 | // cout << "socket: " << socket->native_handle() << "\n"; | |
79 | // setsockopt(socket->native_handle(), SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); | |
80 | // setsockopt(socket->native_handle(), SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); | |
81 | socket_.connect(*iterator); | |
82 | SLOG(NOTICE) << "Connected to " << socket_.remote_endpoint().address().to_string() << endl; | |
83 | active_ = true; | |
84 | sumTimeout_ = chronos::msec(0); | |
85 | readerThread_ = make_unique<thread>(&ClientConnection::reader, this); | |
86 | } | |
87 | ||
88 | ||
89 | void ClientConnection::stop() | |
90 | { | |
91 | active_ = false; | |
92 | try | |
60 | void ClientConnection::connect(const ResultHandler& handler) | |
61 | { | |
62 | tcp::resolver::query query(server_.host, cpt::to_string(server_.port), boost::asio::ip::resolver_query_base::numeric_service); | |
63 | boost::system::error_code ec; | |
64 | LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; | |
65 | auto iterator = resolver_.resolve(query, ec); | |
66 | if (ec) | |
93 | 67 | { |
94 | boost::system::error_code ec; | |
95 | socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); | |
68 | LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << server_.host << "', error: " << ec.message() << "\n"; | |
69 | handler(ec); | |
70 | return; | |
71 | } | |
72 | ||
73 | LOG(INFO, LOG_TAG) << "Connecting\n"; | |
74 | socket_.connect(*iterator, ec); | |
75 | if (ec) | |
76 | { | |
77 | LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; | |
78 | handler(ec); | |
79 | return; | |
80 | } | |
81 | LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << endl; | |
82 | handler(ec); | |
83 | ||
84 | #if 0 | |
85 | resolver_.async_resolve(query, host_, cpt::to_string(port_), [this, handler](const boost::system::error_code& ec, tcp::resolver::results_type results) { | |
96 | 86 | if (ec) |
97 | LOG(ERROR) << "Error in socket shutdown: " << ec.message() << endl; | |
98 | socket_.close(ec); | |
99 | if (ec) | |
100 | LOG(ERROR) << "Error in socket close: " << ec.message() << endl; | |
101 | if (readerThread_) | |
102 | 87 | { |
103 | LOG(DEBUG) << "joining readerThread\n"; | |
104 | readerThread_->join(); | |
88 | LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << host_ << "', error: " << ec.message() << "\n"; | |
89 | handler(ec); | |
90 | return; | |
105 | 91 | } |
92 | ||
93 | resolver_.cancel(); | |
94 | socket_.async_connect(*results, [this, handler](const boost::system::error_code& ec) { | |
95 | if (ec) | |
96 | { | |
97 | LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << host_ << "', error: " << ec.message() << "\n"; | |
98 | handler(ec); | |
99 | return; | |
100 | } | |
101 | ||
102 | LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << endl; | |
103 | handler(ec); | |
104 | getNextMessage(); | |
105 | }); | |
106 | }); | |
107 | #endif | |
108 | } | |
109 | ||
110 | ||
111 | void ClientConnection::disconnect() | |
112 | { | |
113 | LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; | |
114 | if (!socket_.is_open()) | |
115 | { | |
116 | LOG(DEBUG, LOG_TAG) << "Not connected\n"; | |
117 | return; | |
106 | 118 | } |
107 | catch (...) | |
108 | { | |
109 | } | |
110 | readerThread_ = nullptr; | |
111 | LOG(DEBUG) << "readerThread terminated\n"; | |
112 | } | |
113 | ||
114 | ||
115 | bool ClientConnection::send(const msg::BaseMessage* message) | |
116 | { | |
117 | // std::unique_lock<std::mutex> mlock(mutex_); | |
118 | // LOG(DEBUG) << "send: " << message->type << ", size: " << message->getSize() << "\n"; | |
119 | std::lock_guard<std::mutex> socketLock(socketMutex_); | |
120 | if (!socket_.is_open()) | |
121 | return false; | |
122 | // LOG(DEBUG) << "send: " << message->type << ", size: " << message->getSize() << "\n"; | |
123 | boost::asio::streambuf streambuf; | |
119 | boost::system::error_code ec; | |
120 | socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); | |
121 | if (ec) | |
122 | LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << endl; | |
123 | socket_.close(ec); | |
124 | if (ec) | |
125 | LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << endl; | |
126 | boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); | |
127 | LOG(DEBUG, LOG_TAG) << "Disconnected\n"; | |
128 | } | |
129 | ||
130 | ||
131 | void ClientConnection::sendNext() | |
132 | { | |
133 | auto& message = messages_.front(); | |
134 | static boost::asio::streambuf streambuf; | |
124 | 135 | std::ostream stream(&streambuf); |
125 | 136 | tv t; |
126 | message->sent = t; | |
127 | message->serialize(stream); | |
128 | boost::asio::write(socket_, streambuf); | |
129 | return true; | |
130 | } | |
131 | ||
132 | ||
133 | ||
134 | unique_ptr<msg::BaseMessage> ClientConnection::sendRequest(const msg::BaseMessage* message, const chronos::msec& timeout) | |
135 | { | |
136 | unique_ptr<msg::BaseMessage> response(nullptr); | |
137 | if (++reqId_ >= 10000) | |
138 | reqId_ = 1; | |
139 | message->id = reqId_; | |
140 | // LOG(INFO) << "Req: " << message->id << "\n"; | |
141 | shared_ptr<PendingRequest> pendingRequest = make_shared<PendingRequest>(reqId_); | |
142 | ||
143 | { // scope for lock | |
144 | std::unique_lock<std::mutex> lock(pendingRequestsMutex_); | |
145 | pendingRequests_.insert(pendingRequest); | |
146 | send(message); | |
147 | } | |
148 | ||
149 | if ((response = pendingRequest->waitForResponse(std::chrono::milliseconds(timeout))) != nullptr) | |
150 | { | |
151 | sumTimeout_ = chronos::msec(0); | |
152 | // LOG(INFO) << "Resp: " << pendingRequest->id << "\n"; | |
153 | } | |
154 | else | |
155 | { | |
156 | sumTimeout_ += timeout; | |
157 | LOG(WARNING) << "timeout while waiting for response to: " << reqId_ << ", timeout " << sumTimeout_.count() << "\n"; | |
158 | if (sumTimeout_ > chronos::sec(10)) | |
159 | throw SnapException("sum timeout exceeded 10s"); | |
160 | } | |
161 | ||
162 | { // scope for lock | |
163 | std::unique_lock<std::mutex> lock(pendingRequestsMutex_); | |
164 | pendingRequests_.erase(pendingRequest); | |
165 | } | |
166 | return response; | |
167 | } | |
168 | ||
169 | ||
170 | void ClientConnection::getNextMessage() | |
171 | { | |
172 | socketRead(&buffer_[0], base_msg_size_); | |
173 | base_message_.deserialize(buffer_.data()); | |
174 | // LOG(DEBUG) << "getNextMessage: " << base_message_.type << ", size: " << base_message_.size << ", id: " << base_message_.id | |
175 | // << ", refers: " << base_message_.refersTo << "\n"; | |
176 | if (base_message_.size > buffer_.size()) | |
177 | buffer_.resize(base_message_.size); | |
178 | // { | |
179 | // std::lock_guard<std::mutex> socketLock(socketMutex_); | |
180 | socketRead(buffer_.data(), base_message_.size); | |
181 | tv t; | |
182 | base_message_.received = t; | |
183 | // } | |
184 | ||
185 | { // scope for lock | |
186 | std::unique_lock<std::mutex> lock(pendingRequestsMutex_); | |
187 | for (auto req : pendingRequests_) | |
137 | message.msg->sent = t; | |
138 | message.msg->serialize(stream); | |
139 | auto handler = message.handler; | |
140 | ||
141 | boost::asio::async_write(socket_, streambuf, boost::asio::bind_executor(strand_, [this, handler](boost::system::error_code ec, std::size_t length) { | |
142 | if (ec) | |
143 | LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n"; | |
144 | else | |
145 | LOG(TRACE, LOG_TAG) << "Wrote " << length << " bytes to socket\n"; | |
146 | ||
147 | messages_.pop_front(); | |
148 | if (handler) | |
149 | handler(ec); | |
150 | ||
151 | if (!messages_.empty()) | |
152 | sendNext(); | |
153 | })); | |
154 | } | |
155 | ||
156 | ||
157 | void ClientConnection::send(const msg::message_ptr& message, const ResultHandler& handler) | |
158 | { | |
159 | strand_.post([this, message, handler]() { | |
160 | messages_.emplace_back(message, handler); | |
161 | if (messages_.size() > 1) | |
188 | 162 | { |
189 | if (req->id() == base_message_.refersTo) | |
190 | { | |
191 | auto response = msg::factory::createMessage(base_message_, buffer_.data()); | |
192 | req->setValue(std::move(response)); | |
193 | return; | |
194 | } | |
163 | LOG(DEBUG, LOG_TAG) << "outstanding async_write\n"; | |
164 | return; | |
195 | 165 | } |
196 | } | |
197 | ||
198 | if (messageReceiver_ != nullptr) | |
199 | messageReceiver_->onMessageReceived(this, base_message_, buffer_.data()); | |
200 | } | |
201 | ||
202 | ||
203 | ||
204 | void ClientConnection::reader() | |
205 | { | |
206 | try | |
207 | { | |
208 | while (active_) | |
209 | { | |
210 | getNextMessage(); | |
211 | } | |
212 | } | |
213 | catch (...) | |
214 | { | |
215 | if (messageReceiver_ != nullptr) | |
216 | messageReceiver_->onException(this, std::current_exception()); | |
217 | } | |
218 | active_ = false; | |
219 | } | |
166 | sendNext(); | |
167 | }); | |
168 | } | |
169 | ||
170 | ||
171 | void ClientConnection::sendRequest(const msg::message_ptr& message, const chronos::usec& timeout, const MessageHandler<msg::BaseMessage>& handler) | |
172 | { | |
173 | boost::asio::post(strand_, [this, message, timeout, handler]() { | |
174 | pendingRequests_.erase( | |
175 | std::remove_if(pendingRequests_.begin(), pendingRequests_.end(), [](std::weak_ptr<PendingRequest> request) { return request.expired(); }), | |
176 | pendingRequests_.end()); | |
177 | unique_ptr<msg::BaseMessage> response(nullptr); | |
178 | if (++reqId_ >= 10000) | |
179 | reqId_ = 1; | |
180 | message->id = reqId_; | |
181 | auto request = make_shared<PendingRequest>(io_context_, strand_, reqId_, handler); | |
182 | pendingRequests_.push_back(request); | |
183 | request->startTimer(timeout); | |
184 | send(message, [handler](const boost::system::error_code& ec) { | |
185 | if (ec) | |
186 | handler(ec, nullptr); | |
187 | }); | |
188 | }); | |
189 | } | |
190 | ||
191 | ||
192 | void ClientConnection::getNextMessage(const MessageHandler<msg::BaseMessage>& handler) | |
193 | { | |
194 | boost::asio::async_read(socket_, boost::asio::buffer(buffer_, base_msg_size_), | |
195 | boost::asio::bind_executor(strand_, [this, handler](boost::system::error_code ec, std::size_t length) mutable { | |
196 | if (ec) | |
197 | { | |
198 | LOG(ERROR, LOG_TAG) << "Error reading message header of length " << length << ": " << ec.message() << "\n"; | |
199 | if (handler) | |
200 | handler(ec, nullptr); | |
201 | return; | |
202 | } | |
203 | ||
204 | base_message_.deserialize(buffer_.data()); | |
205 | tv t; | |
206 | base_message_.received = t; | |
207 | // LOG(TRACE, LOG_TAG) << "getNextMessage: " << base_message_.type << ", size: " << base_message_.size << ", id: " << | |
208 | // base_message_.id << ", refers: " << base_message_.refersTo << "\n"; | |
209 | if (base_message_.type > message_type::kLast) | |
210 | { | |
211 | LOG(ERROR, LOG_TAG) << "unknown message type received: " << base_message_.type << ", size: " << base_message_.size << "\n"; | |
212 | if (handler) | |
213 | handler(boost::asio::error::invalid_argument, nullptr); | |
214 | return; | |
215 | } | |
216 | else if (base_message_.size > msg::max_size) | |
217 | { | |
218 | LOG(ERROR, LOG_TAG) << "received message of type " << base_message_.type << " to large: " << base_message_.size << "\n"; | |
219 | if (handler) | |
220 | handler(boost::asio::error::invalid_argument, nullptr); | |
221 | return; | |
222 | } | |
223 | ||
224 | if (base_message_.size > buffer_.size()) | |
225 | buffer_.resize(base_message_.size); | |
226 | ||
227 | boost::asio::async_read( | |
228 | socket_, boost::asio::buffer(buffer_, base_message_.size), | |
229 | boost::asio::bind_executor(strand_, [this, handler](boost::system::error_code ec, std::size_t length) mutable { | |
230 | if (ec) | |
231 | { | |
232 | LOG(ERROR, LOG_TAG) << "Error reading message body of length " << length << ": " << ec.message() << "\n"; | |
233 | if (handler) | |
234 | handler(ec, nullptr); | |
235 | return; | |
236 | } | |
237 | ||
238 | auto response = msg::factory::createMessage(base_message_, buffer_.data()); | |
239 | for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter) | |
240 | { | |
241 | auto request = *iter; | |
242 | if (auto req = request.lock()) | |
243 | { | |
244 | if (req->id() == base_message_.refersTo) | |
245 | { | |
246 | req->setValue(std::move(response)); | |
247 | pendingRequests_.erase(iter); | |
248 | getNextMessage(handler); | |
249 | return; | |
250 | } | |
251 | } | |
252 | } | |
253 | ||
254 | if (handler) | |
255 | handler(ec, std::move(response)); | |
256 | })); | |
257 | })); | |
258 | } |
18 | 18 | #ifndef CLIENT_CONNECTION_H |
19 | 19 | #define CLIENT_CONNECTION_H |
20 | 20 | |
21 | #include "client_settings.hpp" | |
21 | 22 | #include "common/time_defs.hpp" |
23 | #include "message/factory.hpp" | |
22 | 24 | #include "message/message.hpp" |
25 | ||
23 | 26 | #include <atomic> |
24 | 27 | #include <boost/asio.hpp> |
25 | 28 | #include <condition_variable> |
29 | #include <deque> | |
26 | 30 | #include <memory> |
27 | 31 | #include <mutex> |
28 | 32 | #include <set> |
35 | 39 | |
36 | 40 | class ClientConnection; |
37 | 41 | |
42 | template <typename Message> | |
43 | using MessageHandler = std::function<void(const boost::system::error_code&, std::unique_ptr<Message>)>; | |
38 | 44 | |
39 | 45 | /// Used to synchronize server requests (wait for server response) |
40 | class PendingRequest | |
46 | class PendingRequest : public std::enable_shared_from_this<PendingRequest> | |
41 | 47 | { |
42 | 48 | public: |
43 | PendingRequest(uint16_t reqId) : id_(reqId) | |
49 | PendingRequest(boost::asio::io_context& io_context, boost::asio::io_context::strand& strand, uint16_t reqId, | |
50 | const MessageHandler<msg::BaseMessage>& handler) | |
51 | : id_(reqId), timer_(io_context), strand_(strand), handler_(handler){}; | |
52 | ||
53 | virtual ~PendingRequest() | |
44 | 54 | { |
45 | future_ = promise_.get_future(); | |
46 | }; | |
47 | ||
48 | template <typename Rep, typename Period> | |
49 | std::unique_ptr<msg::BaseMessage> waitForResponse(const std::chrono::duration<Rep, Period>& timeout) | |
50 | { | |
51 | try | |
52 | { | |
53 | if (future_.wait_for(timeout) == std::future_status::ready) | |
54 | return future_.get(); | |
55 | } | |
56 | catch (...) | |
57 | { | |
58 | } | |
59 | return nullptr; | |
55 | handler_ = nullptr; | |
56 | timer_.cancel(); | |
60 | 57 | } |
61 | 58 | |
59 | /// Set the response for the pending request and passes it to the handler | |
60 | /// @param value the response message | |
62 | 61 | void setValue(std::unique_ptr<msg::BaseMessage> value) |
63 | 62 | { |
64 | promise_.set_value(std::move(value)); | |
63 | boost::asio::post(strand_, [ this, self = shared_from_this(), val = std::move(value) ]() mutable { | |
64 | timer_.cancel(); | |
65 | if (handler_) | |
66 | handler_({}, std::move(val)); | |
67 | }); | |
65 | 68 | } |
66 | 69 | |
70 | /// @return the id of the request | |
67 | 71 | uint16_t id() const |
68 | 72 | { |
69 | 73 | return id_; |
70 | 74 | } |
71 | 75 | |
76 | /// Start the timer for the request | |
77 | /// @param timeout the timeout to wait for the reception of the response | |
78 | void startTimer(const chronos::usec& timeout) | |
79 | { | |
80 | timer_.expires_after(timeout); | |
81 | timer_.async_wait(boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](boost::system::error_code ec) { | |
82 | if (!handler_) | |
83 | return; | |
84 | if (!ec) | |
85 | { | |
86 | // !ec => expired => timeout | |
87 | handler_(boost::asio::error::timed_out, nullptr); | |
88 | handler_ = nullptr; | |
89 | } | |
90 | else if (ec != boost::asio::error::operation_aborted) | |
91 | { | |
92 | // ec != aborted => not cancelled (in setValue) | |
93 | // => should not happen, but who knows => pass the error to the handler | |
94 | handler_(ec, nullptr); | |
95 | } | |
96 | })); | |
97 | } | |
98 | ||
99 | /// Needed to put the requests in a container | |
100 | bool operator<(const PendingRequest& other) const | |
101 | { | |
102 | return (id_ < other.id()); | |
103 | } | |
104 | ||
105 | ||
72 | 106 | private: |
73 | 107 | uint16_t id_; |
74 | ||
75 | std::promise<std::unique_ptr<msg::BaseMessage>> promise_; | |
76 | std::future<std::unique_ptr<msg::BaseMessage>> future_; | |
108 | boost::asio::steady_timer timer_; | |
109 | boost::asio::io_context::strand& strand_; | |
110 | MessageHandler<msg::BaseMessage> handler_; | |
77 | 111 | }; |
78 | 112 | |
79 | ||
80 | /// Interface: callback for a received message and error reporting | |
81 | class MessageReceiver | |
82 | { | |
83 | public: | |
84 | virtual ~MessageReceiver() = default; | |
85 | virtual void onMessageReceived(ClientConnection* connection, const msg::BaseMessage& baseMessage, char* buffer) = 0; | |
86 | virtual void onException(ClientConnection* connection, std::exception_ptr exception) = 0; | |
87 | }; | |
88 | 113 | |
89 | 114 | |
90 | 115 | /// Endpoint of the server connection |
96 | 121 | class ClientConnection |
97 | 122 | { |
98 | 123 | public: |
99 | /// ctor. Received message from the server are passed to MessageReceiver | |
100 | ClientConnection(MessageReceiver* receiver, const std::string& host, size_t port); | |
124 | using ResultHandler = std::function<void(const boost::system::error_code&)>; | |
125 | ||
126 | /// c'tor | |
127 | ClientConnection(boost::asio::io_context& io_context, const ClientSettings::Server& server); | |
128 | /// d'tor | |
101 | 129 | virtual ~ClientConnection(); |
102 | virtual void start(); | |
103 | virtual void stop(); | |
104 | virtual bool send(const msg::BaseMessage* message); | |
130 | ||
131 | /// async connect | |
132 | /// @param handler async result handler | |
133 | void connect(const ResultHandler& handler); | |
134 | /// disconnect the socket | |
135 | void disconnect(); | |
136 | ||
137 | /// async send a message | |
138 | /// @param message the message | |
139 | /// @param handler the result handler | |
140 | void send(const msg::message_ptr& message, const ResultHandler& handler); | |
105 | 141 | |
106 | 142 | /// Send request to the server and wait for answer |
107 | virtual std::unique_ptr<msg::BaseMessage> sendRequest(const msg::BaseMessage* message, const chronos::msec& timeout = chronos::msec(1000)); | |
143 | /// @param message the message | |
144 | /// @param timeout the send timeout | |
145 | /// @param handler async result handler with the response message or error | |
146 | void sendRequest(const msg::message_ptr& message, const chronos::usec& timeout, const MessageHandler<msg::BaseMessage>& handler); | |
108 | 147 | |
109 | /// Send request to the server and wait for answer of type T | |
110 | template <typename T> | |
111 | std::unique_ptr<T> sendReq(const msg::BaseMessage* message, const chronos::msec& timeout = chronos::msec(1000)) | |
148 | /// @sa sendRequest with templated response message | |
149 | template <typename Message> | |
150 | void sendRequest(const msg::message_ptr& message, const chronos::usec& timeout, const MessageHandler<Message>& handler) | |
112 | 151 | { |
113 | std::unique_ptr<msg::BaseMessage> response = sendRequest(message, timeout); | |
114 | if (!response) | |
115 | return nullptr; | |
116 | ||
117 | T* tmp = dynamic_cast<T*>(response.get()); | |
118 | std::unique_ptr<T> result; | |
119 | if (tmp != nullptr) | |
120 | { | |
121 | response.release(); | |
122 | result.reset(tmp); | |
123 | } | |
124 | return result; | |
152 | sendRequest(message, timeout, [handler](const boost::system::error_code& ec, std::unique_ptr<msg::BaseMessage> response) { | |
153 | if (ec) | |
154 | handler(ec, nullptr); | |
155 | else | |
156 | handler(ec, msg::message_cast<Message>(std::move(response))); | |
157 | }); | |
125 | 158 | } |
126 | 159 | |
127 | 160 | std::string getMacAddress(); |
128 | 161 | |
129 | virtual bool active() const | |
130 | { | |
131 | return active_; | |
132 | } | |
162 | /// async get the next message | |
163 | /// @param handler the next received message or error | |
164 | void getNextMessage(const MessageHandler<msg::BaseMessage>& handler); | |
133 | 165 | |
134 | 166 | protected: |
135 | virtual void reader(); | |
136 | ||
137 | void socketRead(void* to, size_t bytes); | |
138 | void getNextMessage(); | |
167 | void sendNext(); | |
139 | 168 | |
140 | 169 | msg::BaseMessage base_message_; |
141 | 170 | std::vector<char> buffer_; |
142 | 171 | size_t base_msg_size_; |
143 | 172 | |
144 | boost::asio::io_context io_context_; | |
145 | mutable std::mutex socketMutex_; | |
173 | boost::asio::io_context& io_context_; | |
174 | tcp::resolver resolver_; | |
146 | 175 | tcp::socket socket_; |
147 | std::atomic<bool> active_; | |
148 | MessageReceiver* messageReceiver_; | |
149 | mutable std::mutex pendingRequestsMutex_; | |
150 | std::set<std::shared_ptr<PendingRequest>> pendingRequests_; | |
176 | std::vector<std::weak_ptr<PendingRequest>> pendingRequests_; | |
151 | 177 | uint16_t reqId_; |
152 | std::string host_; | |
153 | size_t port_; | |
154 | std::unique_ptr<std::thread> readerThread_; | |
155 | chronos::msec sumTimeout_; | |
178 | ClientSettings::Server server_; | |
179 | ||
180 | boost::asio::io_context::strand strand_; | |
181 | struct PendingMessage | |
182 | { | |
183 | PendingMessage(const msg::message_ptr& msg, ResultHandler handler) : msg(msg), handler(handler) | |
184 | { | |
185 | } | |
186 | msg::message_ptr msg; | |
187 | ResultHandler handler; | |
188 | }; | |
189 | std::deque<PendingMessage> messages_; | |
156 | 190 | }; |
157 | 191 | |
158 | 192 |
21 | 21 | #include <string> |
22 | 22 | #include <vector> |
23 | 23 | |
24 | #include "common/sample_format.hpp" | |
24 | 25 | #include "player/pcm_device.hpp" |
25 | 26 | |
26 | 27 | |
27 | 28 | struct ClientSettings |
28 | 29 | { |
29 | struct ServerSettings | |
30 | enum class SharingMode | |
31 | { | |
32 | unspecified, | |
33 | exclusive, | |
34 | shared | |
35 | }; | |
36 | ||
37 | struct Mixer | |
38 | { | |
39 | enum class Mode | |
40 | { | |
41 | hardware, | |
42 | software, | |
43 | script, | |
44 | none | |
45 | }; | |
46 | ||
47 | Mode mode{Mode::software}; | |
48 | std::string parameter{""}; | |
49 | }; | |
50 | ||
51 | struct Server | |
30 | 52 | { |
31 | 53 | std::string host{""}; |
32 | 54 | size_t port{1704}; |
33 | 55 | }; |
34 | 56 | |
35 | struct PlayerSettings | |
57 | struct Player | |
36 | 58 | { |
37 | 59 | std::string player_name{""}; |
60 | std::string parameter{""}; | |
38 | 61 | int latency{0}; |
39 | 62 | PcmDevice pcm_device; |
40 | 63 | SampleFormat sample_format; |
64 | SharingMode sharing_mode{SharingMode::unspecified}; | |
65 | Mixer mixer; | |
41 | 66 | }; |
42 | 67 | |
43 | struct LoggingSettings | |
68 | struct Logging | |
44 | 69 | { |
45 | bool debug{false}; | |
46 | std::string debug_logfile{""}; | |
70 | std::string sink{""}; | |
71 | std::string filter{"*:info"}; | |
47 | 72 | }; |
48 | 73 | |
49 | 74 | size_t instance{1}; |
50 | 75 | std::string host_id; |
51 | 76 | |
52 | ServerSettings server; | |
53 | PlayerSettings player; | |
54 | LoggingSettings logging; | |
77 | Server server; | |
78 | Player player; | |
79 | Logging logging; | |
55 | 80 | }; |
56 | 81 | |
57 | 82 | #endif |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef NOMINMAX | |
19 | #define NOMINMAX | |
20 | #endif // NOMINMAX | |
21 | ||
18 | 22 | #include "controller.hpp" |
19 | 23 | #include "decoder/pcm_decoder.hpp" |
24 | #if defined(HAS_OGG) && (defined(HAS_TREMOR) || defined(HAS_VORBIS)) | |
25 | #include "decoder/ogg_decoder.hpp" | |
26 | #endif | |
27 | #if defined(HAS_FLAC) | |
28 | #include "decoder/flac_decoder.hpp" | |
29 | #endif | |
30 | #if defined(HAS_OPUS) | |
31 | #include "decoder/opus_decoder.hpp" | |
32 | #endif | |
33 | ||
34 | #ifdef HAS_ALSA | |
35 | #include "player/alsa_player.hpp" | |
36 | #endif | |
37 | #ifdef HAS_OPENSL | |
38 | #include "player/opensl_player.hpp" | |
39 | #endif | |
40 | #ifdef HAS_OBOE | |
41 | #include "player/oboe_player.hpp" | |
42 | #endif | |
43 | #ifdef HAS_COREAUDIO | |
44 | #include "player/coreaudio_player.hpp" | |
45 | #endif | |
46 | #ifdef HAS_WASAPI | |
47 | #include "player/wasapi_player.hpp" | |
48 | #endif | |
49 | #include "player/file_player.hpp" | |
50 | ||
51 | #include "browseZeroConf/browse_mdns.hpp" | |
52 | #include "common/aixlog.hpp" | |
53 | #include "common/snap_exception.hpp" | |
54 | #include "message/client_info.hpp" | |
55 | #include "message/hello.hpp" | |
56 | #include "message/time.hpp" | |
57 | #include "time_provider.hpp" | |
58 | ||
59 | #include <algorithm> | |
20 | 60 | #include <iostream> |
21 | 61 | #include <memory> |
22 | 62 | #include <string> |
63 | ||
64 | using namespace std; | |
65 | ||
66 | static constexpr auto LOG_TAG = "Controller"; | |
67 | static constexpr auto TIME_SYNC_INTERVAL = 1s; | |
68 | ||
69 | Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings, std::unique_ptr<MetadataAdapter> meta) | |
70 | : io_context_(io_context), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), meta_(std::move(meta)), | |
71 | serverSettings_(nullptr) | |
72 | { | |
73 | } | |
74 | ||
75 | ||
76 | template <typename PlayerType> | |
77 | std::unique_ptr<Player> Controller::createPlayer(ClientSettings::Player& settings, const std::string& player_name) | |
78 | { | |
79 | if (settings.player_name.empty() || settings.player_name == player_name) | |
80 | { | |
81 | settings.player_name = player_name; | |
82 | return make_unique<PlayerType>(io_context_, settings, stream_); | |
83 | } | |
84 | return nullptr; | |
85 | } | |
86 | ||
87 | std::vector<std::string> Controller::getSupportedPlayerNames() | |
88 | { | |
89 | std::vector<std::string> result; | |
90 | #ifdef HAS_ALSA | |
91 | result.emplace_back("alsa"); | |
92 | #endif | |
93 | #ifdef HAS_OBOE | |
94 | result.emplace_back("oboe"); | |
95 | #endif | |
96 | #ifdef HAS_OPENSL | |
97 | result.emplace_back("opensl"); | |
98 | #endif | |
99 | #ifdef HAS_COREAUDIO | |
100 | result.emplace_back("coreaudio"); | |
101 | #endif | |
102 | #ifdef HAS_WASAPI | |
103 | result.emplace_back("wasapi"); | |
104 | #endif | |
105 | result.emplace_back("file"); | |
106 | return result; | |
107 | } | |
108 | ||
109 | ||
110 | void Controller::getNextMessage() | |
111 | { | |
112 | clientConnection_->getNextMessage([this](const boost::system::error_code& ec, std::unique_ptr<msg::BaseMessage> response) { | |
113 | if (ec) | |
114 | { | |
115 | reconnect(); | |
116 | return; | |
117 | } | |
118 | ||
119 | if (response->type == message_type::kWireChunk) | |
120 | { | |
121 | if (stream_ && decoder_) | |
122 | { | |
123 | // execute on the io_context to do the (costly) decoding on another thread (if more than one thread is used) | |
124 | // boost::asio::post(io_context_, [this, response = std::move(response)]() mutable { | |
125 | auto pcmChunk = msg::message_cast<msg::PcmChunk>(std::move(response)); | |
126 | pcmChunk->format = sampleFormat_; | |
127 | // LOG(TRACE, LOG_TAG) << "chunk: " << pcmChunk->payloadSize << ", sampleFormat: " << sampleFormat_.toString() << "\n"; | |
128 | if (decoder_->decode(pcmChunk.get())) | |
129 | { | |
130 | // LOG(TRACE, LOG_TAG) << ", decoded: " << pcmChunk->payloadSize << ", Duration: " << pcmChunk->durationMs() << ", sec: " << | |
131 | // pcmChunk->timestamp.sec << ", usec: " << pcmChunk->timestamp.usec / 1000 << ", type: " << pcmChunk->type << "\n"; | |
132 | stream_->addChunk(std::move(pcmChunk)); | |
133 | } | |
134 | // }); | |
135 | } | |
136 | } | |
137 | else if (response->type == message_type::kServerSettings) | |
138 | { | |
139 | serverSettings_ = msg::message_cast<msg::ServerSettings>(std::move(response)); | |
140 | LOG(INFO, LOG_TAG) << "ServerSettings - buffer: " << serverSettings_->getBufferMs() << ", latency: " << serverSettings_->getLatency() | |
141 | << ", volume: " << serverSettings_->getVolume() << ", muted: " << serverSettings_->isMuted() << "\n"; | |
142 | if (stream_ && player_) | |
143 | { | |
144 | player_->setVolume(serverSettings_->getVolume() / 100., serverSettings_->isMuted()); | |
145 | stream_->setBufferLen(std::max(0, serverSettings_->getBufferMs() - serverSettings_->getLatency() - settings_.player.latency)); | |
146 | } | |
147 | } | |
148 | else if (response->type == message_type::kCodecHeader) | |
149 | { | |
150 | headerChunk_ = msg::message_cast<msg::CodecHeader>(std::move(response)); | |
151 | decoder_.reset(nullptr); | |
152 | stream_ = nullptr; | |
153 | player_.reset(nullptr); | |
154 | ||
155 | if (headerChunk_->codec == "pcm") | |
156 | decoder_ = make_unique<decoder::PcmDecoder>(); | |
23 | 157 | #if defined(HAS_OGG) && (defined(HAS_TREMOR) || defined(HAS_VORBIS)) |
24 | #include "decoder/ogg_decoder.hpp" | |
158 | else if (headerChunk_->codec == "ogg") | |
159 | decoder_ = make_unique<decoder::OggDecoder>(); | |
25 | 160 | #endif |
26 | 161 | #if defined(HAS_FLAC) |
27 | #include "decoder/flac_decoder.hpp" | |
162 | else if (headerChunk_->codec == "flac") | |
163 | decoder_ = make_unique<decoder::FlacDecoder>(); | |
28 | 164 | #endif |
29 | 165 | #if defined(HAS_OPUS) |
30 | #include "decoder/opus_decoder.hpp" | |
31 | #endif | |
32 | #include "common/aixlog.hpp" | |
33 | #include "common/snap_exception.hpp" | |
34 | #include "message/hello.hpp" | |
35 | #include "message/time.hpp" | |
36 | #include "time_provider.hpp" | |
37 | ||
38 | using namespace std; | |
39 | ||
40 | ||
41 | Controller::Controller(const ClientSettings& settings, std::unique_ptr<MetadataAdapter> meta) | |
42 | : MessageReceiver(), settings_(settings), active_(false), stream_(nullptr), decoder_(nullptr), player_(nullptr), meta_(std::move(meta)), | |
43 | serverSettings_(nullptr), async_exception_(nullptr) | |
44 | { | |
45 | } | |
46 | ||
47 | ||
48 | void Controller::onException(ClientConnection* /*connection*/, std::exception_ptr exception) | |
49 | { | |
50 | LOG(ERROR) << "Controller::onException\n"; | |
51 | async_exception_ = exception; | |
52 | } | |
53 | ||
54 | ||
55 | void Controller::onMessageReceived(ClientConnection* /*connection*/, const msg::BaseMessage& baseMessage, char* buffer) | |
56 | { | |
57 | std::lock_guard<std::mutex> lock(receiveMutex_); | |
58 | if (baseMessage.type == message_type::kWireChunk) | |
166 | else if (headerChunk_->codec == "opus") | |
167 | decoder_ = make_unique<decoder::OpusDecoder>(); | |
168 | #endif | |
169 | else | |
170 | throw SnapException("codec not supported: \"" + headerChunk_->codec + "\""); | |
171 | ||
172 | sampleFormat_ = decoder_->setHeader(headerChunk_.get()); | |
173 | LOG(INFO, LOG_TAG) << "Codec: " << headerChunk_->codec << ", sampleformat: " << sampleFormat_.toString() << "\n"; | |
174 | ||
175 | stream_ = make_shared<Stream>(sampleFormat_, settings_.player.sample_format); | |
176 | stream_->setBufferLen(std::max(0, serverSettings_->getBufferMs() - serverSettings_->getLatency() - settings_.player.latency)); | |
177 | ||
178 | #ifdef HAS_ALSA | |
179 | if (!player_) | |
180 | player_ = createPlayer<AlsaPlayer>(settings_.player, "alsa"); | |
181 | #endif | |
182 | #ifdef HAS_OBOE | |
183 | if (!player_) | |
184 | player_ = createPlayer<OboePlayer>(settings_.player, "oboe"); | |
185 | #endif | |
186 | #ifdef HAS_OPENSL | |
187 | if (!player_) | |
188 | player_ = createPlayer<OpenslPlayer>(settings_.player, "opensl"); | |
189 | #endif | |
190 | #ifdef HAS_COREAUDIO | |
191 | if (!player_) | |
192 | player_ = createPlayer<CoreAudioPlayer>(settings_.player, "coreaudio"); | |
193 | #endif | |
194 | #ifdef HAS_WASAPI | |
195 | if (!player_) | |
196 | player_ = createPlayer<WASAPIPlayer>(settings_.player, "wasapi"); | |
197 | #endif | |
198 | if (!player_ && (settings_.player.player_name == "file")) | |
199 | player_ = createPlayer<FilePlayer>(settings_.player, "file"); | |
200 | ||
201 | if (!player_) | |
202 | throw SnapException("No audio player support" + (settings_.player.player_name.empty() ? "" : " for: " + settings_.player.player_name)); | |
203 | ||
204 | player_->setVolumeCallback([this](double volume, bool muted) { | |
205 | static double last_volume(-1); | |
206 | static bool last_muted(true); | |
207 | if ((volume != last_volume) || (last_muted != muted)) | |
208 | { | |
209 | last_volume = volume; | |
210 | last_muted = muted; | |
211 | auto info = std::make_shared<msg::ClientInfo>(); | |
212 | info->setVolume(static_cast<uint16_t>(volume * 100.)); | |
213 | info->setMuted(muted); | |
214 | clientConnection_->send(info, [this](const boost::system::error_code& ec) { | |
215 | if (ec) | |
216 | { | |
217 | LOG(ERROR, LOG_TAG) << "Failed to send client info, error: " << ec.message() << "\n"; | |
218 | reconnect(); | |
219 | return; | |
220 | } | |
221 | }); | |
222 | } | |
223 | }); | |
224 | player_->start(); | |
225 | // Don't change the initial hardware mixer volume on the user's device. | |
226 | // The player class will send the device's volume to the server instead | |
227 | if (settings_.player.mixer.mode != ClientSettings::Mixer::Mode::hardware) | |
228 | { | |
229 | player_->setVolume(serverSettings_->getVolume() / 100., serverSettings_->isMuted()); | |
230 | } | |
231 | } | |
232 | else if (response->type == message_type::kStreamTags) | |
233 | { | |
234 | if (meta_) | |
235 | { | |
236 | auto stream_tags = msg::message_cast<msg::StreamTags>(std::move(response)); | |
237 | meta_->push(stream_tags->msg); | |
238 | } | |
239 | } | |
240 | else | |
241 | { | |
242 | LOG(WARNING, LOG_TAG) << "Unexpected message received, type: " << response->type << "\n"; | |
243 | } | |
244 | getNextMessage(); | |
245 | }); | |
246 | } | |
247 | ||
248 | ||
249 | void Controller::sendTimeSyncMessage(int quick_syncs) | |
250 | { | |
251 | auto timeReq = std::make_shared<msg::Time>(); | |
252 | clientConnection_->sendRequest<msg::Time>(timeReq, 2s, [this, quick_syncs](const boost::system::error_code& ec, | |
253 | const std::unique_ptr<msg::Time>& response) mutable { | |
254 | if (ec) | |
255 | { | |
256 | LOG(ERROR, LOG_TAG) << "Time sync request failed: " << ec.message() << "\n"; | |
257 | reconnect(); | |
258 | return; | |
259 | } | |
260 | else | |
261 | { | |
262 | TimeProvider::getInstance().setDiff(response->latency, response->received - response->sent); | |
263 | } | |
264 | ||
265 | std::chrono::microseconds next = TIME_SYNC_INTERVAL; | |
266 | if (quick_syncs > 0) | |
267 | { | |
268 | if (--quick_syncs == 0) | |
269 | LOG(INFO, LOG_TAG) << "diff to server [ms]: " << (float)TimeProvider::getInstance().getDiffToServer<chronos::usec>().count() / 1000.f << "\n"; | |
270 | next = 100us; | |
271 | } | |
272 | timer_.expires_after(next); | |
273 | timer_.async_wait([this, quick_syncs](const boost::system::error_code& ec) { | |
274 | if (!ec) | |
275 | { | |
276 | sendTimeSyncMessage(quick_syncs); | |
277 | } | |
278 | }); | |
279 | }); | |
280 | } | |
281 | ||
282 | void Controller::browseMdns(const MdnsHandler& handler) | |
283 | { | |
284 | #if defined(HAS_AVAHI) || defined(HAS_BONJOUR) | |
285 | try | |
59 | 286 | { |
60 | if (stream_ && decoder_) | |
61 | { | |
62 | auto pcmChunk = make_unique<msg::PcmChunk>(sampleFormat_, 0); | |
63 | pcmChunk->deserialize(baseMessage, buffer); | |
64 | // LOG(DEBUG) << "chunk: " << pcmChunk->payloadSize << ", sampleFormat: " << sampleFormat_.getFormat() << "\n"; | |
65 | if (decoder_->decode(pcmChunk.get())) | |
66 | { | |
67 | // TODO: do decoding in thread? | |
68 | stream_->addChunk(move(pcmChunk)); | |
69 | // LOG(DEBUG) << ", decoded: " << pcmChunk->payloadSize << ", Duration: " << pcmChunk->getDuration() << ", sec: " << pcmChunk->timestamp.sec << | |
70 | // ", usec: " << pcmChunk->timestamp.usec/1000 << ", type: " << pcmChunk->type << "\n"; | |
71 | } | |
287 | BrowseZeroConf browser; | |
288 | mDNSResult avahiResult; | |
289 | if (browser.browse("_snapcast._tcp", avahiResult, 1000)) | |
290 | { | |
291 | string host = avahiResult.ip; | |
292 | uint16_t port = avahiResult.port; | |
293 | if (avahiResult.ip_version == IPVersion::IPv6) | |
294 | host += "%" + cpt::to_string(avahiResult.iface_idx); | |
295 | handler({}, host, port); | |
296 | return; | |
72 | 297 | } |
73 | 298 | } |
74 | else if (baseMessage.type == message_type::kTime) | |
299 | catch (const std::exception& e) | |
75 | 300 | { |
76 | msg::Time reply; | |
77 | reply.deserialize(baseMessage, buffer); | |
78 | TimeProvider::getInstance().setDiff(reply.latency, reply.received - reply.sent); // ToServer(diff / 2); | |
301 | LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << std::endl; | |
79 | 302 | } |
80 | else if (baseMessage.type == message_type::kServerSettings) | |
303 | ||
304 | timer_.expires_after(500ms); | |
305 | timer_.async_wait([this, handler](const boost::system::error_code& ec) { | |
306 | if (!ec) | |
307 | { | |
308 | browseMdns(handler); | |
309 | } | |
310 | else | |
311 | { | |
312 | handler(ec, "", 0); | |
313 | } | |
314 | }); | |
315 | #else | |
316 | handler(boost::asio::error::operation_not_supported, "", 0); | |
317 | #endif | |
318 | } | |
319 | ||
320 | void Controller::start() | |
321 | { | |
322 | if (settings_.server.host.empty()) | |
81 | 323 | { |
82 | serverSettings_ = make_unique<msg::ServerSettings>(); | |
83 | serverSettings_->deserialize(baseMessage, buffer); | |
84 | LOG(INFO) << "ServerSettings - buffer: " << serverSettings_->getBufferMs() << ", latency: " << serverSettings_->getLatency() | |
85 | << ", volume: " << serverSettings_->getVolume() << ", muted: " << serverSettings_->isMuted() << "\n"; | |
86 | if (stream_ && player_) | |
87 | { | |
88 | player_->setVolume(serverSettings_->getVolume() / 100.); | |
89 | player_->setMute(serverSettings_->isMuted()); | |
90 | stream_->setBufferLen(serverSettings_->getBufferMs() - serverSettings_->getLatency()); | |
91 | } | |
324 | browseMdns([this](const boost::system::error_code& ec, const std::string& host, uint16_t port) { | |
325 | if (ec) | |
326 | { | |
327 | LOG(ERROR, LOG_TAG) << "Failed to browse MDNS, error: " << ec.message() << "\n"; | |
328 | } | |
329 | else | |
330 | { | |
331 | settings_.server.host = host; | |
332 | settings_.server.port = port; | |
333 | LOG(INFO, LOG_TAG) << "Found server " << settings_.server.host << ":" << settings_.server.port << "\n"; | |
334 | clientConnection_ = make_unique<ClientConnection>(io_context_, settings_.server); | |
335 | worker(); | |
336 | } | |
337 | }); | |
92 | 338 | } |
93 | else if (baseMessage.type == message_type::kCodecHeader) | |
339 | else | |
94 | 340 | { |
95 | headerChunk_ = make_unique<msg::CodecHeader>(); | |
96 | headerChunk_->deserialize(baseMessage, buffer); | |
97 | ||
98 | LOG(INFO) << "Codec: " << headerChunk_->codec << "\n"; | |
99 | decoder_.reset(nullptr); | |
100 | stream_ = nullptr; | |
101 | player_.reset(nullptr); | |
102 | ||
103 | if (headerChunk_->codec == "pcm") | |
104 | decoder_ = make_unique<decoder::PcmDecoder>(); | |
105 | #if defined(HAS_OGG) && (defined(HAS_TREMOR) || defined(HAS_VORBIS)) | |
106 | else if (headerChunk_->codec == "ogg") | |
107 | decoder_ = make_unique<decoder::OggDecoder>(); | |
108 | #endif | |
109 | #if defined(HAS_FLAC) | |
110 | else if (headerChunk_->codec == "flac") | |
111 | decoder_ = make_unique<decoder::FlacDecoder>(); | |
112 | #endif | |
113 | #if defined(HAS_OPUS) | |
114 | else if (headerChunk_->codec == "opus") | |
115 | decoder_ = make_unique<decoder::OpusDecoder>(); | |
116 | #endif | |
117 | else | |
118 | throw SnapException("codec not supported: \"" + headerChunk_->codec + "\""); | |
119 | ||
120 | sampleFormat_ = decoder_->setHeader(headerChunk_.get()); | |
121 | LOG(NOTICE) << TAG("state") << "sampleformat: " << sampleFormat_.getFormat() << "\n"; | |
122 | ||
123 | stream_ = make_shared<Stream>(sampleFormat_, settings_.player.sample_format); | |
124 | stream_->setBufferLen(serverSettings_->getBufferMs() - settings_.player.latency); | |
125 | ||
126 | const auto& pcm_device = settings_.player.pcm_device; | |
127 | const auto& player_name = settings_.player.player_name; | |
128 | player_ = nullptr; | |
129 | #ifdef HAS_ALSA | |
130 | if (!player_ && (player_name.empty() || (player_name == "alsa"))) | |
131 | player_ = make_unique<AlsaPlayer>(pcm_device, stream_); | |
132 | #endif | |
133 | #ifdef HAS_OBOE | |
134 | if (!player_ && (player_name.empty() || (player_name == "oboe"))) | |
135 | player_ = make_unique<OboePlayer>(pcm_device, stream_); | |
136 | #endif | |
137 | #ifdef HAS_OPENSL | |
138 | if (!player_ && (player_name.empty() || (player_name == "opensl"))) | |
139 | player_ = make_unique<OpenslPlayer>(pcm_device, stream_); | |
140 | #endif | |
141 | #ifdef HAS_COREAUDIO | |
142 | if (!player_ && (player_name.empty() || (player_name == "coreaudio"))) | |
143 | player_ = make_unique<CoreAudioPlayer>(pcm_device, stream_); | |
144 | #endif | |
145 | if (!player_) | |
146 | throw SnapException("No audio player support"); | |
147 | player_->setVolume(serverSettings_->getVolume() / 100.); | |
148 | player_->setMute(serverSettings_->isMuted()); | |
149 | player_->start(); | |
341 | clientConnection_ = make_unique<ClientConnection>(io_context_, settings_.server); | |
342 | worker(); | |
150 | 343 | } |
151 | else if (baseMessage.type == message_type::kStreamTags) | |
152 | { | |
153 | if (meta_) | |
154 | { | |
155 | msg::StreamTags streamTags_; | |
156 | streamTags_.deserialize(baseMessage, buffer); | |
157 | meta_->push(streamTags_.msg); | |
158 | } | |
159 | } | |
160 | ||
161 | // if (baseMessage.type != message_type::kTime) | |
162 | // if (sendTimeSyncMessage(1000)) | |
163 | // LOG(DEBUG) << "time sync onMessageReceived\n"; | |
164 | } | |
165 | ||
166 | ||
167 | bool Controller::sendTimeSyncMessage(const std::chrono::milliseconds& after) | |
168 | { | |
169 | static chronos::time_point_clk lastTimeSync(chronos::clk::now()); | |
170 | auto now = chronos::clk::now(); | |
171 | if (lastTimeSync + after > now) | |
172 | return false; | |
173 | ||
174 | lastTimeSync = now; | |
175 | msg::Time timeReq; | |
176 | clientConnection_->send(&timeReq); | |
177 | return true; | |
178 | } | |
179 | ||
180 | ||
181 | void Controller::start() | |
182 | { | |
183 | clientConnection_ = make_unique<ClientConnection>(this, settings_.server.host, settings_.server.port); | |
184 | controllerThread_ = thread(&Controller::worker, this); | |
185 | } | |
186 | ||
187 | ||
188 | void Controller::run() | |
189 | { | |
190 | clientConnection_ = make_unique<ClientConnection>(this, settings_.server.host, settings_.server.port); | |
191 | worker(); | |
192 | // controllerThread_ = thread(&Controller::worker, this); | |
193 | } | |
194 | ||
195 | ||
196 | void Controller::stop() | |
197 | { | |
198 | LOG(DEBUG) << "Stopping Controller" << endl; | |
199 | active_ = false; | |
200 | controllerThread_.join(); | |
201 | clientConnection_->stop(); | |
202 | } | |
203 | ||
344 | } | |
345 | ||
346 | ||
347 | // void Controller::stop() | |
348 | // { | |
349 | // LOG(DEBUG, LOG_TAG) << "Stopping\n"; | |
350 | // timer_.cancel(); | |
351 | // } | |
352 | ||
353 | void Controller::reconnect() | |
354 | { | |
355 | timer_.cancel(); | |
356 | clientConnection_->disconnect(); | |
357 | player_.reset(); | |
358 | stream_.reset(); | |
359 | decoder_.reset(); | |
360 | timer_.expires_after(1s); | |
361 | timer_.async_wait([this](const boost::system::error_code& ec) { | |
362 | if (!ec) | |
363 | { | |
364 | worker(); | |
365 | } | |
366 | }); | |
367 | } | |
204 | 368 | |
205 | 369 | void Controller::worker() |
206 | 370 | { |
207 | active_ = true; | |
208 | ||
209 | while (active_) | |
210 | { | |
211 | try | |
212 | { | |
213 | clientConnection_->start(); | |
214 | ||
371 | clientConnection_->connect([this](const boost::system::error_code& ec) { | |
372 | if (!ec) | |
373 | { | |
374 | // LOG(INFO, LOG_TAG) << "Connected!\n"; | |
215 | 375 | string macAddress = clientConnection_->getMacAddress(); |
216 | 376 | if (settings_.host_id.empty()) |
217 | 377 | settings_.host_id = ::getHostId(macAddress); |
218 | 378 | |
219 | /// Say hello to the server | |
220 | msg::Hello hello(macAddress, settings_.host_id, settings_.instance); | |
221 | clientConnection_->send(&hello); | |
222 | ||
223 | /// Do initial time sync with the server | |
224 | msg::Time timeReq; | |
225 | for (size_t n = 0; n < 50 && active_; ++n) | |
226 | { | |
227 | if (async_exception_) | |
228 | { | |
229 | LOG(DEBUG) << "Async exception\n"; | |
230 | std::rethrow_exception(async_exception_); | |
231 | } | |
232 | ||
233 | auto reply = clientConnection_->sendReq<msg::Time>(&timeReq, chronos::msec(2000)); | |
234 | if (reply) | |
235 | { | |
236 | TimeProvider::getInstance().setDiff(reply->latency, reply->received - reply->sent); | |
237 | chronos::usleep(100); | |
238 | } | |
239 | } | |
240 | LOG(INFO) << "diff to server [ms]: " << (float)TimeProvider::getInstance().getDiffToServer<chronos::usec>().count() / 1000.f << "\n"; | |
241 | ||
242 | /// Main loop | |
243 | while (active_) | |
244 | { | |
245 | if (async_exception_) | |
246 | { | |
247 | LOG(DEBUG) << "Async exception\n"; | |
248 | std::rethrow_exception(async_exception_); | |
249 | } | |
250 | ||
251 | if (sendTimeSyncMessage(1000ms)) | |
252 | LOG(DEBUG) << "time sync main loop\n"; | |
253 | this_thread::sleep_for(100ms); | |
254 | } | |
255 | } | |
256 | catch (const std::exception& e) | |
257 | { | |
258 | async_exception_ = nullptr; | |
259 | SLOG(ERROR) << "Exception in Controller::worker(): " << e.what() << endl; | |
260 | clientConnection_->stop(); | |
261 | player_.reset(); | |
262 | stream_.reset(); | |
263 | decoder_.reset(); | |
264 | for (size_t n = 0; (n < 10) && active_; ++n) | |
265 | chronos::sleep(100); | |
266 | } | |
267 | } | |
268 | LOG(DEBUG) << "Thread stopped\n"; | |
269 | } | |
379 | // Say hello to the server | |
380 | auto hello = std::make_shared<msg::Hello>(macAddress, settings_.host_id, settings_.instance); | |
381 | clientConnection_->sendRequest<msg::ServerSettings>( | |
382 | hello, 2s, [this](const boost::system::error_code& ec, std::unique_ptr<msg::ServerSettings> response) mutable { | |
383 | if (ec) | |
384 | { | |
385 | LOG(ERROR, LOG_TAG) << "Failed to send hello request, error: " << ec.message() << "\n"; | |
386 | reconnect(); | |
387 | return; | |
388 | } | |
389 | else | |
390 | { | |
391 | serverSettings_ = std::move(response); | |
392 | LOG(INFO, LOG_TAG) << "ServerSettings - buffer: " << serverSettings_->getBufferMs() << ", latency: " << serverSettings_->getLatency() | |
393 | << ", volume: " << serverSettings_->getVolume() << ", muted: " << serverSettings_->isMuted() << "\n"; | |
394 | } | |
395 | }); | |
396 | ||
397 | // Do initial time sync with the server | |
398 | sendTimeSyncMessage(50); | |
399 | // Start receiver loop | |
400 | getNextMessage(); | |
401 | } | |
402 | else | |
403 | { | |
404 | LOG(ERROR, LOG_TAG) << "Error: " << ec.message() << "\n"; | |
405 | reconnect(); | |
406 | } | |
407 | }); | |
408 | } |
18 | 18 | #ifndef CONTROLLER_H |
19 | 19 | #define CONTROLLER_H |
20 | 20 | |
21 | #include "client_connection.hpp" | |
22 | #include "client_settings.hpp" | |
21 | 23 | #include "decoder/decoder.hpp" |
22 | 24 | #include "message/message.hpp" |
23 | 25 | #include "message/server_settings.hpp" |
24 | 26 | #include "message/stream_tags.hpp" |
27 | #include "metadata.hpp" | |
28 | #include "player/player.hpp" | |
29 | #include "stream.hpp" | |
25 | 30 | #include <atomic> |
26 | 31 | #include <thread> |
27 | #ifdef HAS_ALSA | |
28 | #include "player/alsa_player.hpp" | |
29 | #endif | |
30 | #ifdef HAS_OPENSL | |
31 | #include "player/opensl_player.hpp" | |
32 | #endif | |
33 | #ifdef HAS_OBOE | |
34 | #include "player/oboe_player.hpp" | |
35 | #endif | |
36 | #ifdef HAS_COREAUDIO | |
37 | #include "player/coreaudio_player.hpp" | |
38 | #endif | |
39 | #include "client_connection.hpp" | |
40 | #include "client_settings.hpp" | |
41 | #include "metadata.hpp" | |
42 | #include "stream.hpp" | |
43 | 32 | |
44 | 33 | using namespace std::chrono_literals; |
45 | 34 | |
50 | 39 | * Decodes audio (message_type::kWireChunk) and feeds PCM to the audio stream buffer |
51 | 40 | * Does timesync with the server |
52 | 41 | */ |
53 | class Controller : public MessageReceiver | |
42 | class Controller | |
54 | 43 | { |
55 | 44 | public: |
56 | Controller(const ClientSettings& settings, std::unique_ptr<MetadataAdapter> meta); | |
45 | Controller(boost::asio::io_context& io_context, const ClientSettings& settings, std::unique_ptr<MetadataAdapter> meta); | |
57 | 46 | void start(); |
58 | void run(); | |
59 | void stop(); | |
60 | ||
61 | /// Implementation of MessageReceiver. | |
62 | /// ClientConnection passes messages from the server through these callbacks | |
63 | void onMessageReceived(ClientConnection* connection, const msg::BaseMessage& baseMessage, char* buffer) override; | |
64 | ||
65 | /// Implementation of MessageReceiver. | |
66 | /// Used for async exception reporting | |
67 | void onException(ClientConnection* connection, std::exception_ptr exception) override; | |
47 | // void stop(); | |
48 | static std::vector<std::string> getSupportedPlayerNames(); | |
68 | 49 | |
69 | 50 | private: |
51 | using MdnsHandler = std::function<void(const boost::system::error_code& ec, const std::string& host, uint16_t port)>; | |
70 | 52 | void worker(); |
71 | bool sendTimeSyncMessage(const std::chrono::milliseconds& after = 1000ms); | |
53 | void reconnect(); | |
54 | void browseMdns(const MdnsHandler& handler); | |
55 | ||
56 | template <typename PlayerType> | |
57 | std::unique_ptr<Player> createPlayer(ClientSettings::Player& settings, const std::string& player_name); | |
58 | ||
59 | void getNextMessage(); | |
60 | void sendTimeSyncMessage(int quick_syncs); | |
61 | ||
62 | boost::asio::io_context& io_context_; | |
63 | boost::asio::steady_timer timer_; | |
72 | 64 | ClientSettings settings_; |
73 | 65 | std::string meta_callback_; |
74 | std::atomic<bool> active_; | |
75 | std::thread controllerThread_; | |
76 | 66 | SampleFormat sampleFormat_; |
77 | 67 | std::unique_ptr<ClientConnection> clientConnection_; |
78 | 68 | std::shared_ptr<Stream> stream_; |
81 | 71 | std::unique_ptr<MetadataAdapter> meta_; |
82 | 72 | std::unique_ptr<msg::ServerSettings> serverSettings_; |
83 | 73 | std::unique_ptr<msg::CodecHeader> headerChunk_; |
84 | std::mutex receiveMutex_; | |
85 | ||
86 | std::exception_ptr async_exception_; | |
87 | 74 | }; |
88 | 75 | |
89 | 76 |
26 | 26 | |
27 | 27 | using namespace std; |
28 | 28 | |
29 | static constexpr auto LOG_TAG = "FlacDecoder"; | |
30 | ||
29 | 31 | namespace decoder |
30 | 32 | { |
31 | 33 | |
82 | 84 | |
83 | 85 | if (lastError_) |
84 | 86 | { |
85 | LOG(ERROR) << "FLAC decode error: " << FLAC__StreamDecoderErrorStatusString[*lastError_] << "\n"; | |
87 | LOG(ERROR, LOG_TAG) << "FLAC decode error: " << FLAC__StreamDecoderErrorStatusString[*lastError_] << "\n"; | |
86 | 88 | lastError_ = nullptr; |
87 | 89 | return false; |
88 | 90 | } |
92 | 94 | { |
93 | 95 | double diffMs = static_cast<double>(cacheInfo_.cachedBlocks_) / (static_cast<double>(cacheInfo_.sampleRate_) / 1000.); |
94 | 96 | auto us = static_cast<uint64_t>(diffMs * 1000.); |
95 | tv diff(us / 1000000, us % 1000000); | |
96 | LOG(DEBUG) << "Cached: " << cacheInfo_.cachedBlocks_ << ", " << diffMs << "ms, " << diff.sec << "s, " << diff.usec << "us\n"; | |
97 | tv diff(static_cast<int32_t>(us / 1000000), static_cast<int32_t>(us % 1000000)); | |
98 | LOG(TRACE, LOG_TAG) << "Cached: " << cacheInfo_.cachedBlocks_ << ", " << diffMs << "ms, " << diff.sec << "s, " << diff.usec << "us\n"; | |
97 | 99 | chunk->timestamp = chunk->timestamp - diff; |
98 | 100 | } |
99 | 101 | return true; |
143 | 145 | |
144 | 146 | memcpy(buffer, flacChunk->payload, *bytes); |
145 | 147 | memmove(flacChunk->payload, flacChunk->payload + *bytes, flacChunk->payloadSize - *bytes); |
146 | flacChunk->payloadSize = flacChunk->payloadSize - *bytes; | |
148 | flacChunk->payloadSize = flacChunk->payloadSize - static_cast<uint32_t>(*bytes); | |
147 | 149 | flacChunk->payload = (char*)realloc(flacChunk->payload, flacChunk->payloadSize); |
148 | 150 | } |
149 | 151 | return FLAC__STREAM_DECODER_READ_STATUS_CONTINUE; |
167 | 169 | { |
168 | 170 | if (buffer[channel] == nullptr) |
169 | 171 | { |
170 | SLOG(ERROR) << "ERROR: buffer[" << channel << "] is NULL\n"; | |
172 | LOG(ERROR, LOG_TAG) << "ERROR: buffer[" << channel << "] is NULL\n"; | |
171 | 173 | return FLAC__STREAM_DECODER_WRITE_STATUS_ABORT; |
172 | 174 | } |
173 | 175 | |
190 | 192 | chunkBuffer[sampleFormat.channels() * i + channel] = SWAP_32((int32_t)(buffer[channel][i])); |
191 | 193 | } |
192 | 194 | } |
193 | pcmChunk->payloadSize += bytes; | |
195 | pcmChunk->payloadSize += static_cast<uint32_t>(bytes); | |
194 | 196 | } |
195 | 197 | |
196 | 198 | return FLAC__STREAM_DECODER_WRITE_STATUS_CONTINUE; |
210 | 212 | |
211 | 213 | void error_callback(const FLAC__StreamDecoder* /*decoder*/, FLAC__StreamDecoderErrorStatus status, void* client_data) |
212 | 214 | { |
213 | SLOG(ERROR) << "Got error callback: " << FLAC__StreamDecoderErrorStatusString[status] << "\n"; | |
215 | LOG(ERROR, LOG_TAG) << "Got error callback: " << FLAC__StreamDecoderErrorStatusString[status] << "\n"; | |
214 | 216 | static_cast<FlacDecoder*>(client_data)->lastError_ = std::make_unique<FLAC__StreamDecoderErrorStatus>(status); |
215 | 217 | } |
216 | 218 | } // namespace callback |
16 | 16 | ***/ |
17 | 17 | |
18 | 18 | #include <cmath> |
19 | #include <cstdint> | |
19 | 20 | #include <cstring> |
20 | 21 | #include <iostream> |
21 | 22 | |
26 | 27 | |
27 | 28 | |
28 | 29 | using namespace std; |
30 | ||
31 | static constexpr auto LOG_TAG = "OpusDecoder"; | |
29 | 32 | |
30 | 33 | namespace decoder |
31 | 34 | { |
72 | 75 | if (result < 0) |
73 | 76 | { |
74 | 77 | /* missing or corrupt data at this page position */ |
75 | LOG(ERROR) << "Corrupt or missing data in bitstream; continuing...\n"; | |
78 | LOG(ERROR, LOG_TAG) << "Corrupt or missing data in bitstream; continuing...\n"; | |
76 | 79 | continue; |
77 | 80 | } |
78 | 81 | |
103 | 106 | (-1.<=range<=1.) to whatever PCM format and write it out */ |
104 | 107 | while ((samples = vorbis_synthesis_pcmout(&vd, &pcm)) > 0) |
105 | 108 | { |
106 | size_t bytes = sampleFormat_.sampleSize() * vi.channels * samples; | |
109 | uint32_t bytes = sampleFormat_.sampleSize() * vi.channels * samples; | |
107 | 110 | chunk->payload = (char*)realloc(chunk->payload, chunk->payloadSize + bytes); |
108 | 111 | for (int channel = 0; channel < vi.channels; ++channel) |
109 | 112 | { |
114 | 117 | { |
115 | 118 | int8_t& val = chunkBuffer[sampleFormat_.channels() * i + channel]; |
116 | 119 | #ifdef HAS_TREMOR |
117 | val = clip<int8_t>(pcm[channel][i], -128, 127); | |
118 | #else | |
119 | val = clip<int8_t>(floor(pcm[channel][i] * 127.f + .5f), -128, 127); | |
120 | val = clip<int8_t>(pcm[channel][i], INT8_MIN, INT8_MAX); | |
121 | #else | |
122 | val = clip<int8_t>(floor(pcm[channel][i] * 127.f + .5f), INT8_MIN, INT8_MAX); | |
120 | 123 | #endif |
121 | 124 | } |
122 | 125 | } |
127 | 130 | { |
128 | 131 | int16_t& val = chunkBuffer[sampleFormat_.channels() * i + channel]; |
129 | 132 | #ifdef HAS_TREMOR |
130 | val = SWAP_16(clip<int16_t>(pcm[channel][i] >> 9, -32768, 32767)); | |
131 | #else | |
132 | val = SWAP_16(clip<int16_t>(floor(pcm[channel][i] * 32767.f + .5f), -32768, 32767)); | |
133 | val = SWAP_16(clip<int16_t>(pcm[channel][i] >> 9, INT16_MIN, INT16_MAX)); | |
134 | #else | |
135 | val = SWAP_16(clip<int16_t>(floor(pcm[channel][i] * 32767.f + .5f), INT16_MIN, INT16_MAX)); | |
133 | 136 | #endif |
134 | 137 | } |
135 | 138 | } |
140 | 143 | { |
141 | 144 | int32_t& val = chunkBuffer[sampleFormat_.channels() * i + channel]; |
142 | 145 | #ifdef HAS_TREMOR |
143 | val = SWAP_32(clip<int32_t>(pcm[channel][i] << 7, -2147483648, 2147483647)); | |
144 | #else | |
145 | val = SWAP_32(clip<int32_t>(floor(pcm[channel][i] * 2147483647.f + .5f), -2147483648, 2147483647)); | |
146 | val = SWAP_32(clip<int32_t>(pcm[channel][i] << 7, INT32_MIN, INT32_MAX)); | |
147 | #else | |
148 | val = SWAP_32(clip<int32_t>(floor(pcm[channel][i] * 2147483647.f + .5f), INT32_MIN, INT32_MAX)); | |
146 | 149 | #endif |
147 | 150 | } |
148 | 151 | } |
230 | 233 | std::string comment(*ptr); |
231 | 234 | if (comment.find("SAMPLE_FORMAT=") == 0) |
232 | 235 | sampleFormat_.setFormat(comment.substr(comment.find("=") + 1)); |
233 | LOG(INFO) << "comment: " << comment << "\n"; | |
236 | LOG(INFO, LOG_TAG) << "comment: " << comment << "\n"; | |
234 | 237 | ; |
235 | 238 | ++ptr; |
236 | 239 | } |
237 | 240 | |
238 | LOG(INFO) << "Encoded by: " << vc.vendor << "\n"; | |
241 | LOG(INFO, LOG_TAG) << "Encoded by: " << vc.vendor << "\n"; | |
239 | 242 | |
240 | 243 | return sampleFormat_; |
241 | 244 | } |
38 | 38 | |
39 | 39 | private: |
40 | 40 | bool decodePayload(msg::PcmChunk* chunk); |
41 | template <typename T> | |
42 | T clip(const T& value, const T& lower, const T& upper) const | |
41 | template <typename T, typename IN_TYPE> | |
42 | T clip(const IN_TYPE& value, const T& lower, const T& upper) const | |
43 | 43 | { |
44 | if (value > upper) | |
44 | auto val = static_cast<int64_t>(value); | |
45 | if (val > upper) | |
45 | 46 | return upper; |
46 | if (value < lower) | |
47 | if (val < lower) | |
47 | 48 | return lower; |
48 | return value; | |
49 | return static_cast<T>(value); | |
49 | 50 | } |
50 | 51 | |
51 | 52 | ogg_sync_state oy; /// sync and verify incoming physical bitstream |
31 | 31 | /// Passing in a duration of less than 10 ms (480 samples at 48 kHz) will prevent the encoder from using the LPC or hybrid modes. |
32 | 32 | static constexpr int const_max_frame_size = 2880; |
33 | 33 | |
34 | static constexpr auto LOG_TAG = "OpusDecoder"; | |
34 | 35 | |
35 | 36 | OpusDecoder::OpusDecoder() : Decoder(), dec_(nullptr) |
36 | 37 | { |
47 | 48 | |
48 | 49 | bool OpusDecoder::decode(msg::PcmChunk* chunk) |
49 | 50 | { |
50 | int frame_size = 0; | |
51 | int decoded_frames = 0; | |
51 | 52 | |
52 | while ((frame_size = opus_decode(dec_, (unsigned char*)chunk->payload, chunk->payloadSize, pcm_.data(), pcm_.size() / sample_format_.channels(), 0)) == | |
53 | OPUS_BUFFER_TOO_SMALL) | |
53 | while ((decoded_frames = opus_decode(dec_, (unsigned char*)chunk->payload, chunk->payloadSize, pcm_.data(), | |
54 | static_cast<int>(pcm_.size()) / sample_format_.channels(), 0)) == OPUS_BUFFER_TOO_SMALL) | |
54 | 55 | { |
55 | 56 | if (pcm_.size() < const_max_frame_size * sample_format_.channels()) |
56 | 57 | { |
57 | 58 | pcm_.resize(pcm_.size() * 2); |
58 | LOG(INFO) << "OPUS encoding buffer too small, resizing to " << pcm_.size() / sample_format_.channels() << " samples per channel\n"; | |
59 | LOG(DEBUG, LOG_TAG) << "OPUS encoding buffer too small, resizing to " << pcm_.size() / sample_format_.channels() << " samples per channel\n"; | |
59 | 60 | } |
60 | 61 | else |
61 | 62 | break; |
62 | 63 | } |
63 | 64 | |
64 | if (frame_size < 0) | |
65 | if (decoded_frames < 0) | |
65 | 66 | { |
66 | LOG(ERROR) << "Failed to decode chunk: " << opus_strerror(frame_size) << ", IN size: " << chunk->payloadSize << ", OUT size: " << pcm_.size() << '\n'; | |
67 | LOG(ERROR, LOG_TAG) << "Failed to decode chunk: " << opus_strerror(decoded_frames) << ", IN size: " << chunk->payloadSize | |
68 | << ", OUT size: " << pcm_.size() << '\n'; | |
67 | 69 | return false; |
68 | 70 | } |
69 | 71 | else |
70 | 72 | { |
71 | LOG(DEBUG) << "Decoded chunk: size " << chunk->payloadSize << " bytes, decoded " << frame_size << " samples" << '\n'; | |
73 | LOG(TRACE, LOG_TAG) << "Decode chunk: " << decoded_frames << " frames, size: " << chunk->payloadSize | |
74 | << " bytes, decoded: " << decoded_frames * sample_format_.frameSize() << " bytes\n"; | |
72 | 75 | |
73 | 76 | // copy encoded data to chunk |
74 | chunk->payloadSize = frame_size * sample_format_.channels() * sizeof(opus_int16); | |
77 | chunk->payloadSize = decoded_frames * sample_format_.frameSize(); // decoded_frames * sample_format_.channels() * sizeof(opus_int16); | |
75 | 78 | chunk->payload = (char*)realloc(chunk->payload, chunk->payloadSize); |
76 | 79 | memcpy(chunk->payload, (char*)pcm_.data(), chunk->payloadSize); |
77 | 80 | return true; |
93 | 96 | |
94 | 97 | // decode the sampleformat |
95 | 98 | uint32_t rate; |
96 | memcpy(&rate, chunk->payload + 4, sizeof(id_opus)); | |
99 | memcpy(&rate, chunk->payload + 4, sizeof(rate)); | |
97 | 100 | uint16_t bits; |
98 | 101 | memcpy(&bits, chunk->payload + 8, sizeof(bits)); |
99 | 102 | uint16_t channels; |
100 | 103 | memcpy(&channels, chunk->payload + 10, sizeof(channels)); |
101 | 104 | |
102 | 105 | sample_format_.setFormat(SWAP_32(rate), SWAP_16(bits), SWAP_16(channels)); |
103 | LOG(DEBUG) << "Opus sampleformat: " << sample_format_.getFormat() << "\n"; | |
106 | LOG(DEBUG, LOG_TAG) << "Opus sampleformat: " << sample_format_.toString() << "\n"; | |
104 | 107 | |
105 | 108 | // create the decoder |
106 | 109 | int error; |
19 | 19 | #define DOUBLE_BUFFER_H |
20 | 20 | |
21 | 21 | #include <algorithm> |
22 | #include <array> | |
22 | 23 | #include <deque> |
23 | 24 | |
24 | 25 | |
50 | 51 | } |
51 | 52 | |
52 | 53 | /// Median as mean over N values around the median |
53 | T median(unsigned int mean = 1) const | |
54 | T median(uint16_t mean = 1) const | |
54 | 55 | { |
55 | 56 | if (buffer.empty()) |
56 | 57 | return 0; |
60 | 61 | return tmpBuffer[tmpBuffer.size() / 2]; |
61 | 62 | else |
62 | 63 | { |
63 | unsigned int low = tmpBuffer.size() / 2; | |
64 | unsigned int high = low; | |
64 | uint16_t low = static_cast<uint16_t>(tmpBuffer.size()) / 2; | |
65 | uint16_t high = low; | |
65 | 66 | low -= mean / 2; |
66 | 67 | high += mean / 2; |
67 | 68 | T result((T)0); |
68 | for (unsigned int i = low; i <= high; ++i) | |
69 | for (uint16_t i = low; i <= high; ++i) | |
69 | 70 | { |
70 | 71 | result += tmpBuffer[i]; |
71 | 72 | } |
89 | 90 | return 0; |
90 | 91 | std::deque<T> tmpBuffer(buffer.begin(), buffer.end()); |
91 | 92 | std::sort(tmpBuffer.begin(), tmpBuffer.end()); |
92 | return tmpBuffer[(size_t)(tmpBuffer.size() * ((float)percentile / (float)100))]; | |
93 | return tmpBuffer[(size_t)((tmpBuffer.size() - 1) * ((float)percentile / (float)100))]; | |
94 | } | |
95 | ||
96 | template <std::size_t Size> | |
97 | std::array<T, Size> percentiles(std::array<uint8_t, Size> percentiles) const | |
98 | { | |
99 | std::array<T, Size> result; | |
100 | result.fill(0); | |
101 | if (buffer.empty()) | |
102 | return result; | |
103 | std::deque<T> tmpBuffer(buffer.begin(), buffer.end()); | |
104 | std::sort(tmpBuffer.begin(), tmpBuffer.end()); | |
105 | for (std::size_t i = 0; i < Size; ++i) | |
106 | result[i] = tmpBuffer[(size_t)((tmpBuffer.size() - 1) * ((float)percentiles[i] / (float)100))]; | |
107 | ||
108 | return result; | |
93 | 109 | } |
94 | 110 | |
95 | 111 | inline bool full() const |
6 | 6 | <key>ProgramArguments</key> |
7 | 7 | <array> |
8 | 8 | <string>/usr/local/bin/snapclient</string> |
9 | <string>--logsink=system</string> | |
9 | 10 | <!-- <string>-d</string> --> |
10 | 11 | </array> |
11 | 12 | <key>RunAtLoad</key> |
19 | 19 | #include "common/aixlog.hpp" |
20 | 20 | #include "common/snap_exception.hpp" |
21 | 21 | #include "common/str_compat.hpp" |
22 | #include "common/utils/string_utils.hpp" | |
22 | 23 | |
23 | 24 | //#define BUFFER_TIME 120000 |
24 | #define PERIOD_TIME 30000 | |
25 | #define PERIOD_TIME 15000 | |
26 | #define exp10(x) (exp((x)*log(10))) | |
25 | 27 | |
26 | 28 | using namespace std; |
27 | 29 | |
28 | 30 | static constexpr auto LOG_TAG = "Alsa"; |
29 | ||
30 | AlsaPlayer::AlsaPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream) : Player(pcmDevice, stream), handle_(nullptr) | |
31 | { | |
31 | static constexpr auto DEFAULT_MIXER = "PCM"; | |
32 | ||
33 | ||
34 | AlsaPlayer::AlsaPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
35 | : Player(io_context, settings, stream), handle_(nullptr), ctl_(nullptr), mixer_(nullptr), elem_(nullptr), sd_(io_context), timer_(io_context) | |
36 | { | |
37 | if (settings_.mixer.mode == ClientSettings::Mixer::Mode::hardware) | |
38 | { | |
39 | string tmp; | |
40 | if (settings_.mixer.parameter.empty()) | |
41 | mixer_name_ = DEFAULT_MIXER; | |
42 | else | |
43 | mixer_name_ = utils::string::split_left(settings_.mixer.parameter, ':', tmp); | |
44 | ||
45 | string card; | |
46 | // default:CARD=ALSA[,DEV=x] => default | |
47 | mixer_device_ = utils::string::split_left(settings_.pcm_device.name, ':', card); | |
48 | if (!card.empty()) | |
49 | { | |
50 | auto pos = card.find("CARD="); | |
51 | if (pos != string::npos) | |
52 | { | |
53 | card = card.substr(pos + 5); | |
54 | card = utils::string::split_left(card, ',', tmp); | |
55 | int card_idx = snd_card_get_index(card.c_str()); | |
56 | if ((card_idx >= 0) && (card_idx < 32)) | |
57 | mixer_device_ = "hw:" + std::to_string(card_idx); | |
58 | } | |
59 | } | |
60 | ||
61 | LOG(DEBUG, LOG_TAG) << "Mixer: " << mixer_name_ << ", device: " << mixer_device_ << "\n"; | |
62 | } | |
63 | } | |
64 | ||
65 | ||
66 | void AlsaPlayer::setHardwareVolume(double volume, bool muted) | |
67 | { | |
68 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
69 | if (elem_ == nullptr) | |
70 | return; | |
71 | ||
72 | last_change_ = std::chrono::steady_clock::now(); | |
73 | try | |
74 | { | |
75 | int val = muted ? 0 : 1; | |
76 | int err = snd_mixer_selem_set_playback_switch_all(elem_, val); | |
77 | if (err < 0) | |
78 | LOG(ERROR, LOG_TAG) << "Failed to mute, error: " << snd_strerror(err) << "\n"; | |
79 | ||
80 | long minv, maxv; | |
81 | if ((err = snd_mixer_selem_get_playback_dB_range(elem_, &minv, &maxv)) == 0) | |
82 | { | |
83 | double min_norm = exp10((minv - maxv) / 6000.0); | |
84 | volume = volume * (1 - min_norm) + min_norm; | |
85 | double mixer_volume = 6000.0 * log10(volume) + maxv; | |
86 | ||
87 | LOG(DEBUG, LOG_TAG) << "Mixer playback dB range [" << minv << ", " << maxv << "], volume: " << volume << ", mixer volume: " << mixer_volume << "\n"; | |
88 | if ((err = snd_mixer_selem_set_playback_dB_all(elem_, mixer_volume, 0)) < 0) | |
89 | throw SnapException(std::string("Failed to set playback volume, error: ") + snd_strerror(err)); | |
90 | } | |
91 | else | |
92 | { | |
93 | if ((err = snd_mixer_selem_get_playback_volume_range(elem_, &minv, &maxv)) < 0) | |
94 | throw SnapException(std::string("Failed to get playback volume range, error: ") + snd_strerror(err)); | |
95 | ||
96 | auto mixer_volume = volume * (maxv - minv) + minv; | |
97 | LOG(DEBUG, LOG_TAG) << "Mixer playback volume range [" << minv << ", " << maxv << "], volume: " << volume << ", mixer volume: " << mixer_volume | |
98 | << "\n"; | |
99 | if ((err = snd_mixer_selem_set_playback_volume_all(elem_, mixer_volume)) < 0) | |
100 | throw SnapException(std::string("Failed to set playback volume, error: ") + snd_strerror(err)); | |
101 | } | |
102 | } | |
103 | catch (const std::exception& e) | |
104 | { | |
105 | LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << "\n"; | |
106 | uninitMixer(); | |
107 | } | |
108 | } | |
109 | ||
110 | ||
111 | bool AlsaPlayer::getHardwareVolume(double& volume, bool& muted) | |
112 | { | |
113 | try | |
114 | { | |
115 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
116 | if (elem_ == nullptr) | |
117 | throw SnapException("Mixer not initialized"); | |
118 | ||
119 | long vol; | |
120 | int err = 0; | |
121 | while (snd_mixer_handle_events(mixer_) > 0) | |
122 | this_thread::sleep_for(1us); | |
123 | long minv, maxv; | |
124 | if ((err = snd_mixer_selem_get_playback_dB_range(elem_, &minv, &maxv)) == 0) | |
125 | { | |
126 | if ((err = snd_mixer_selem_get_playback_dB(elem_, SND_MIXER_SCHN_MONO, &vol)) < 0) | |
127 | throw SnapException(std::string("Failed to get playback volume, error: ") + snd_strerror(err)); | |
128 | ||
129 | volume = pow(10, (vol - maxv) / 6000.0); | |
130 | if (minv != SND_CTL_TLV_DB_GAIN_MUTE) | |
131 | { | |
132 | double min_norm = pow(10, (minv - maxv) / 6000.0); | |
133 | volume = (volume - min_norm) / (1 - min_norm); | |
134 | } | |
135 | } | |
136 | else | |
137 | { | |
138 | if ((err = snd_mixer_selem_get_playback_volume_range(elem_, &minv, &maxv)) < 0) | |
139 | throw SnapException(std::string("Failed to get playback volume range, error: ") + snd_strerror(err)); | |
140 | if ((err = snd_mixer_selem_get_playback_volume(elem_, SND_MIXER_SCHN_MONO, &vol)) < 0) | |
141 | throw SnapException(std::string("Failed to get playback volume, error: ") + snd_strerror(err)); | |
142 | ||
143 | vol -= minv; | |
144 | maxv = maxv - minv; | |
145 | volume = static_cast<double>(vol) / static_cast<double>(maxv); | |
146 | } | |
147 | int val; | |
148 | if ((err = snd_mixer_selem_get_playback_switch(elem_, SND_MIXER_SCHN_MONO, &val)) < 0) | |
149 | throw SnapException(std::string("Failed to get mute state, error: ") + snd_strerror(err)); | |
150 | muted = (val == 0); | |
151 | LOG(DEBUG, LOG_TAG) << "Get volume, mixer volume range [" << minv << ", " << maxv << "], volume: " << volume << ", muted: " << muted << "\n"; | |
152 | snd_mixer_handle_events(mixer_); | |
153 | return true; | |
154 | } | |
155 | catch (const std::exception& e) | |
156 | { | |
157 | LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << "\n"; | |
158 | return false; | |
159 | } | |
160 | } | |
161 | ||
162 | ||
163 | void AlsaPlayer::waitForEvent() | |
164 | { | |
165 | sd_.async_wait(boost::asio::posix::stream_descriptor::wait_read, [this](const boost::system::error_code& ec) { | |
166 | if (ec) | |
167 | { | |
168 | // TODO: fd is "Bad" after unplugging/plugging USB DAC, i.e. after init/uninit/init cycle | |
169 | LOG(DEBUG, LOG_TAG) << "waitForEvent error: " << ec.message() << "\n"; | |
170 | return; | |
171 | } | |
172 | ||
173 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
174 | if (ctl_ == nullptr) | |
175 | return; | |
176 | ||
177 | unsigned short revents; | |
178 | snd_ctl_poll_descriptors_revents(ctl_, fd_.get(), 1, &revents); | |
179 | if (revents & POLLIN || (revents == 0)) | |
180 | { | |
181 | snd_ctl_event_t* event; | |
182 | snd_ctl_event_alloca(&event); | |
183 | ||
184 | if (((snd_ctl_read(ctl_, event) >= 0) && (snd_ctl_event_get_type(event) == SND_CTL_EVENT_ELEM)) || (revents == 0)) | |
185 | { | |
186 | auto now = std::chrono::steady_clock::now(); | |
187 | if (now - last_change_ < 1s) | |
188 | { | |
189 | LOG(DEBUG, LOG_TAG) << "Last volume change by server: " << std::chrono::duration_cast<std::chrono::milliseconds>(now - last_change_).count() | |
190 | << " ms => ignoring volume change\n"; | |
191 | waitForEvent(); | |
192 | return; | |
193 | } | |
194 | // Sometimes the old volume is reported after this event has been raised. | |
195 | // As workaround we defer getting the volume by 20ms. | |
196 | timer_.cancel(); | |
197 | timer_.expires_after(20ms); | |
198 | timer_.async_wait([this](const boost::system::error_code& ec) { | |
199 | if (!ec) | |
200 | { | |
201 | double volume; | |
202 | bool muted; | |
203 | if (getHardwareVolume(volume, muted)) | |
204 | { | |
205 | LOG(DEBUG, LOG_TAG) << "Volume: " << volume << ", muted: " << muted << "\n"; | |
206 | notifyVolumeChange(volume, muted); | |
207 | } | |
208 | } | |
209 | }); | |
210 | } | |
211 | } | |
212 | waitForEvent(); | |
213 | }); | |
214 | } | |
215 | ||
216 | void AlsaPlayer::initMixer() | |
217 | { | |
218 | if (settings_.mixer.mode != ClientSettings::Mixer::Mode::hardware) | |
219 | return; | |
220 | ||
221 | LOG(DEBUG, LOG_TAG) << "initMixer\n"; | |
222 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
223 | int err; | |
224 | if ((err = snd_ctl_open(&ctl_, mixer_device_.c_str(), SND_CTL_READONLY)) < 0) | |
225 | throw SnapException("Can't open control for " + mixer_device_ + ", error: " + snd_strerror(err)); | |
226 | if ((err = snd_ctl_subscribe_events(ctl_, 1)) < 0) | |
227 | throw SnapException("Can't subscribe for events for " + mixer_device_ + ", error: " + snd_strerror(err)); | |
228 | fd_ = std::unique_ptr<pollfd, std::function<void(pollfd*)>>(new pollfd(), [](pollfd* p) { | |
229 | close(p->fd); | |
230 | delete p; | |
231 | }); | |
232 | err = snd_ctl_poll_descriptors(ctl_, fd_.get(), 1); | |
233 | LOG(DEBUG, LOG_TAG) << "Filled " << err << " poll descriptors, poll descriptor count: " << snd_ctl_poll_descriptors_count(ctl_) << ", fd: " << fd_->fd | |
234 | << "\n"; | |
235 | ||
236 | snd_mixer_selem_id_t* sid; | |
237 | snd_mixer_selem_id_alloca(&sid); | |
238 | int mix_index = 0; | |
239 | // sets simple-mixer index and name | |
240 | snd_mixer_selem_id_set_index(sid, mix_index); | |
241 | snd_mixer_selem_id_set_name(sid, mixer_name_.c_str()); | |
242 | ||
243 | if ((err = snd_mixer_open(&mixer_, 0)) < 0) | |
244 | throw SnapException(std::string("Failed to open mixer, error: ") + snd_strerror(err)); | |
245 | if ((err = snd_mixer_attach(mixer_, mixer_device_.c_str())) < 0) | |
246 | throw SnapException("Failed to attach mixer to " + mixer_device_ + ", error: " + snd_strerror(err)); | |
247 | if ((err = snd_mixer_selem_register(mixer_, NULL, NULL)) < 0) | |
248 | throw SnapException(std::string("Failed to register selem, error: ") + snd_strerror(err)); | |
249 | if ((err = snd_mixer_load(mixer_)) < 0) | |
250 | throw SnapException(std::string("Failed to load mixer, error: ") + snd_strerror(err)); | |
251 | elem_ = snd_mixer_find_selem(mixer_, sid); | |
252 | if (!elem_) | |
253 | throw SnapException("Failed to find mixer: " + mixer_name_); | |
254 | ||
255 | sd_ = boost::asio::posix::stream_descriptor(io_context_, fd_->fd); | |
256 | waitForEvent(); | |
32 | 257 | } |
33 | 258 | |
34 | 259 | |
35 | 260 | void AlsaPlayer::initAlsa() |
36 | 261 | { |
262 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
37 | 263 | unsigned int tmp, rate; |
38 | int pcm, channels; | |
264 | int err, channels; | |
39 | 265 | snd_pcm_hw_params_t* params; |
40 | 266 | |
41 | 267 | const SampleFormat& format = stream_->getFormat(); |
43 | 269 | channels = format.channels(); |
44 | 270 | |
45 | 271 | /* Open the PCM device in playback mode */ |
46 | if ((pcm = snd_pcm_open(&handle_, pcmDevice_.name.c_str(), SND_PCM_STREAM_PLAYBACK, 0)) < 0) | |
47 | throw SnapException("Can't open " + pcmDevice_.name + " PCM device: " + snd_strerror(pcm)); | |
272 | if ((err = snd_pcm_open(&handle_, settings_.pcm_device.name.c_str(), SND_PCM_STREAM_PLAYBACK, 0)) < 0) | |
273 | throw SnapException("Can't open " + settings_.pcm_device.name + ", error: " + snd_strerror(err), err); | |
48 | 274 | |
49 | 275 | /* struct snd_pcm_playback_info_t pinfo; |
50 | 276 | if ( (pcm = snd_pcm_playback_info( pcm_handle, &pinfo )) < 0 ) |
54 | 280 | /* Allocate parameters object and fill it with default values*/ |
55 | 281 | snd_pcm_hw_params_alloca(¶ms); |
56 | 282 | |
57 | if ((pcm = snd_pcm_hw_params_any(handle_, params)) < 0) | |
58 | throw SnapException("Can't fill params: " + string(snd_strerror(pcm))); | |
283 | if ((err = snd_pcm_hw_params_any(handle_, params)) < 0) | |
284 | throw SnapException("Can't fill params: " + string(snd_strerror(err))); | |
59 | 285 | |
60 | 286 | /* Set parameters */ |
61 | if ((pcm = snd_pcm_hw_params_set_access(handle_, params, SND_PCM_ACCESS_RW_INTERLEAVED)) < 0) | |
62 | throw SnapException("Can't set interleaved mode: " + string(snd_strerror(pcm))); | |
287 | if ((err = snd_pcm_hw_params_set_access(handle_, params, SND_PCM_ACCESS_RW_INTERLEAVED)) < 0) | |
288 | throw SnapException("Can't set interleaved mode: " + string(snd_strerror(err))); | |
63 | 289 | |
64 | 290 | snd_pcm_format_t snd_pcm_format; |
65 | 291 | if (format.bits() == 8) |
73 | 299 | else |
74 | 300 | throw SnapException("Unsupported sample format: " + cpt::to_string(format.bits())); |
75 | 301 | |
76 | pcm = snd_pcm_hw_params_set_format(handle_, params, snd_pcm_format); | |
77 | if (pcm == -EINVAL) | |
302 | err = snd_pcm_hw_params_set_format(handle_, params, snd_pcm_format); | |
303 | if (err == -EINVAL) | |
78 | 304 | { |
79 | 305 | if (snd_pcm_format == SND_PCM_FORMAT_S24_LE) |
80 | 306 | { |
87 | 313 | } |
88 | 314 | } |
89 | 315 | |
90 | pcm = snd_pcm_hw_params_set_format(handle_, params, snd_pcm_format); | |
91 | if (pcm < 0) | |
92 | { | |
93 | cerr << "error: " << pcm << "\n"; | |
316 | err = snd_pcm_hw_params_set_format(handle_, params, snd_pcm_format); | |
317 | if (err < 0) | |
318 | { | |
94 | 319 | stringstream ss; |
95 | ss << "Can't set format: " << string(snd_strerror(pcm)) << ", supported: "; | |
320 | ss << "Can't set format: " << string(snd_strerror(err)) << ", supported: "; | |
96 | 321 | for (int format = 0; format <= (int)SND_PCM_FORMAT_LAST; format++) |
97 | 322 | { |
98 | 323 | snd_pcm_format_t snd_pcm_format = static_cast<snd_pcm_format_t>(format); |
102 | 327 | throw SnapException(ss.str()); |
103 | 328 | } |
104 | 329 | |
105 | if ((pcm = snd_pcm_hw_params_set_channels(handle_, params, channels)) < 0) | |
106 | throw SnapException("Can't set channels number: " + string(snd_strerror(pcm))); | |
107 | ||
108 | if ((pcm = snd_pcm_hw_params_set_rate_near(handle_, params, &rate, nullptr)) < 0) | |
109 | throw SnapException("Can't set rate: " + string(snd_strerror(pcm))); | |
330 | if ((err = snd_pcm_hw_params_set_channels(handle_, params, channels)) < 0) | |
331 | throw SnapException("Can't set channel count: " + string(snd_strerror(err))); | |
332 | ||
333 | if ((err = snd_pcm_hw_params_set_rate_near(handle_, params, &rate, nullptr)) < 0) | |
334 | throw SnapException("Can't set rate: " + string(snd_strerror(err))); | |
110 | 335 | |
111 | 336 | unsigned int period_time; |
112 | 337 | snd_pcm_hw_params_get_period_time_max(params, &period_time, nullptr); |
123 | 348 | // LOG(ERROR, LOG_TAG) << "Unable to set buffer size " << (long int)periodsize << ": " << snd_strerror(pcm) << "\n"; |
124 | 349 | |
125 | 350 | /* Write parameters */ |
126 | if ((pcm = snd_pcm_hw_params(handle_, params)) < 0) | |
127 | throw SnapException("Can't set hardware parameters: " + string(snd_strerror(pcm))); | |
351 | if ((err = snd_pcm_hw_params(handle_, params)) < 0) | |
352 | throw SnapException("Can't set hardware parameters: " + string(snd_strerror(err))); | |
128 | 353 | |
129 | 354 | /* Resume information */ |
130 | 355 | LOG(DEBUG, LOG_TAG) << "PCM name: " << snd_pcm_name(handle_) << "\n"; |
137 | 362 | |
138 | 363 | /* Allocate buffer to hold single period */ |
139 | 364 | snd_pcm_hw_params_get_period_size(params, &frames_, nullptr); |
140 | LOG(INFO, LOG_TAG) << "frames: " << frames_ << "\n"; | |
365 | LOG(DEBUG, LOG_TAG) << "frames: " << frames_ << "\n"; | |
141 | 366 | |
142 | 367 | snd_pcm_hw_params_get_period_time(params, &tmp, nullptr); |
143 | 368 | LOG(DEBUG, LOG_TAG) << "period time: " << tmp << "\n"; |
150 | 375 | snd_pcm_sw_params_set_start_threshold(handle_, swparams, frames_); |
151 | 376 | // snd_pcm_sw_params_set_stop_threshold(pcm_handle, swparams, frames_); |
152 | 377 | snd_pcm_sw_params(handle_, swparams); |
153 | } | |
154 | ||
155 | ||
156 | void AlsaPlayer::uninitAlsa() | |
157 | { | |
378 | ||
379 | if (ctl_ == nullptr) | |
380 | initMixer(); | |
381 | } | |
382 | ||
383 | ||
384 | void AlsaPlayer::uninitAlsa(bool uninit_mixer) | |
385 | { | |
386 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
387 | if (uninit_mixer) | |
388 | uninitMixer(); | |
389 | ||
158 | 390 | if (handle_ != nullptr) |
159 | 391 | { |
160 | snd_pcm_drain(handle_); | |
392 | snd_pcm_drop(handle_); | |
161 | 393 | snd_pcm_close(handle_); |
162 | 394 | handle_ = nullptr; |
163 | 395 | } |
164 | 396 | } |
165 | 397 | |
166 | 398 | |
399 | void AlsaPlayer::uninitMixer() | |
400 | { | |
401 | if (settings_.mixer.mode != ClientSettings::Mixer::Mode::hardware) | |
402 | return; | |
403 | ||
404 | LOG(DEBUG, LOG_TAG) << "uninitMixer\n"; | |
405 | std::lock_guard<std::recursive_mutex> lock(mutex_); | |
406 | if (sd_.is_open()) | |
407 | { | |
408 | boost::system::error_code ec; | |
409 | sd_.cancel(ec); | |
410 | } | |
411 | if (ctl_ != nullptr) | |
412 | { | |
413 | snd_ctl_close(ctl_); | |
414 | ctl_ = nullptr; | |
415 | } | |
416 | if (mixer_ != nullptr) | |
417 | { | |
418 | snd_mixer_close(mixer_); | |
419 | mixer_ = nullptr; | |
420 | } | |
421 | fd_ = nullptr; | |
422 | elem_ = nullptr; | |
423 | } | |
424 | ||
425 | ||
167 | 426 | void AlsaPlayer::start() |
168 | 427 | { |
169 | initAlsa(); | |
428 | try | |
429 | { | |
430 | initAlsa(); | |
431 | } | |
432 | catch (const SnapException& e) | |
433 | { | |
434 | LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << ", code: " << e.code() << "\n"; | |
435 | // Accept "Device or ressource busy", the worker loop will retry | |
436 | if (e.code() != -EBUSY) | |
437 | throw; | |
438 | } | |
439 | ||
170 | 440 | Player::start(); |
171 | 441 | } |
172 | 442 | |
180 | 450 | void AlsaPlayer::stop() |
181 | 451 | { |
182 | 452 | Player::stop(); |
183 | uninitAlsa(); | |
453 | uninitAlsa(true); | |
454 | } | |
455 | ||
456 | ||
457 | bool AlsaPlayer::needsThread() const | |
458 | { | |
459 | return true; | |
184 | 460 | } |
185 | 461 | |
186 | 462 | |
191 | 467 | snd_pcm_sframes_t framesAvail; |
192 | 468 | long lastChunkTick = chronos::getTickCount(); |
193 | 469 | const SampleFormat& format = stream_->getFormat(); |
194 | ||
195 | 470 | while (active_) |
196 | 471 | { |
197 | 472 | if (handle_ == nullptr) |
199 | 474 | try |
200 | 475 | { |
201 | 476 | initAlsa(); |
477 | // set the hardware volume. It might have changed when we were not initialized | |
478 | if (settings_.mixer.mode == ClientSettings::Mixer::Mode::hardware) | |
479 | setHardwareVolume(volume_, muted_); | |
202 | 480 | } |
203 | 481 | catch (const std::exception& e) |
204 | 482 | { |
205 | 483 | LOG(ERROR, LOG_TAG) << "Exception in initAlsa: " << e.what() << endl; |
206 | 484 | chronos::sleep(100); |
207 | 485 | } |
486 | if (handle_ == nullptr) | |
487 | continue; | |
208 | 488 | } |
209 | 489 | |
210 | 490 | int wait_result = snd_pcm_wait(handle_, 100); |
216 | 496 | else if (wait_result < 0) |
217 | 497 | { |
218 | 498 | LOG(ERROR, LOG_TAG) << "ERROR. Can't wait for PCM to become ready: " << snd_strerror(wait_result) << "\n"; |
219 | uninitAlsa(); | |
499 | uninitAlsa(true); | |
500 | continue; | |
220 | 501 | } |
221 | 502 | else if (wait_result == 0) |
222 | 503 | { |
252 | 533 | } |
253 | 534 | } |
254 | 535 | |
536 | if (framesAvail < static_cast<snd_pcm_sframes_t>(frames_)) | |
537 | { | |
538 | this_thread::sleep_for(10ms); | |
539 | continue; | |
540 | } | |
541 | ||
542 | // LOG(TRACE, LOG_TAG) << "res: " << result << ", framesAvail: " << framesAvail << ", delay: " << framesDelay << ", frames: " << frames_ << "\n"; | |
255 | 543 | chronos::usec delay(static_cast<chronos::usec::rep>(1000 * (double)framesDelay / format.msRate())); |
256 | 544 | // LOG(TRACE, LOG_TAG) << "delay: " << framesDelay << ", delay[ms]: " << delay.count() / 1000 << ", avail: " << framesAvail << "\n"; |
257 | 545 | |
258 | 546 | if (buffer_.size() < static_cast<size_t>(framesAvail * format.frameSize())) |
259 | 547 | { |
260 | LOG(INFO, LOG_TAG) << "Resizing buffer from " << buffer_.size() << " to " << framesAvail * format.frameSize() << "\n"; | |
548 | LOG(DEBUG, LOG_TAG) << "Resizing buffer from " << buffer_.size() << " to " << framesAvail * format.frameSize() << "\n"; | |
261 | 549 | buffer_.resize(framesAvail * format.frameSize()); |
262 | 550 | } |
263 | 551 | if (stream_->getPlayerChunk(buffer_.data(), delay, framesAvail)) |
272 | 560 | else if (pcm < 0) |
273 | 561 | { |
274 | 562 | LOG(ERROR, LOG_TAG) << "ERROR. Can't write to PCM device: " << snd_strerror(pcm) << "\n"; |
275 | uninitAlsa(); | |
563 | uninitAlsa(true); | |
276 | 564 | } |
277 | 565 | } |
278 | 566 | else |
284 | 572 | if ((handle_ != nullptr) && (chronos::getTickCount() - lastChunkTick > 5000)) |
285 | 573 | { |
286 | 574 | LOG(NOTICE, LOG_TAG) << "No chunk received for 5000ms. Closing ALSA.\n"; |
287 | uninitAlsa(); | |
575 | uninitAlsa(false); | |
288 | 576 | stream_->clearChunks(); |
289 | 577 | } |
290 | 578 | } |
29 | 29 | class AlsaPlayer : public Player |
30 | 30 | { |
31 | 31 | public: |
32 | AlsaPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream); | |
32 | AlsaPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
33 | 33 | ~AlsaPlayer() override; |
34 | 34 | |
35 | /// Set audio volume in range [0..1] | |
36 | 35 | void start() override; |
37 | 36 | void stop() override; |
38 | 37 | |
41 | 40 | |
42 | 41 | protected: |
43 | 42 | void worker() override; |
43 | bool needsThread() const override; | |
44 | 44 | |
45 | 45 | private: |
46 | /// initialize alsa and the mixer (if neccessary) | |
46 | 47 | void initAlsa(); |
47 | void uninitAlsa(); | |
48 | /// free alsa and optionally the mixer | |
49 | /// @param uninit_mixer free the mixer | |
50 | void uninitAlsa(bool uninit_mixer); | |
51 | ||
52 | void initMixer(); | |
53 | void uninitMixer(); | |
54 | ||
55 | bool getHardwareVolume(double& volume, bool& muted) override; | |
56 | void setHardwareVolume(double volume, bool muted) override; | |
57 | ||
58 | void waitForEvent(); | |
48 | 59 | |
49 | 60 | snd_pcm_t* handle_; |
61 | snd_ctl_t* ctl_; | |
62 | ||
63 | snd_mixer_t* mixer_; | |
64 | snd_mixer_elem_t* elem_; | |
65 | std::string mixer_name_; | |
66 | std::string mixer_device_; | |
67 | ||
68 | std::unique_ptr<pollfd, std::function<void(pollfd*)>> fd_; | |
50 | 69 | std::vector<char> buffer_; |
51 | 70 | snd_pcm_uframes_t frames_; |
71 | boost::asio::posix::stream_descriptor sd_; | |
72 | std::chrono::time_point<std::chrono::steady_clock> last_change_; | |
73 | std::recursive_mutex mutex_; | |
74 | boost::asio::steady_timer timer_; | |
52 | 75 | }; |
53 | 76 | |
54 | 77 |
20 | 20 | |
21 | 21 | #define NUM_BUFFERS 2 |
22 | 22 | |
23 | static constexpr auto LOG_TAG = "CoreAudioPlayer"; | |
23 | 24 | |
24 | 25 | // http://stackoverflow.com/questions/4863811/how-to-use-audioqueue-to-play-a-sound-for-mac-osx-in-c |
25 | 26 | // https://gist.github.com/andormade/1360885 |
31 | 32 | } |
32 | 33 | |
33 | 34 | |
34 | CoreAudioPlayer::CoreAudioPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream) : Player(pcmDevice, stream), ms_(100), pubStream_(stream) | |
35 | CoreAudioPlayer::CoreAudioPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
36 | : Player(io_context, settings, stream), ms_(100), pubStream_(stream) | |
35 | 37 | { |
36 | 38 | } |
37 | 39 | |
78 | 80 | char buf[1024]; |
79 | 81 | theAddress = {kAudioDevicePropertyDeviceName, kAudioDevicePropertyScopeOutput, 0}; |
80 | 82 | AudioObjectGetPropertyData(devids[i], &theAddress, 0, NULL, &maxlen, buf); |
81 | LOG(DEBUG) << "device: " << i << ", name: " << buf << ", channels: " << channels << "\n"; | |
83 | LOG(DEBUG, LOG_TAG) << "device: " << i << ", name: " << buf << ", channels: " << channels << "\n"; | |
82 | 84 | |
83 | 85 | result.push_back(PcmDevice(i, buf)); |
84 | 86 | } |
97 | 99 | size_t bufferedMs = bufferedFrames * 1000 / pubStream_->getFormat().rate() + (ms_ * (NUM_BUFFERS - 1)); |
98 | 100 | /// 15ms DAC delay. Based on trying. |
99 | 101 | bufferedMs += 15; |
100 | // LOG(INFO) << "buffered: " << bufferedFrames << ", ms: " << bufferedMs << ", mSampleTime: " << timestamp.mSampleTime << "\n"; | |
102 | // LOG(INFO, LOG_TAG) << "buffered: " << bufferedFrames << ", ms: " << bufferedMs << ", mSampleTime: " << timestamp.mSampleTime << "\n"; | |
101 | 103 | |
102 | 104 | /// TODO: sometimes this bufferedMS or AudioTimeStamp wraps around 1s (i.e. we're 1s out of sync (behind)) and recovers later on |
103 | 105 | chronos::usec delay(bufferedMs * 1000); |
106 | 108 | { |
107 | 109 | if (chronos::getTickCount() - lastChunkTick > 5000) |
108 | 110 | { |
109 | LOG(NOTICE) << "No chunk received for 5000ms. Closing Audio Queue.\n"; | |
111 | LOG(NOTICE, LOG_TAG) << "No chunk received for 5000ms. Closing Audio Queue.\n"; | |
110 | 112 | uninitAudioQueue(queue); |
111 | 113 | return; |
112 | 114 | } |
113 | // LOG(INFO) << "Failed to get chunk. Playing silence.\n"; | |
115 | // LOG(INFO, LOG_TAG) << "Failed to get chunk. Playing silence.\n"; | |
114 | 116 | memset(buffer, 0, buff_size_); |
115 | 117 | } |
116 | 118 | else |
126 | 128 | { |
127 | 129 | uninitAudioQueue(queue); |
128 | 130 | } |
131 | } | |
132 | ||
133 | ||
134 | bool CoreAudioPlayer::needsThread() const | |
135 | { | |
136 | return true; | |
129 | 137 | } |
130 | 138 | |
131 | 139 | |
141 | 149 | } |
142 | 150 | catch (const std::exception& e) |
143 | 151 | { |
144 | LOG(ERROR) << "Exception in worker: " << e.what() << "\n"; | |
152 | LOG(ERROR, LOG_TAG) << "Exception in worker: " << e.what() << "\n"; | |
145 | 153 | chronos::sleep(100); |
146 | 154 | } |
147 | 155 | } |
178 | 186 | frames_ = (sampleFormat.rate() * ms_) / 1000; |
179 | 187 | ms_ = frames_ * 1000 / sampleFormat.rate(); |
180 | 188 | buff_size_ = frames_ * sampleFormat.frameSize(); |
181 | LOG(INFO) << "frames: " << frames_ << ", ms: " << ms_ << ", buffer size: " << buff_size_ << "\n"; | |
189 | LOG(INFO, LOG_TAG) << "frames: " << frames_ << ", ms: " << ms_ << ", buffer size: " << buff_size_ << "\n"; | |
182 | 190 | |
183 | 191 | AudioQueueBufferRef buffers[NUM_BUFFERS]; |
184 | 192 | for (int i = 0; i < NUM_BUFFERS; i++) |
188 | 196 | callback(this, queue, buffers[i]); |
189 | 197 | } |
190 | 198 | |
191 | LOG(ERROR) << "CoreAudioPlayer::worker\n"; | |
199 | LOG(ERROR, LOG_TAG) << "CoreAudioPlayer::worker\n"; | |
192 | 200 | AudioQueueCreateTimeline(queue, &timeLine_); |
193 | 201 | AudioQueueStart(queue, NULL); |
194 | 202 | CFRunLoopRun(); |
36 | 36 | class CoreAudioPlayer : public Player |
37 | 37 | { |
38 | 38 | public: |
39 | CoreAudioPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream); | |
39 | CoreAudioPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
40 | 40 | virtual ~CoreAudioPlayer(); |
41 | 41 | |
42 | 42 | void playerCallback(AudioQueueRef queue, AudioQueueBufferRef bufferRef); |
43 | 43 | static std::vector<PcmDevice> pcm_list(void); |
44 | 44 | |
45 | 45 | protected: |
46 | virtual void worker(); | |
46 | void worker() override; | |
47 | bool needsThread() const override; | |
48 | ||
47 | 49 | void initAudioQueue(); |
48 | 50 | void uninitAudioQueue(AudioQueueRef queue); |
49 | 51 |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include <assert.h> | |
19 | #include <iostream> | |
20 | ||
21 | #include "common/aixlog.hpp" | |
22 | #include "common/snap_exception.hpp" | |
23 | #include "common/str_compat.hpp" | |
24 | #include "common/utils/string_utils.hpp" | |
25 | #include "file_player.hpp" | |
26 | ||
27 | using namespace std; | |
28 | ||
29 | static constexpr auto LOG_TAG = "FilePlayer"; | |
30 | static constexpr auto kDefaultBuffer = 50ms; | |
31 | ||
32 | ||
33 | FilePlayer::FilePlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
34 | : Player(io_context, settings, stream), timer_(io_context), file_(nullptr) | |
35 | { | |
36 | auto params = utils::string::split_pairs(settings.parameter, ',', '='); | |
37 | string filename; | |
38 | if (params.find("filename") != params.end()) | |
39 | filename = params["filename"]; | |
40 | ||
41 | if (filename.empty() || (filename == "stdout")) | |
42 | { | |
43 | file_.reset(stdout, [](auto p) { std::ignore = p; }); | |
44 | } | |
45 | else if (filename == "stderr") | |
46 | { | |
47 | file_.reset(stderr, [](auto p) { std::ignore = p; }); | |
48 | } | |
49 | else | |
50 | { | |
51 | std::string mode = "w"; | |
52 | if (params.find("mode") != params.end()) | |
53 | mode = params["mode"]; | |
54 | if ((mode != "w") && (mode != "a")) | |
55 | throw SnapException("Mode must be w (write) or a (append)"); | |
56 | mode += "b"; | |
57 | file_.reset(fopen(filename.c_str(), mode.c_str()), [](auto p) { fclose(p); }); | |
58 | if (!file_) | |
59 | throw SnapException("Error opening file: '" + filename + "', error: " + cpt::to_string(errno)); | |
60 | } | |
61 | } | |
62 | ||
63 | ||
64 | FilePlayer::~FilePlayer() | |
65 | { | |
66 | LOG(DEBUG, LOG_TAG) << "Destructor\n"; | |
67 | stop(); | |
68 | } | |
69 | ||
70 | ||
71 | bool FilePlayer::needsThread() const | |
72 | { | |
73 | return false; | |
74 | } | |
75 | ||
76 | ||
77 | void FilePlayer::requestAudio() | |
78 | { | |
79 | auto numFrames = static_cast<uint32_t>(stream_->getFormat().msRate() * kDefaultBuffer.count()); | |
80 | auto needed = numFrames * stream_->getFormat().frameSize(); | |
81 | if (buffer_.size() < needed) | |
82 | buffer_.resize(needed); | |
83 | ||
84 | if (!stream_->getPlayerChunk(buffer_.data(), 10ms, numFrames)) | |
85 | { | |
86 | // LOG(INFO, LOG_TAG) << "Failed to get chunk. Playing silence.\n"; | |
87 | memset(buffer_.data(), 0, needed); | |
88 | } | |
89 | else | |
90 | { | |
91 | adjustVolume(static_cast<char*>(buffer_.data()), numFrames); | |
92 | } | |
93 | fwrite(buffer_.data(), 1, needed, file_.get()); | |
94 | fflush(file_.get()); | |
95 | loop(); | |
96 | } | |
97 | ||
98 | ||
99 | void FilePlayer::loop() | |
100 | { | |
101 | next_request_ += kDefaultBuffer; | |
102 | auto now = std::chrono::steady_clock::now(); | |
103 | if (next_request_ < now) | |
104 | next_request_ = now + 1ms; | |
105 | ||
106 | timer_.expires_at(next_request_); | |
107 | timer_.async_wait([this](boost::system::error_code ec) { | |
108 | if (ec) | |
109 | return; | |
110 | requestAudio(); | |
111 | }); | |
112 | } | |
113 | ||
114 | ||
115 | void FilePlayer::start() | |
116 | { | |
117 | next_request_ = std::chrono::steady_clock::now(); | |
118 | loop(); | |
119 | } | |
120 | ||
121 | ||
122 | void FilePlayer::stop() | |
123 | { | |
124 | LOG(INFO, LOG_TAG) << "Stop\n"; | |
125 | timer_.cancel(); | |
126 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef FILE_PLAYER_HPP | |
19 | #define FILE_PLAYER_HPP | |
20 | ||
21 | #include "player.hpp" | |
22 | #include <cstdio> | |
23 | #include <memory> | |
24 | ||
25 | /// File Player | |
26 | /// Used for testing and doesn't even write the received audio to file at the moment, | |
27 | /// but just discards it | |
28 | class FilePlayer : public Player | |
29 | { | |
30 | public: | |
31 | FilePlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
32 | virtual ~FilePlayer(); | |
33 | ||
34 | void start() override; | |
35 | void stop() override; | |
36 | ||
37 | protected: | |
38 | void requestAudio(); | |
39 | void loop(); | |
40 | bool needsThread() const override; | |
41 | boost::asio::steady_timer timer_; | |
42 | std::vector<char> buffer_; | |
43 | std::chrono::time_point<std::chrono::steady_clock> next_request_; | |
44 | std::shared_ptr<FILE> file_; | |
45 | }; | |
46 | ||
47 | ||
48 | #endif |
29 | 29 | static constexpr double kDefaultLatency = 50; |
30 | 30 | |
31 | 31 | |
32 | OboePlayer::OboePlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream) : Player(pcmDevice, stream) | |
32 | OboePlayer::OboePlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
33 | : Player(io_context, settings, stream) | |
33 | 34 | { |
34 | 35 | LOG(DEBUG, LOG_TAG) << "Contructor\n"; |
35 | 36 | LOG(INFO, LOG_TAG) << "Init start\n"; |
43 | 44 | LOG(INFO, LOG_TAG) << "DefaultStreamValues::SampleRate: " << oboe::DefaultStreamValues::SampleRate |
44 | 45 | << ", DefaultStreamValues::FramesPerBurst: " << oboe::DefaultStreamValues::FramesPerBurst << "\n"; |
45 | 46 | |
47 | ||
48 | auto result = openStream(); | |
49 | LOG(INFO, LOG_TAG) << "BufferSizeInFrames: " << out_stream_->getBufferSizeInFrames() << ", FramesPerBurst: " << out_stream_->getFramesPerBurst() << "\n"; | |
50 | if (result != oboe::Result::OK) | |
51 | LOG(ERROR, LOG_TAG) << "Error building AudioStream: " << oboe::convertToText(result) << "\n"; | |
52 | LOG(INFO, LOG_TAG) << "Init done\n"; | |
53 | } | |
54 | ||
55 | ||
56 | OboePlayer::~OboePlayer() | |
57 | { | |
58 | LOG(DEBUG, LOG_TAG) << "Destructor\n"; | |
59 | stop(); | |
60 | auto result = out_stream_->stop(std::chrono::nanoseconds(100ms).count()); | |
61 | if (result != oboe::Result::OK) | |
62 | LOG(ERROR, LOG_TAG) << "Error in AudioStream::stop: " << oboe::convertToText(result) << "\n"; | |
63 | result = out_stream_->close(); | |
64 | if (result != oboe::Result::OK) | |
65 | LOG(ERROR, LOG_TAG) << "Error in AudioStream::stop: " << oboe::convertToText(result) << "\n"; | |
66 | } | |
67 | ||
68 | ||
69 | oboe::Result OboePlayer::openStream() | |
70 | { | |
71 | oboe::SharingMode sharing_mode = oboe::SharingMode::Shared; | |
72 | if (settings_.sharing_mode == ClientSettings::SharingMode::exclusive) | |
73 | sharing_mode = oboe::SharingMode::Exclusive; | |
74 | ||
46 | 75 | // The builder set methods can be chained for convenience. |
47 | 76 | oboe::AudioStreamBuilder builder; |
48 | auto result = builder.setSharingMode(oboe::SharingMode::Exclusive) | |
77 | auto result = builder.setSharingMode(sharing_mode) | |
49 | 78 | ->setPerformanceMode(oboe::PerformanceMode::LowLatency) |
50 | ->setChannelCount(stream->getFormat().channels()) | |
51 | ->setSampleRate(stream->getFormat().rate()) | |
79 | ->setChannelCount(stream_->getFormat().channels()) | |
80 | ->setSampleRate(stream_->getFormat().rate()) | |
52 | 81 | ->setFormat(oboe::AudioFormat::I16) |
53 | 82 | ->setCallback(this) |
54 | 83 | ->setDirection(oboe::Direction::Output) |
55 | 84 | //->setFramesPerCallback((8 * stream->getFormat().rate) / 1000) |
56 | 85 | //->setFramesPerCallback(2 * oboe::DefaultStreamValues::FramesPerBurst) |
57 | 86 | //->setFramesPerCallback(960) // 2*192) |
58 | ->openManagedStream(out_stream_); | |
59 | LOG(INFO, LOG_TAG) << "BufferSizeInFrames: " << out_stream_->getBufferSizeInFrames() << ", FramesPerBurst: " << out_stream_->getFramesPerBurst() << "\n"; | |
60 | if (result != oboe::Result::OK) | |
61 | LOG(ERROR, LOG_TAG) << "Error building AudioStream: " << oboe::convertToText(result) << "\n"; | |
87 | ->openStream(out_stream_); | |
62 | 88 | |
63 | 89 | if (out_stream_->getAudioApi() == oboe::AudioApi::AAudio) |
64 | 90 | { |
71 | 97 | LOG(INFO, LOG_TAG) << "AudioApi: OpenSL\n"; |
72 | 98 | out_stream_->setBufferSizeInFrames(4 * out_stream_->getFramesPerBurst()); |
73 | 99 | } |
74 | LOG(INFO, LOG_TAG) << "Init done\n"; | |
75 | } | |
76 | ||
77 | ||
78 | OboePlayer::~OboePlayer() | |
79 | { | |
80 | LOG(DEBUG, LOG_TAG) << "Destructor\n"; | |
81 | stop(); | |
82 | auto result = out_stream_->stop(std::chrono::nanoseconds(100ms).count()); | |
83 | if (result != oboe::Result::OK) | |
84 | LOG(ERROR, LOG_TAG) << "Error in AudioStream::stop: " << oboe::convertToText(result) << "\n"; | |
85 | result = out_stream_->close(); | |
86 | if (result != oboe::Result::OK) | |
87 | LOG(ERROR, LOG_TAG) << "Error in AudioStream::stop: " << oboe::convertToText(result) << "\n"; | |
100 | ||
101 | return result; | |
102 | } | |
103 | ||
104 | ||
105 | bool OboePlayer::needsThread() const | |
106 | { | |
107 | return false; | |
88 | 108 | } |
89 | 109 | |
90 | 110 | |
129 | 149 | |
130 | 150 | if (!stream_->getPlayerChunk(audioData, delay, numFrames)) |
131 | 151 | { |
132 | // LOG(INFO) << "Failed to get chunk. Playing silence.\n"; | |
152 | // LOG(INFO, LOG_TAG) << "Failed to get chunk. Playing silence.\n"; | |
133 | 153 | memset(audioData, 0, numFrames * stream_->getFormat().frameSize()); |
134 | 154 | } |
135 | 155 | else |
138 | 158 | } |
139 | 159 | |
140 | 160 | return oboe::DataCallbackResult::Continue; |
161 | } | |
162 | ||
163 | ||
164 | void OboePlayer::onErrorBeforeClose(oboe::AudioStream* oboeStream, oboe::Result error) | |
165 | { | |
166 | std::ignore = oboeStream; | |
167 | LOG(INFO, LOG_TAG) << "onErrorBeforeClose: " << oboe::convertToText(error) << "\n"; | |
168 | stop(); | |
169 | } | |
170 | ||
171 | ||
172 | void OboePlayer::onErrorAfterClose(oboe::AudioStream* oboeStream, oboe::Result error) | |
173 | { | |
174 | // Tech Note: Disconnected Streams and Plugin Issues | |
175 | // https://github.com/google/oboe/blob/master/docs/notes/disconnect.md | |
176 | std::ignore = oboeStream; | |
177 | LOG(INFO, LOG_TAG) << "onErrorAfterClose: " << oboe::convertToText(error) << "\n"; | |
178 | auto result = openStream(); | |
179 | if (result != oboe::Result::OK) | |
180 | LOG(ERROR, LOG_TAG) << "Error building AudioStream: " << oboe::convertToText(result) << "\n"; | |
181 | start(); | |
141 | 182 | } |
142 | 183 | |
143 | 184 | |
158 | 199 | if (result != oboe::Result::OK) |
159 | 200 | LOG(ERROR, LOG_TAG) << "Error in requestStop: " << oboe::convertToText(result) << "\n"; |
160 | 201 | } |
161 | ||
162 | ||
163 | void OboePlayer::worker() | |
164 | { | |
165 | } |
23 | 23 | |
24 | 24 | #include "player.hpp" |
25 | 25 | |
26 | typedef int (*AndroidAudioCallback)(short* buffer, int num_samples); | |
27 | 26 | |
28 | ||
29 | /// OpenSL Audio Player | |
27 | /// Android Oboe Audio Player | |
30 | 28 | /** |
31 | * Player implementation for Oboe | |
29 | * Player implementation for Android Oboe | |
32 | 30 | */ |
33 | 31 | class OboePlayer : public Player, public oboe::AudioStreamCallback |
34 | 32 | { |
35 | 33 | public: |
36 | OboePlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream); | |
34 | OboePlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
37 | 35 | virtual ~OboePlayer(); |
38 | 36 | |
39 | 37 | void start() override; |
40 | 38 | void stop() override; |
41 | 39 | |
42 | 40 | protected: |
41 | // AudioStreamCallback overrides | |
43 | 42 | oboe::DataCallbackResult onAudioReady(oboe::AudioStream* oboeStream, void* audioData, int32_t numFrames) override; |
43 | void onErrorBeforeClose(oboe::AudioStream* oboeStream, oboe::Result error) override; | |
44 | void onErrorAfterClose(oboe::AudioStream* oboeStream, oboe::Result error) override; | |
45 | ||
46 | protected: | |
47 | oboe::Result openStream(); | |
44 | 48 | double getCurrentOutputLatencyMillis() const; |
45 | 49 | |
46 | void worker() override; | |
47 | oboe::ManagedStream out_stream_; | |
50 | bool needsThread() const override; | |
51 | std::shared_ptr<oboe::AudioStream> out_stream_; | |
48 | 52 | |
49 | 53 | std::unique_ptr<oboe::LatencyTuner> mLatencyTuner; |
50 | 54 | }; |
48 | 48 | } |
49 | 49 | |
50 | 50 | |
51 | OpenslPlayer::OpenslPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream) | |
52 | : Player(pcmDevice, stream), engineObject(NULL), engineEngine(NULL), outputMixObject(NULL), bqPlayerObject(NULL), bqPlayerPlay(NULL), | |
51 | OpenslPlayer::OpenslPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
52 | : Player(io_context, settings, stream), engineObject(NULL), engineEngine(NULL), outputMixObject(NULL), bqPlayerObject(NULL), bqPlayerPlay(NULL), | |
53 | 53 | bqPlayerBufferQueue(NULL), bqPlayerVolume(NULL), curBuffer(0), ms_(50), buff_size(0), pubStream_(stream) |
54 | 54 | { |
55 | 55 | initOpensl(); |
141 | 141 | } |
142 | 142 | |
143 | 143 | |
144 | bool OpenslPlayer::needsThread() const | |
145 | { | |
146 | return false; | |
147 | } | |
148 | ||
144 | 149 | |
145 | 150 | void OpenslPlayer::throwUnsuccess(const std::string& phase, const std::string& what, SLresult result) |
146 | 151 | { |
369 | 374 | (*bqPlayerBufferQueue)->Clear(bqPlayerBufferQueue); |
370 | 375 | throwUnsuccess(kPhaseStop, "PlayerPlay::SetPlayState", result); |
371 | 376 | } |
372 | ||
373 | ||
374 | void OpenslPlayer::worker() | |
375 | { | |
376 | } |
34 | 34 | class OpenslPlayer : public Player |
35 | 35 | { |
36 | 36 | public: |
37 | OpenslPlayer(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream); | |
37 | OpenslPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
38 | 38 | virtual ~OpenslPlayer(); |
39 | 39 | |
40 | 40 | void start() override; |
46 | 46 | void initOpensl(); |
47 | 47 | void uninitOpensl(); |
48 | 48 | |
49 | void worker() override; | |
49 | bool needsThread() const override; | |
50 | 50 | void throwUnsuccess(const std::string& phase, const std::string& what, SLresult result); |
51 | 51 | std::string resultToString(SLresult result) const; |
52 | 52 |
18 | 18 | #include <cmath> |
19 | 19 | #include <iostream> |
20 | 20 | |
21 | #ifdef WINDOWS | |
22 | #include <cstdlib> | |
23 | #else | |
24 | #pragma GCC diagnostic push | |
25 | #pragma GCC diagnostic ignored "-Wunused-result" | |
26 | #pragma GCC diagnostic ignored "-Wunused-parameter" | |
27 | #pragma GCC diagnostic ignored "-Wmissing-braces" | |
28 | #include <boost/process/args.hpp> | |
29 | #include <boost/process/child.hpp> | |
30 | #include <boost/process/exe.hpp> | |
31 | #pragma GCC diagnostic pop | |
32 | #endif | |
33 | ||
21 | 34 | #include "common/aixlog.hpp" |
35 | #include "common/snap_exception.hpp" | |
36 | #include "common/str_compat.hpp" | |
37 | #include "common/utils/string_utils.hpp" | |
22 | 38 | #include "player.hpp" |
23 | 39 | |
24 | 40 | |
25 | 41 | using namespace std; |
26 | 42 | |
27 | ||
28 | Player::Player(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream) | |
29 | : active_(false), stream_(stream), pcmDevice_(pcmDevice), volume_(1.0), muted_(false), volCorrection_(1.0) | |
30 | { | |
31 | } | |
32 | ||
43 | static constexpr auto LOG_TAG = "Player"; | |
44 | ||
45 | Player::Player(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
46 | : io_context_(io_context), active_(false), stream_(stream), settings_(settings), volume_(1.0), muted_(false), volCorrection_(1.0) | |
47 | { | |
48 | string sharing_mode; | |
49 | switch (settings_.sharing_mode) | |
50 | { | |
51 | case ClientSettings::SharingMode::unspecified: | |
52 | sharing_mode = "unspecified"; | |
53 | break; | |
54 | case ClientSettings::SharingMode::exclusive: | |
55 | sharing_mode = "exclusive"; | |
56 | break; | |
57 | case ClientSettings::SharingMode::shared: | |
58 | sharing_mode = "shared"; | |
59 | break; | |
60 | } | |
61 | ||
62 | auto not_empty = [](const std::string& value) -> std::string { | |
63 | if (!value.empty()) | |
64 | return value; | |
65 | else | |
66 | return "<none>"; | |
67 | }; | |
68 | LOG(INFO, LOG_TAG) << "Player name: " << not_empty(settings_.player_name) << ", device: " << not_empty(settings_.pcm_device.name) | |
69 | << ", description: " << not_empty(settings_.pcm_device.description) << ", idx: " << settings_.pcm_device.idx | |
70 | << ", sharing mode: " << sharing_mode << ", parameters: " << not_empty(settings.parameter) << "\n"; | |
71 | ||
72 | string mixer; | |
73 | switch (settings_.mixer.mode) | |
74 | { | |
75 | case ClientSettings::Mixer::Mode::hardware: | |
76 | mixer = "hardware"; | |
77 | break; | |
78 | case ClientSettings::Mixer::Mode::software: | |
79 | mixer = "software"; | |
80 | break; | |
81 | case ClientSettings::Mixer::Mode::script: | |
82 | mixer = "script"; | |
83 | break; | |
84 | case ClientSettings::Mixer::Mode::none: | |
85 | mixer = "none"; | |
86 | break; | |
87 | } | |
88 | LOG(INFO, LOG_TAG) << "Mixer mode: " << mixer << ", parameters: " << not_empty(settings_.mixer.parameter) << "\n"; | |
89 | LOG(INFO, LOG_TAG) << "Sampleformat: " << (settings_.sample_format.isInitialized() ? settings_.sample_format.toString() : stream->getFormat().toString()) | |
90 | << ", stream: " << stream->getFormat().toString() << "\n"; | |
91 | } | |
92 | ||
93 | ||
94 | Player::~Player() | |
95 | { | |
96 | stop(); | |
97 | } | |
33 | 98 | |
34 | 99 | |
35 | 100 | void Player::start() |
36 | 101 | { |
37 | 102 | active_ = true; |
38 | playerThread_ = thread(&Player::worker, this); | |
39 | } | |
40 | ||
41 | ||
42 | Player::~Player() | |
43 | { | |
44 | stop(); | |
45 | } | |
46 | ||
103 | if (needsThread()) | |
104 | playerThread_ = thread(&Player::worker, this); | |
105 | ||
106 | // If hardware mixer is used, send the initial volume to the server, because this is | |
107 | // the volume that is configured by the user on his local device, so we shouldn't change it | |
108 | // on client start up | |
109 | if (settings_.mixer.mode == ClientSettings::Mixer::Mode::hardware) | |
110 | { | |
111 | double volume; | |
112 | bool muted; | |
113 | if (getHardwareVolume(volume, muted)) | |
114 | { | |
115 | LOG(DEBUG, LOG_TAG) << "Volume: " << volume << ", muted: " << muted << "\n"; | |
116 | notifyVolumeChange(volume, muted); | |
117 | } | |
118 | } | |
119 | } | |
47 | 120 | |
48 | 121 | |
49 | 122 | void Player::stop() |
51 | 124 | if (active_) |
52 | 125 | { |
53 | 126 | active_ = false; |
54 | playerThread_.join(); | |
55 | } | |
127 | if (playerThread_.joinable()) | |
128 | playerThread_.join(); | |
129 | } | |
130 | } | |
131 | ||
132 | ||
133 | void Player::worker() | |
134 | { | |
135 | } | |
136 | ||
137 | ||
138 | void Player::setHardwareVolume(double volume, bool muted) | |
139 | { | |
140 | std::ignore = volume; | |
141 | std::ignore = muted; | |
142 | throw SnapException("Failed to set hardware mixer volume: not supported"); | |
143 | } | |
144 | ||
145 | ||
146 | bool Player::getHardwareVolume(double& volume, bool& muted) | |
147 | { | |
148 | std::ignore = volume; | |
149 | std::ignore = muted; | |
150 | throw SnapException("Failed to get hardware mixer volume: not supported"); | |
151 | return false; | |
56 | 152 | } |
57 | 153 | |
58 | 154 | |
59 | 155 | void Player::adjustVolume(char* buffer, size_t frames) |
60 | 156 | { |
61 | double volume = volume_; | |
62 | if (muted_) | |
63 | volume = 0.; | |
64 | ||
65 | const SampleFormat& sampleFormat = stream_->getFormat(); | |
66 | ||
67 | if ((volume < 1.0) || (volCorrection_ != 1.)) | |
68 | { | |
157 | double volume = volCorrection_; | |
158 | // apply volume changes only for software mixer | |
159 | // for any other mixer, we might still have to apply the volCorrection_ | |
160 | if (settings_.mixer.mode == ClientSettings::Mixer::Mode::software) | |
161 | { | |
162 | volume = muted_ ? 0. : volume_; | |
69 | 163 | volume *= volCorrection_; |
164 | } | |
165 | ||
166 | if (volume != 1.0) | |
167 | { | |
168 | const SampleFormat& sampleFormat = stream_->getFormat(); | |
70 | 169 | if (sampleFormat.sampleSize() == 1) |
71 | 170 | adjustVolume<int8_t>(buffer, frames * sampleFormat.channels(), volume); |
72 | 171 | else if (sampleFormat.sampleSize() == 2) |
83 | 182 | void Player::setVolume_poly(double volume, double exp) |
84 | 183 | { |
85 | 184 | volume_ = std::pow(volume, exp); |
86 | LOG(DEBUG) << "setVolume poly: " << volume << " => " << volume_ << "\n"; | |
185 | LOG(DEBUG, LOG_TAG) << "setVolume poly with exp " << exp << ": " << volume << " => " << volume_ << "\n"; | |
87 | 186 | } |
88 | 187 | |
89 | 188 | |
93 | 192 | // double base = M_E; |
94 | 193 | // double base = 10.; |
95 | 194 | volume_ = (pow(base, volume) - 1) / (base - 1); |
96 | LOG(DEBUG) << "setVolume exp: " << volume << " => " << volume_ << "\n"; | |
97 | } | |
98 | ||
99 | ||
100 | void Player::setVolume(double volume) | |
101 | { | |
102 | setVolume_exp(volume, 10.); | |
103 | } | |
104 | ||
105 | ||
106 | void Player::setMute(bool mute) | |
107 | { | |
195 | LOG(DEBUG, LOG_TAG) << "setVolume exp with base " << base << ": " << volume << " => " << volume_ << "\n"; | |
196 | } | |
197 | ||
198 | ||
199 | void Player::setVolume(double volume, bool mute) | |
200 | { | |
201 | volume_ = volume; | |
108 | 202 | muted_ = mute; |
109 | } | |
203 | if (settings_.mixer.mode == ClientSettings::Mixer::Mode::hardware) | |
204 | { | |
205 | setHardwareVolume(volume, muted_); | |
206 | } | |
207 | else if (settings_.mixer.mode == ClientSettings::Mixer::Mode::software) | |
208 | { | |
209 | string param; | |
210 | string mode = utils::string::split_left(settings_.mixer.parameter, ':', param); | |
211 | double dparam = -1.; | |
212 | if (!param.empty()) | |
213 | { | |
214 | try | |
215 | { | |
216 | dparam = cpt::stod(param); | |
217 | if (dparam < 0) | |
218 | throw SnapException("must be a positive number"); | |
219 | } | |
220 | catch (const std::exception& e) | |
221 | { | |
222 | throw SnapException("Invalid mixer param: " + param + ", error: " + string(e.what())); | |
223 | } | |
224 | } | |
225 | if (mode == "poly") | |
226 | setVolume_poly(volume, (dparam < 0) ? 3. : dparam); | |
227 | else | |
228 | setVolume_exp(volume, (dparam < 0) ? 10. : dparam); | |
229 | } | |
230 | else if (settings_.mixer.mode == ClientSettings::Mixer::Mode::script) | |
231 | { | |
232 | try | |
233 | { | |
234 | #ifdef WINDOWS | |
235 | string cmd = settings_.mixer.parameter + " --volume " + cpt::to_string(volume) + " --mute " + (mute ? "true" : "false"); | |
236 | std::system(cmd.c_str()); | |
237 | #else | |
238 | using namespace boost::process; | |
239 | child c(exe = settings_.mixer.parameter, args = {"--volume", cpt::to_string(volume), "--mute", mute ? "true" : "false"}); | |
240 | c.detach(); | |
241 | #endif | |
242 | } | |
243 | catch (const std::exception& e) | |
244 | { | |
245 | LOG(ERROR, LOG_TAG) << "Failed to run script '" + settings_.mixer.parameter + "', error: " << e.what() << "\n"; | |
246 | } | |
247 | } | |
248 | } |
18 | 18 | #ifndef PLAYER_H |
19 | 19 | #define PLAYER_H |
20 | 20 | |
21 | #include "client_settings.hpp" | |
21 | 22 | #include "common/aixlog.hpp" |
22 | 23 | #include "common/endian.hpp" |
23 | #include "pcm_device.hpp" | |
24 | 24 | #include "stream.hpp" |
25 | ||
26 | #include <boost/asio.hpp> | |
27 | ||
25 | 28 | #include <atomic> |
29 | #include <functional> | |
26 | 30 | #include <string> |
27 | 31 | #include <thread> |
28 | 32 | #include <vector> |
34 | 38 | */ |
35 | 39 | class Player |
36 | 40 | { |
41 | using volume_callback = std::function<void(double volume, bool muted)>; | |
42 | ||
37 | 43 | public: |
38 | Player(const PcmDevice& pcmDevice, std::shared_ptr<Stream> stream); | |
44 | Player(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
39 | 45 | virtual ~Player(); |
40 | 46 | |
41 | 47 | /// Set audio volume in range [0..1] |
42 | virtual void setVolume(double volume); | |
43 | virtual void setMute(bool mute); | |
48 | /// @param volume the volume on range [0..1] | |
49 | /// @param muted muted or not | |
50 | virtual void setVolume(double volume, bool mute); | |
51 | ||
52 | /// Called on start, before the first audio sample is sent or any other function is called. | |
53 | /// In case of hardware mixer, it will call getVolume and notify the server about the current volume | |
44 | 54 | virtual void start(); |
55 | /// Called on stop | |
45 | 56 | virtual void stop(); |
57 | /// Sets the hardware volume change callback | |
58 | void setVolumeCallback(const volume_callback& callback) | |
59 | { | |
60 | onVolumeChanged_ = callback; | |
61 | } | |
46 | 62 | |
47 | 63 | protected: |
48 | virtual void worker() = 0; | |
64 | /// will be run in a thread if needsThread is true | |
65 | virtual void worker(); | |
66 | /// @return true if the worker function should be started in a thread | |
67 | virtual bool needsThread() const = 0; | |
68 | ||
69 | /// get the hardware mixer volume | |
70 | /// @param[out] volume the volume on range [0..1] | |
71 | /// @param[out] muted muted or not | |
72 | /// @return success or not | |
73 | virtual bool getHardwareVolume(double& volume, bool& muted); | |
74 | ||
75 | /// set the hardware mixer volume | |
76 | /// @param volume the volume on range [0..1] | |
77 | /// @param muted muted or not | |
78 | virtual void setHardwareVolume(double volume, bool muted); | |
49 | 79 | |
50 | 80 | void setVolume_poly(double volume, double exp); |
51 | 81 | void setVolume_exp(double volume, double base); |
52 | 82 | |
83 | void adjustVolume(char* buffer, size_t frames); | |
84 | ||
85 | /// Notify the server about hardware volume changes | |
86 | /// @param volume the volume in range [0..1] | |
87 | /// @param muted if muted or not | |
88 | void notifyVolumeChange(double volume, bool muted) const | |
89 | { | |
90 | if (onVolumeChanged_) | |
91 | onVolumeChanged_(volume, muted); | |
92 | } | |
93 | ||
94 | boost::asio::io_context& io_context_; | |
95 | std::atomic<bool> active_; | |
96 | std::shared_ptr<Stream> stream_; | |
97 | std::thread playerThread_; | |
98 | ClientSettings::Player settings_; | |
99 | double volume_; | |
100 | bool muted_; | |
101 | double volCorrection_; | |
102 | volume_callback onVolumeChanged_; | |
103 | ||
104 | private: | |
53 | 105 | template <typename T> |
54 | 106 | void adjustVolume(char* buffer, size_t count, double volume) |
55 | 107 | { |
56 | 108 | T* bufferT = (T*)buffer; |
57 | 109 | for (size_t n = 0; n < count; ++n) |
58 | bufferT[n] = endian::swap<T>(endian::swap<T>(bufferT[n]) * volume); | |
110 | bufferT[n] = endian::swap<T>(static_cast<T>(endian::swap<T>(bufferT[n]) * volume)); | |
59 | 111 | } |
60 | ||
61 | void adjustVolume(char* buffer, size_t frames); | |
62 | ||
63 | std::atomic<bool> active_; | |
64 | std::shared_ptr<Stream> stream_; | |
65 | std::thread playerThread_; | |
66 | PcmDevice pcmDevice_; | |
67 | double volume_; | |
68 | bool muted_; | |
69 | double volCorrection_; | |
70 | 112 | }; |
71 | 113 | |
72 | 114 |
0 | #include "wasapi_player.hpp" | |
1 | #include <initguid.h> | |
2 | #include <mmdeviceapi.h> | |
3 | //#include <functiondiscoverykeys_devpkey.h> | |
4 | #include "common/aixlog.hpp" | |
5 | #include "common/snap_exception.hpp" | |
6 | #include <assert.h> | |
7 | #include <audioclient.h> | |
8 | #include <avrt.h> | |
9 | #include <chrono> | |
10 | #include <codecvt> | |
11 | #include <comdef.h> | |
12 | #include <comip.h> | |
13 | #include <functional> | |
14 | #include <ksmedia.h> | |
15 | #include <locale> | |
16 | #include <mmdeviceapi.h> | |
17 | ||
18 | using namespace std; | |
19 | using namespace std::chrono; | |
20 | using namespace std::chrono_literals; | |
21 | ||
22 | static constexpr auto LOG_TAG = "WASAPI"; | |
23 | ||
24 | template <typename T> | |
25 | struct COMMemDeleter | |
26 | { | |
27 | void operator()(T* obj) | |
28 | { | |
29 | if (obj != NULL) | |
30 | { | |
31 | CoTaskMemFree(obj); | |
32 | obj = NULL; | |
33 | } | |
34 | } | |
35 | }; | |
36 | ||
37 | template <typename T> | |
38 | using com_mem_ptr = unique_ptr<T, COMMemDeleter<T>>; | |
39 | ||
40 | using com_handle = unique_ptr<void, function<BOOL(HANDLE)>>; | |
41 | ||
42 | const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator); | |
43 | const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator); | |
44 | const IID IID_IAudioClient = __uuidof(IAudioClient); | |
45 | const IID IID_IAudioRenderClient = __uuidof(IAudioRenderClient); | |
46 | const IID IID_IAudioClock = __uuidof(IAudioClock); | |
47 | const IID IID_IAudioEndpointVolume = _uuidof(IAudioEndpointVolume); | |
48 | ||
49 | _COM_SMARTPTR_TYPEDEF(IMMDevice, __uuidof(IMMDevice)); | |
50 | _COM_SMARTPTR_TYPEDEF(IMMDeviceCollection, __uuidof(IMMDeviceCollection)); | |
51 | _COM_SMARTPTR_TYPEDEF(IMMDeviceEnumerator, __uuidof(IMMDeviceEnumerator)); | |
52 | _COM_SMARTPTR_TYPEDEF(IAudioClient, __uuidof(IAudioClient)); | |
53 | _COM_SMARTPTR_TYPEDEF(IPropertyStore, __uuidof(IPropertyStore)); | |
54 | _COM_SMARTPTR_TYPEDEF(IAudioSessionManager, __uuidof(IAudioSessionManager)); | |
55 | _COM_SMARTPTR_TYPEDEF(IAudioSessionControl, __uuidof(IAudioSessionControl)); | |
56 | ||
57 | #define REFTIMES_PER_SEC 10000000 | |
58 | #define REFTIMES_PER_MILLISEC 10000 | |
59 | ||
60 | EXTERN_C const PROPERTYKEY DECLSPEC_SELECTANY PKEY_Device_FriendlyName = {{0xa45c254e, 0xdf1c, 0x4efd, {0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0}}, 14}; | |
61 | ||
62 | #define CHECK_HR(hres) \ | |
63 | if (FAILED(hres)) \ | |
64 | { \ | |
65 | stringstream ss; \ | |
66 | ss << "HRESULT fault status: " << hex << (hres) << " line " << dec << __LINE__ << endl; \ | |
67 | LOG(FATAL, LOG_TAG) << ss.str(); \ | |
68 | throw SnapException(ss.str()); \ | |
69 | } | |
70 | ||
71 | WASAPIPlayer::WASAPIPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream) | |
72 | : Player(io_context, settings, stream) | |
73 | { | |
74 | HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); | |
75 | CHECK_HR(hr); | |
76 | ||
77 | audioEventListener_ = new AudioSessionEventListener(); | |
78 | } | |
79 | ||
80 | WASAPIPlayer::~WASAPIPlayer() | |
81 | { | |
82 | WASAPIPlayer::stop(); | |
83 | } | |
84 | ||
85 | inline PcmDevice convertToDevice(int idx, IMMDevicePtr& device) | |
86 | { | |
87 | HRESULT hr; | |
88 | PcmDevice desc; | |
89 | ||
90 | LPWSTR id = NULL; | |
91 | hr = device->GetId(&id); | |
92 | CHECK_HR(hr); | |
93 | ||
94 | IPropertyStorePtr properties = nullptr; | |
95 | hr = device->OpenPropertyStore(STGM_READ, &properties); | |
96 | ||
97 | PROPVARIANT deviceName; | |
98 | PropVariantInit(&deviceName); | |
99 | ||
100 | hr = properties->GetValue(PKEY_Device_FriendlyName, &deviceName); | |
101 | CHECK_HR(hr); | |
102 | ||
103 | desc.idx = idx; | |
104 | desc.name = wstring_convert<codecvt_utf8<wchar_t>, wchar_t>().to_bytes(id); | |
105 | desc.description = wstring_convert<codecvt_utf8<wchar_t>, wchar_t>().to_bytes(deviceName.pwszVal); | |
106 | ||
107 | CoTaskMemFree(id); | |
108 | ||
109 | return desc; | |
110 | } | |
111 | ||
112 | vector<PcmDevice> WASAPIPlayer::pcm_list() | |
113 | { | |
114 | HRESULT hr; | |
115 | IMMDeviceCollectionPtr devices = nullptr; | |
116 | IMMDeviceEnumeratorPtr deviceEnumerator = nullptr; | |
117 | ||
118 | hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); | |
119 | if (hr != CO_E_ALREADYINITIALIZED) | |
120 | CHECK_HR(hr); | |
121 | ||
122 | hr = CoCreateInstance(CLSID_MMDeviceEnumerator, NULL, CLSCTX_SERVER, IID_IMMDeviceEnumerator, (void**)&deviceEnumerator); | |
123 | CHECK_HR(hr); | |
124 | ||
125 | hr = deviceEnumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &devices); | |
126 | CHECK_HR(hr); | |
127 | ||
128 | UINT deviceCount; | |
129 | devices->GetCount(&deviceCount); | |
130 | ||
131 | if (deviceCount == 0) | |
132 | throw SnapException("no valid devices"); | |
133 | ||
134 | vector<PcmDevice> deviceList; | |
135 | ||
136 | { | |
137 | IMMDevicePtr defaultDevice = nullptr; | |
138 | hr = deviceEnumerator->GetDefaultAudioEndpoint(eRender, eConsole, &defaultDevice); | |
139 | CHECK_HR(hr); | |
140 | ||
141 | auto dev = convertToDevice(0, defaultDevice); | |
142 | dev.name = "default"; | |
143 | deviceList.push_back(dev); | |
144 | } | |
145 | ||
146 | for (UINT i = 0; i < deviceCount; ++i) | |
147 | { | |
148 | IMMDevicePtr device = nullptr; | |
149 | ||
150 | hr = devices->Item(i, &device); | |
151 | CHECK_HR(hr); | |
152 | deviceList.push_back(convertToDevice(i + 1, device)); | |
153 | } | |
154 | ||
155 | return deviceList; | |
156 | } | |
157 | ||
158 | void WASAPIPlayer::worker() | |
159 | { | |
160 | assert(sizeof(char) == sizeof(BYTE)); | |
161 | ||
162 | HRESULT hr; | |
163 | ||
164 | // Create the format specifier | |
165 | com_mem_ptr<WAVEFORMATEX> waveformat((WAVEFORMATEX*)(CoTaskMemAlloc(sizeof(WAVEFORMATEX)))); | |
166 | waveformat->wFormatTag = WAVE_FORMAT_PCM; | |
167 | waveformat->nChannels = stream_->getFormat().channels(); | |
168 | waveformat->nSamplesPerSec = stream_->getFormat().rate(); | |
169 | waveformat->wBitsPerSample = stream_->getFormat().bits(); | |
170 | ||
171 | waveformat->nBlockAlign = waveformat->nChannels * waveformat->wBitsPerSample / 8; | |
172 | waveformat->nAvgBytesPerSec = waveformat->nSamplesPerSec * waveformat->nBlockAlign; | |
173 | ||
174 | waveformat->cbSize = 0; | |
175 | ||
176 | com_mem_ptr<WAVEFORMATEXTENSIBLE> waveformatExtended((WAVEFORMATEXTENSIBLE*)(CoTaskMemAlloc(sizeof(WAVEFORMATEXTENSIBLE)))); | |
177 | waveformatExtended->Format = *waveformat; | |
178 | waveformatExtended->Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; | |
179 | waveformatExtended->Format.cbSize = 22; | |
180 | waveformatExtended->Samples.wValidBitsPerSample = waveformat->wBitsPerSample; | |
181 | waveformatExtended->dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; | |
182 | waveformatExtended->SubFormat = KSDATAFORMAT_SUBTYPE_PCM; | |
183 | ||
184 | ||
185 | // Retrieve the device enumerator | |
186 | IMMDeviceEnumeratorPtr deviceEnumerator = nullptr; | |
187 | hr = CoCreateInstance(CLSID_MMDeviceEnumerator, NULL, CLSCTX_SERVER, IID_IMMDeviceEnumerator, (void**)&deviceEnumerator); | |
188 | CHECK_HR(hr); | |
189 | ||
190 | // Register the default playback device (eRender for playback) | |
191 | IMMDevicePtr device = nullptr; | |
192 | if (settings_.pcm_device.idx == 0) | |
193 | { | |
194 | hr = deviceEnumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); | |
195 | CHECK_HR(hr); | |
196 | } | |
197 | else | |
198 | { | |
199 | IMMDeviceCollectionPtr devices = nullptr; | |
200 | hr = deviceEnumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &devices); | |
201 | CHECK_HR(hr); | |
202 | ||
203 | devices->Item(settings_.pcm_device.idx - 1, &device); | |
204 | } | |
205 | ||
206 | IPropertyStorePtr properties = nullptr; | |
207 | hr = device->OpenPropertyStore(STGM_READ, &properties); | |
208 | CHECK_HR(hr); | |
209 | ||
210 | PROPVARIANT format; | |
211 | hr = properties->GetValue(PKEY_AudioEngine_DeviceFormat, &format); | |
212 | CHECK_HR(hr); | |
213 | ||
214 | PWAVEFORMATEX formatEx = (PWAVEFORMATEX)format.blob.pBlobData; | |
215 | LOG(INFO, LOG_TAG) << "Device accepts format: " << formatEx->nSamplesPerSec << ":" << formatEx->wBitsPerSample << ":" << formatEx->nChannels << "\n"; | |
216 | // Activate the device | |
217 | IAudioClientPtr audioClient = nullptr; | |
218 | hr = device->Activate(IID_IAudioClient, CLSCTX_SERVER, NULL, (void**)&audioClient); | |
219 | CHECK_HR(hr); | |
220 | ||
221 | if (settings_.sharing_mode == ClientSettings::SharingMode::exclusive) | |
222 | { | |
223 | hr = audioClient->IsFormatSupported(AUDCLNT_SHAREMODE_EXCLUSIVE, &(waveformatExtended->Format), NULL); | |
224 | CHECK_HR(hr); | |
225 | } | |
226 | ||
227 | IAudioSessionManagerPtr sessionManager = nullptr; | |
228 | // Get the session manager for the endpoint device. | |
229 | hr = device->Activate(__uuidof(IAudioSessionManager), CLSCTX_INPROC_SERVER, NULL, (void**)&sessionManager); | |
230 | CHECK_HR(hr); | |
231 | ||
232 | ||
233 | // Get the control interface for the process-specific audio | |
234 | // session with session GUID = GUID_NULL. This is the session | |
235 | // that an audio stream for a DirectSound, DirectShow, waveOut, | |
236 | // or PlaySound application stream belongs to by default. | |
237 | IAudioSessionControlPtr control = nullptr; | |
238 | hr = sessionManager->GetAudioSessionControl(NULL, 0, &control); | |
239 | CHECK_HR(hr); | |
240 | ||
241 | // register | |
242 | hr = control->RegisterAudioSessionNotification(audioEventListener_); | |
243 | CHECK_HR(hr); | |
244 | ||
245 | AudioEndpointVolumeCallback audioEndpointVolumeCallback; | |
246 | hr = device->Activate(IID_IAudioEndpointVolume, CLSCTX_ALL, NULL, (void**)&audioEndpointListener_); | |
247 | ||
248 | audioEndpointListener_->RegisterControlChangeNotify((IAudioEndpointVolumeCallback*)&audioEndpointVolumeCallback); | |
249 | ||
250 | // Get the device period | |
251 | REFERENCE_TIME hnsRequestedDuration = REFTIMES_PER_SEC; | |
252 | hr = audioClient->GetDevicePeriod(NULL, &hnsRequestedDuration); | |
253 | CHECK_HR(hr); | |
254 | ||
255 | LOG(INFO, LOG_TAG) << "Initializing WASAPI in " << (settings_.sharing_mode == ClientSettings::SharingMode::shared ? "shared" : "exclusive") << " mode\n"; | |
256 | ||
257 | _AUDCLNT_SHAREMODE share_mode = settings_.sharing_mode == ClientSettings::SharingMode::shared ? AUDCLNT_SHAREMODE_SHARED : AUDCLNT_SHAREMODE_EXCLUSIVE; | |
258 | DWORD stream_flags = settings_.sharing_mode == ClientSettings::SharingMode::shared | |
259 | ? AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY | |
260 | : AUDCLNT_STREAMFLAGS_EVENTCALLBACK; | |
261 | ||
262 | // Initialize the client at minimum latency | |
263 | hr = audioClient->Initialize(share_mode, stream_flags, hnsRequestedDuration, hnsRequestedDuration, &(waveformatExtended->Format), NULL); | |
264 | ||
265 | if (hr == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) | |
266 | { | |
267 | UINT32 alignedBufferSize; | |
268 | hr = audioClient->GetBufferSize(&alignedBufferSize); | |
269 | CHECK_HR(hr); | |
270 | audioClient.Attach(NULL, false); | |
271 | hnsRequestedDuration = (REFERENCE_TIME)((10000.0 * 1000 / waveformatExtended->Format.nSamplesPerSec * alignedBufferSize) + 0.5); | |
272 | hr = device->Activate(IID_IAudioClient, CLSCTX_SERVER, NULL, (void**)&audioClient); | |
273 | CHECK_HR(hr); | |
274 | hr = audioClient->Initialize(share_mode, stream_flags, hnsRequestedDuration, hnsRequestedDuration, &(waveformatExtended->Format), NULL); | |
275 | } | |
276 | CHECK_HR(hr); | |
277 | ||
278 | // Register an event to refill the buffer | |
279 | com_handle eventHandle(CreateEvent(NULL, FALSE, FALSE, NULL), &::CloseHandle); | |
280 | if (eventHandle == NULL) | |
281 | CHECK_HR(E_FAIL); | |
282 | hr = audioClient->SetEventHandle(HANDLE(eventHandle.get())); | |
283 | CHECK_HR(hr); | |
284 | ||
285 | // Get size of buffer | |
286 | UINT32 bufferFrameCount; | |
287 | hr = audioClient->GetBufferSize(&bufferFrameCount); | |
288 | CHECK_HR(hr); | |
289 | ||
290 | // Get the rendering service | |
291 | IAudioRenderClient* renderClient = NULL; | |
292 | hr = audioClient->GetService(IID_IAudioRenderClient, (void**)&renderClient); | |
293 | CHECK_HR(hr); | |
294 | ||
295 | // Grab the clock service | |
296 | IAudioClock* clock = NULL; | |
297 | hr = audioClient->GetService(IID_IAudioClock, (void**)&clock); | |
298 | CHECK_HR(hr); | |
299 | ||
300 | // Boost our priority | |
301 | DWORD taskIndex = 0; | |
302 | com_handle taskHandle(AvSetMmThreadCharacteristics(TEXT("Pro Audio"), &taskIndex), &::AvRevertMmThreadCharacteristics); | |
303 | if (taskHandle == NULL) | |
304 | CHECK_HR(E_FAIL); | |
305 | ||
306 | // And, action! | |
307 | hr = audioClient->Start(); | |
308 | CHECK_HR(hr); | |
309 | ||
310 | size_t bufferSize = bufferFrameCount * waveformatExtended->Format.nBlockAlign; | |
311 | BYTE* buffer; | |
312 | unique_ptr<char[]> queueBuffer(new char[bufferSize]); | |
313 | UINT64 position = 0, bufferPosition = 0, frequency; | |
314 | clock->GetFrequency(&frequency); | |
315 | ||
316 | while (active_) | |
317 | { | |
318 | DWORD returnVal = WaitForSingleObject(eventHandle.get(), 2000); | |
319 | if (returnVal != WAIT_OBJECT_0) | |
320 | { | |
321 | // stop(); | |
322 | LOG(INFO, LOG_TAG) << "Got timeout waiting for audio device callback\n"; | |
323 | CHECK_HR(ERROR_TIMEOUT); | |
324 | ||
325 | hr = audioClient->Stop(); | |
326 | CHECK_HR(hr); | |
327 | hr = audioClient->Reset(); | |
328 | CHECK_HR(hr); | |
329 | ||
330 | while (active_ && !stream_->waitForChunk(std::chrono::milliseconds(100))) | |
331 | LOG(INFO, LOG_TAG) << "Waiting for chunk\n"; | |
332 | ||
333 | hr = audioClient->Start(); | |
334 | CHECK_HR(hr); | |
335 | bufferPosition = 0; | |
336 | break; | |
337 | } | |
338 | ||
339 | // Thread was sleeping above, double check that we are still running | |
340 | if (!active_) | |
341 | break; | |
342 | ||
343 | // update our volume from IAudioControl | |
344 | if (mode_ == ClientSettings::SharingMode::exclusive) | |
345 | { | |
346 | volCorrection_ = audioEventListener_->getVolume(); | |
347 | // muteOverride = audioEventListener_->getMuted(); // use this for also applying audio mixer mute state | |
348 | } | |
349 | ||
350 | // get audio device volume from IAudioEndpointVolume | |
351 | // float deviceVolume = audioEndpointVolumeCallback.getVolume(); // system volume (for this audio device) | |
352 | // bool deviceMuted = audioEndpointVolumeCallback.getMuted(); // system mute (for this audio device) | |
353 | ||
354 | clock->GetPosition(&position, NULL); | |
355 | ||
356 | UINT32 padding = 0; | |
357 | if (settings_.sharing_mode == ClientSettings::SharingMode::shared) | |
358 | { | |
359 | hr = audioClient->GetCurrentPadding(&padding); | |
360 | CHECK_HR(hr); | |
361 | } | |
362 | ||
363 | int available = bufferFrameCount - padding; | |
364 | ||
365 | if (stream_->getPlayerChunk(queueBuffer.get(), | |
366 | microseconds(((bufferPosition * 1000000) / waveformat->nSamplesPerSec) - ((position * 1000000) / frequency)), available)) | |
367 | { | |
368 | if (available > 0) | |
369 | { | |
370 | adjustVolume(queueBuffer.get(), available); | |
371 | hr = renderClient->GetBuffer(available, &buffer); | |
372 | CHECK_HR(hr); | |
373 | memcpy(buffer, queueBuffer.get(), bufferSize); | |
374 | hr = renderClient->ReleaseBuffer(available, 0); | |
375 | CHECK_HR(hr); | |
376 | ||
377 | bufferPosition += available; | |
378 | } | |
379 | } | |
380 | else | |
381 | { | |
382 | LOG(INFO, LOG_TAG) << "Failed to get chunk\n"; | |
383 | ||
384 | hr = audioClient->Stop(); | |
385 | CHECK_HR(hr); | |
386 | hr = audioClient->Reset(); | |
387 | CHECK_HR(hr); | |
388 | ||
389 | while (active_ && !stream_->waitForChunk(std::chrono::milliseconds(100))) | |
390 | LOG(INFO, LOG_TAG) << "Waiting for chunk\n"; | |
391 | ||
392 | hr = audioClient->Start(); | |
393 | CHECK_HR(hr); | |
394 | bufferPosition = 0; | |
395 | } | |
396 | } | |
397 | } | |
398 | ||
399 | HRESULT STDMETHODCALLTYPE AudioSessionEventListener::QueryInterface(REFIID riid, VOID** ppvInterface) | |
400 | { | |
401 | if (IID_IUnknown == riid) | |
402 | { | |
403 | AddRef(); | |
404 | *ppvInterface = (IUnknown*)this; | |
405 | } | |
406 | else if (__uuidof(IAudioSessionEvents) == riid) | |
407 | { | |
408 | AddRef(); | |
409 | *ppvInterface = (IAudioSessionEvents*)this; | |
410 | } | |
411 | else | |
412 | { | |
413 | *ppvInterface = NULL; | |
414 | return E_NOINTERFACE; | |
415 | } | |
416 | return S_OK; | |
417 | } | |
418 | ||
419 | HRESULT STDMETHODCALLTYPE AudioSessionEventListener::OnSimpleVolumeChanged(float NewVolume, BOOL NewMute, LPCGUID EventContext) | |
420 | { | |
421 | volume_ = NewVolume; | |
422 | muted_ = NewMute; | |
423 | ||
424 | if (NewMute) | |
425 | { | |
426 | LOG(DEBUG, LOG_TAG) << ("MUTE\n"); | |
427 | } | |
428 | else | |
429 | { | |
430 | LOG(DEBUG, LOG_TAG) << "Volume = " << (UINT32)(100 * NewVolume + 0.5) << " percent\n"; | |
431 | } | |
432 | ||
433 | return S_OK; | |
434 | } | |
435 | ||
436 | HRESULT STDMETHODCALLTYPE AudioSessionEventListener::OnStateChanged(AudioSessionState NewState) | |
437 | { | |
438 | char* pszState = "?????"; | |
439 | ||
440 | switch (NewState) | |
441 | { | |
442 | case AudioSessionStateActive: | |
443 | pszState = "active"; | |
444 | break; | |
445 | case AudioSessionStateInactive: | |
446 | pszState = "inactive"; | |
447 | break; | |
448 | } | |
449 | LOG(DEBUG, LOG_TAG) << "New session state = " << pszState << "\n"; | |
450 | ||
451 | return S_OK; | |
452 | } | |
453 | ||
454 | HRESULT STDMETHODCALLTYPE AudioSessionEventListener::OnSessionDisconnected(AudioSessionDisconnectReason DisconnectReason) | |
455 | { | |
456 | char* pszReason = "?????"; | |
457 | ||
458 | switch (DisconnectReason) | |
459 | { | |
460 | case DisconnectReasonDeviceRemoval: | |
461 | pszReason = "device removed"; | |
462 | break; | |
463 | case DisconnectReasonServerShutdown: | |
464 | pszReason = "server shut down"; | |
465 | break; | |
466 | case DisconnectReasonFormatChanged: | |
467 | pszReason = "format changed"; | |
468 | break; | |
469 | case DisconnectReasonSessionLogoff: | |
470 | pszReason = "user logged off"; | |
471 | break; | |
472 | case DisconnectReasonSessionDisconnected: | |
473 | pszReason = "session disconnected"; | |
474 | break; | |
475 | case DisconnectReasonExclusiveModeOverride: | |
476 | pszReason = "exclusive-mode override"; | |
477 | break; | |
478 | } | |
479 | LOG(INFO, LOG_TAG) << "Audio session disconnected (reason: " << pszReason << ")"; | |
480 | ||
481 | return S_OK; | |
482 | } | |
483 | ||
484 | ||
485 | ||
486 | HRESULT STDMETHODCALLTYPE AudioEndpointVolumeCallback::OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA pNotify) | |
487 | { | |
488 | if (pNotify == NULL) | |
489 | { | |
490 | return E_INVALIDARG; | |
491 | } | |
492 | ||
493 | if (pNotify->bMuted) | |
494 | { | |
495 | LOG(DEBUG, LOG_TAG) << ("MASTER MUTE\n"); | |
496 | } | |
497 | ||
498 | LOG(DEBUG, LOG_TAG) << "Volume = " << (UINT32)(100 * pNotify->fMasterVolume + 0.5) << " percent\n"; | |
499 | ||
500 | volume_ = pNotify->fMasterVolume; | |
501 | muted_ = pNotify->bMuted; | |
502 | ||
503 | return S_OK; | |
504 | }⏎ |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2016 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef WASAPI_PLAYER_HPP | |
19 | #define WASAPI_PLAYER_HPP | |
20 | ||
21 | #include "player.hpp" | |
22 | #include <audiopolicy.h> | |
23 | #include <endpointvolume.h> | |
24 | ||
25 | class AudioSessionEventListener : public IAudioSessionEvents | |
26 | { | |
27 | LONG _cRef; | |
28 | ||
29 | float volume_ = 1.f; | |
30 | bool muted_ = false; | |
31 | ||
32 | public: | |
33 | AudioSessionEventListener() : _cRef(1) | |
34 | { | |
35 | } | |
36 | ||
37 | float getVolume() | |
38 | { | |
39 | return volume_; | |
40 | } | |
41 | ||
42 | bool getMuted() | |
43 | { | |
44 | return muted_; | |
45 | } | |
46 | ||
47 | ~AudioSessionEventListener() | |
48 | { | |
49 | } | |
50 | ||
51 | // IUnknown methods -- AddRef, Release, and QueryInterface | |
52 | ||
53 | ULONG STDMETHODCALLTYPE AddRef() | |
54 | { | |
55 | return InterlockedIncrement(&_cRef); | |
56 | } | |
57 | ||
58 | ULONG STDMETHODCALLTYPE Release() | |
59 | { | |
60 | ULONG ulRef = InterlockedDecrement(&_cRef); | |
61 | if (0 == ulRef) | |
62 | { | |
63 | delete this; | |
64 | } | |
65 | return ulRef; | |
66 | } | |
67 | ||
68 | HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID** ppvInterface); | |
69 | ||
70 | // Notification methods for audio session events | |
71 | ||
72 | HRESULT STDMETHODCALLTYPE OnDisplayNameChanged(LPCWSTR NewDisplayName, LPCGUID EventContext) | |
73 | { | |
74 | return S_OK; | |
75 | } | |
76 | ||
77 | HRESULT STDMETHODCALLTYPE OnIconPathChanged(LPCWSTR NewIconPath, LPCGUID EventContext) | |
78 | { | |
79 | return S_OK; | |
80 | } | |
81 | ||
82 | HRESULT STDMETHODCALLTYPE OnSimpleVolumeChanged(float NewVolume, BOOL NewMute, LPCGUID EventContext); | |
83 | ||
84 | HRESULT STDMETHODCALLTYPE OnChannelVolumeChanged(DWORD ChannelCount, float NewChannelVolumeArray[], DWORD ChangedChannel, LPCGUID EventContext) | |
85 | { | |
86 | return S_OK; | |
87 | } | |
88 | ||
89 | HRESULT STDMETHODCALLTYPE OnGroupingParamChanged(LPCGUID NewGroupingParam, LPCGUID EventContext) | |
90 | { | |
91 | return S_OK; | |
92 | } | |
93 | ||
94 | HRESULT STDMETHODCALLTYPE OnStateChanged(AudioSessionState NewState); | |
95 | ||
96 | HRESULT STDMETHODCALLTYPE OnSessionDisconnected(AudioSessionDisconnectReason DisconnectReason); | |
97 | }; | |
98 | ||
99 | ||
100 | class AudioEndpointVolumeCallback : public IAudioEndpointVolumeCallback | |
101 | { | |
102 | LONG _cRef; | |
103 | float volume_ = 1.f; | |
104 | bool muted_ = false; | |
105 | ||
106 | public: | |
107 | AudioEndpointVolumeCallback() : _cRef(1) | |
108 | { | |
109 | } | |
110 | ||
111 | ~AudioEndpointVolumeCallback() | |
112 | { | |
113 | } | |
114 | ||
115 | float getVolume() | |
116 | { | |
117 | return volume_; | |
118 | } | |
119 | ||
120 | bool getMuted() | |
121 | { | |
122 | return muted_; | |
123 | } | |
124 | ||
125 | // IUnknown methods -- AddRef, Release, and QueryInterface | |
126 | ||
127 | ULONG STDMETHODCALLTYPE AddRef() | |
128 | { | |
129 | return InterlockedIncrement(&_cRef); | |
130 | } | |
131 | ||
132 | ULONG STDMETHODCALLTYPE Release() | |
133 | { | |
134 | ULONG ulRef = InterlockedDecrement(&_cRef); | |
135 | if (0 == ulRef) | |
136 | { | |
137 | delete this; | |
138 | } | |
139 | return ulRef; | |
140 | } | |
141 | ||
142 | HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID** ppvInterface) | |
143 | { | |
144 | if (IID_IUnknown == riid) | |
145 | { | |
146 | AddRef(); | |
147 | *ppvInterface = (IUnknown*)this; | |
148 | } | |
149 | else if (__uuidof(IAudioEndpointVolumeCallback) == riid) | |
150 | { | |
151 | AddRef(); | |
152 | *ppvInterface = (IAudioEndpointVolumeCallback*)this; | |
153 | } | |
154 | else | |
155 | { | |
156 | *ppvInterface = NULL; | |
157 | return E_NOINTERFACE; | |
158 | } | |
159 | ||
160 | return S_OK; | |
161 | } | |
162 | ||
163 | // Callback method for endpoint-volume-change notifications. | |
164 | ||
165 | HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA pNotify); | |
166 | }; | |
167 | ||
168 | class WASAPIPlayer : public Player | |
169 | { | |
170 | public: | |
171 | WASAPIPlayer(boost::asio::io_context& io_context, const ClientSettings::Player& settings, std::shared_ptr<Stream> stream); | |
172 | virtual ~WASAPIPlayer(); | |
173 | ||
174 | static std::vector<PcmDevice> pcm_list(void); | |
175 | ||
176 | protected: | |
177 | virtual void worker(); | |
178 | virtual bool needsThread() const override | |
179 | { | |
180 | return true; | |
181 | } | |
182 | ||
183 | private: | |
184 | AudioSessionEventListener* audioEventListener_; | |
185 | IAudioEndpointVolume* audioEndpointListener_; | |
186 | ClientSettings::SharingMode mode_; | |
187 | }; | |
188 | ||
189 | #endif |
0 | 0 | .\"groff -Tascii -man snapclient.1 |
1 | .TH SNAPCLIENT 1 "January 2020" | |
1 | .TH SNAPCLIENT 1 "June 2020" | |
2 | 2 | .SH NAME |
3 | 3 | snapclient - Snapcast client |
4 | 4 | .SH SYNOPSIS |
13 | 13 | into this file will be send to the connected clients. One of the most generic |
14 | 14 | ways to use Snapcast is in conjunction with the music player daemon or Mopidy, |
15 | 15 | which can be configured to use a named pipe as audio output. |
16 | .SS Options | |
16 | .SS Allowed options: | |
17 | 17 | .TP |
18 | 18 | \fB--help\fR |
19 | 19 | produce help message |
21 | 21 | \fB-v, --version\fR |
22 | 22 | show version number |
23 | 23 | .TP |
24 | \fB-l, --list\fR | |
25 | list pcm devices | |
26 | .TP | |
27 | \fB-s, --soundcard arg (=default)\fR | |
28 | index or name of the soundcard | |
29 | .TP | |
30 | \fB-e, --mstderr\fR | |
31 | send metadata to stderr | |
32 | .TP | |
33 | 24 | \fB-h, --host arg\fR |
34 | 25 | server hostname or ip address |
35 | 26 | .TP |
36 | 27 | \fB-p, --port arg (=1704)\fR |
37 | 28 | server port |
29 | .TP | |
30 | \fB-i, --instance arg (=1)\fR | |
31 | instance id when running multiple instances on the same host | |
32 | .TP | |
33 | \fB--hostID arg\fR | |
34 | unique host id, default is MAC address | |
35 | .TP | |
36 | \fB-l, --list\fR | |
37 | list PCM devices | |
38 | .TP | |
39 | \fB-s, --soundcard arg (=default)\fR | |
40 | index or name of the pcm device | |
41 | .TP | |
42 | \fB--latency arg (=0)\fR | |
43 | latency of the PCM device | |
44 | .TP | |
45 | \fB--sampleformat arg\fR | |
46 | resample audio stream to <rate>:<bits>:<channels> | |
47 | .TP | |
48 | \fB--player arg (=alsa)\fR | |
49 | alsa|file[:<options>|?] | |
50 | .TP | |
51 | \fB--mixer arg (=software)\fR | |
52 | software|hardware|script|none|?[:<options>] | |
53 | .TP | |
54 | \fB-e, --mstderr\fR | |
55 | send metadata to stderr | |
38 | 56 | .TP |
39 | 57 | \fB-d, --daemon [=arg(=-3)]\fR |
40 | 58 | daemonize, optional process priority [-20..19] |
42 | 60 | \fB--user arg\fR |
43 | 61 | the user[:group] to run snapclient as when daemonized |
44 | 62 | .TP |
45 | \fB--latency arg (=0)\fR | |
46 | latency of the soundcard | |
63 | \fB--logsink arg\fR | |
64 | log sink [null,system,stdout,stderr,file:<filename>] | |
47 | 65 | .TP |
48 | \fB-i, --instance arg (=1)\fR | |
49 | instance id | |
50 | .TP | |
51 | \fB--hostID arg\fR | |
52 | unique host id | |
66 | \fB--logfilter arg (=*:info)\fR | |
67 | log filter <tag>:<level>[,<tag>:<level>]* with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal] | |
53 | 68 | .SH FILES |
54 | 69 | .TP |
55 | 70 | \fI/etc/default/snapclient\fR |
17 | 17 | |
18 | 18 | #include <chrono> |
19 | 19 | #include <iostream> |
20 | #ifndef WINDOWS | |
21 | #include <signal.h> | |
20 | 22 | #include <sys/resource.h> |
21 | ||
22 | #include "browseZeroConf/browse_mdns.hpp" | |
23 | #endif | |
24 | ||
23 | 25 | #include "common/popl.hpp" |
24 | 26 | #include "controller.hpp" |
25 | 27 | |
26 | 28 | #ifdef HAS_ALSA |
27 | 29 | #include "player/alsa_player.hpp" |
28 | 30 | #endif |
31 | #ifdef HAS_WASAPI | |
32 | #include "player/wasapi_player.hpp" | |
33 | #endif | |
29 | 34 | #ifdef HAS_DAEMON |
30 | 35 | #include "common/daemon.hpp" |
31 | 36 | #endif |
32 | 37 | #include "client_settings.hpp" |
33 | 38 | #include "common/aixlog.hpp" |
34 | #include "common/signal_handler.hpp" | |
35 | 39 | #include "common/snap_exception.hpp" |
36 | 40 | #include "common/str_compat.hpp" |
37 | 41 | #include "common/utils.hpp" |
43 | 47 | |
44 | 48 | using namespace std::chrono_literals; |
45 | 49 | |
50 | static constexpr auto LOG_TAG = "Snapclient"; | |
51 | ||
46 | 52 | PcmDevice getPcmDevice(const std::string& soundcard) |
47 | 53 | { |
54 | #if defined(HAS_ALSA) || defined(HAS_WASAPI) | |
55 | vector<PcmDevice> pcmDevices = | |
48 | 56 | #ifdef HAS_ALSA |
49 | vector<PcmDevice> pcmDevices = AlsaPlayer::pcm_list(); | |
57 | AlsaPlayer::pcm_list(); | |
58 | #else | |
59 | WASAPIPlayer::pcm_list(); | |
60 | #endif | |
50 | 61 | |
51 | 62 | try |
52 | 63 | { |
62 | 73 | for (auto dev : pcmDevices) |
63 | 74 | if (dev.name.find(soundcard) != string::npos) |
64 | 75 | return dev; |
65 | #else | |
66 | 76 | std::ignore = soundcard; |
67 | 77 | #endif |
68 | 78 | |
71 | 81 | return pcmDevice; |
72 | 82 | } |
73 | 83 | |
84 | #ifdef WINDOWS | |
85 | // hack to avoid case destinction in the signal handler | |
86 | #define SIGHUP SIGINT | |
87 | const char* strsignal(int sig) | |
88 | { | |
89 | switch (sig) | |
90 | { | |
91 | case SIGTERM: | |
92 | return "SIGTERM"; | |
93 | case SIGINT: | |
94 | return "SIGINT"; | |
95 | case SIGBREAK: | |
96 | return "SIGBREAK"; | |
97 | case SIGABRT: | |
98 | return "SIGABRT"; | |
99 | default: | |
100 | return "Unhandled"; | |
101 | } | |
102 | } | |
103 | #endif | |
74 | 104 | |
75 | 105 | int main(int argc, char** argv) |
76 | 106 | { |
87 | 117 | OptionParser op("Allowed options"); |
88 | 118 | auto helpSwitch = op.add<Switch>("", "help", "produce help message"); |
89 | 119 | auto groffSwitch = op.add<Switch, Attribute::hidden>("", "groff", "produce groff message"); |
90 | auto debugOption = op.add<Implicit<string>, Attribute::hidden>("", "debug", "enable debug logging", ""); // TODO: &settings.logging.debug); | |
91 | 120 | auto versionSwitch = op.add<Switch>("v", "version", "show version number"); |
92 | #if defined(HAS_ALSA) | |
121 | op.add<Value<string>>("h", "host", "server hostname or ip address", "", &settings.server.host); | |
122 | op.add<Value<size_t>>("p", "port", "server port", 1704, &settings.server.port); | |
123 | op.add<Value<size_t>>("i", "instance", "instance id when running multiple instances on the same host", 1, &settings.instance); | |
124 | op.add<Value<string>>("", "hostID", "unique host id, default is MAC address", "", &settings.host_id); | |
125 | ||
126 | // PCM device specific | |
127 | #if defined(HAS_ALSA) || defined(HAS_WASAPI) | |
93 | 128 | auto listSwitch = op.add<Switch>("l", "list", "list PCM devices"); |
94 | 129 | /*auto soundcardValue =*/op.add<Value<string>>("s", "soundcard", "index or name of the pcm device", "default", &pcm_device); |
95 | 130 | #endif |
131 | /*auto latencyValue =*/op.add<Value<int>>("", "latency", "latency of the PCM device", 0, &settings.player.latency); | |
132 | #ifdef HAS_SOXR | |
133 | auto sample_format = op.add<Value<string>>("", "sampleformat", "resample audio stream to <rate>:<bits>:<channels>", ""); | |
134 | #endif | |
135 | ||
136 | auto supported_players = Controller::getSupportedPlayerNames(); | |
137 | string supported_players_str; | |
138 | for (const auto& supported_player : supported_players) | |
139 | supported_players_str += (!supported_players_str.empty() ? "|" : "") + supported_player; | |
140 | op.add<Value<string>>("", "player", supported_players_str + "[:<options>|?]", supported_players.front(), &settings.player.player_name); | |
141 | ||
142 | // sharing mode | |
143 | #if defined(HAS_OBOE) || defined(HAS_WASAPI) | |
144 | auto sharing_mode = op.add<Value<string>>("", "sharingmode", "audio mode to use [shared|exclusive]", "shared"); | |
145 | #endif | |
146 | ||
147 | // mixer | |
148 | bool hw_mixer_supported = false; | |
149 | #if defined(HAS_ALSA) | |
150 | hw_mixer_supported = true; | |
151 | #endif | |
152 | std::shared_ptr<popl::Value<std::string>> mixer_mode; | |
153 | if (hw_mixer_supported) | |
154 | mixer_mode = op.add<Value<string>>("", "mixer", "software|hardware|script|none|?[:<options>]", "software"); | |
155 | else | |
156 | mixer_mode = op.add<Value<string>>("", "mixer", "software|script|none|?[:<options>]", "software"); | |
157 | ||
96 | 158 | auto metaStderr = op.add<Switch>("e", "mstderr", "send metadata to stderr"); |
97 | /*auto hostValue =*/op.add<Value<string>>("h", "host", "server hostname or ip address", "", &settings.server.host); | |
98 | /*auto portValue =*/op.add<Value<size_t>>("p", "port", "server port", 1704, &settings.server.port); | |
159 | ||
160 | // daemon settings | |
99 | 161 | #ifdef HAS_DAEMON |
100 | 162 | int processPriority(-3); |
101 | 163 | auto daemonOption = op.add<Implicit<int>>("d", "daemon", "daemonize, optional process priority [-20..19]", processPriority, &processPriority); |
102 | 164 | auto userValue = op.add<Value<string>>("", "user", "the user[:group] to run snapclient as when daemonized"); |
103 | 165 | #endif |
104 | /*auto latencyValue =*/op.add<Value<int>>("", "latency", "latency of the PCM device", 0, &settings.player.latency); | |
105 | /*auto instanceValue =*/op.add<Value<size_t>>("i", "instance", "instance id", 1, &settings.instance); | |
106 | /*auto hostIdValue =*/op.add<Value<string>>("", "hostID", "unique host id", "", &settings.host_id); | |
107 | op.add<Value<string>>("", "player", "audio backend", "", &settings.player.player_name); | |
108 | #ifdef HAS_SOXR | |
109 | auto sample_format = op.add<Value<string>>("", "sampleformat", "resample audio stream to <rate>:<bits>:<channels>", ""); | |
110 | #endif | |
166 | ||
167 | // logging | |
168 | op.add<Value<string>>("", "logsink", "log sink [null,system,stdout,stderr,file:<filename>]", settings.logging.sink, &settings.logging.sink); | |
169 | auto logfilterOption = op.add<Value<string>>( | |
170 | "", "logfilter", "log filter <tag>:<level>[,<tag>:<level>]* with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]", | |
171 | settings.logging.filter); | |
111 | 172 | |
112 | 173 | try |
113 | 174 | { |
131 | 192 | exit(EXIT_SUCCESS); |
132 | 193 | } |
133 | 194 | |
195 | #if defined(HAS_ALSA) || defined(HAS_WASAPI) | |
196 | if (listSwitch->is_set()) | |
197 | { | |
198 | vector<PcmDevice> pcmDevices = | |
134 | 199 | #ifdef HAS_ALSA |
135 | if (listSwitch->is_set()) | |
136 | { | |
137 | vector<PcmDevice> pcmDevices = AlsaPlayer::pcm_list(); | |
200 | AlsaPlayer::pcm_list(); | |
201 | #else | |
202 | WASAPIPlayer::pcm_list(); | |
203 | #endif | |
138 | 204 | for (auto dev : pcmDevices) |
139 | 205 | { |
140 | 206 | cout << dev.idx << ": " << dev.name << "\n" << dev.description << "\n\n"; |
158 | 224 | |
159 | 225 | // XXX: Only one metadata option must be set |
160 | 226 | |
161 | AixLog::Log::init<AixLog::SinkNative>("snapclient", AixLog::Severity::trace, AixLog::Type::special); | |
162 | if (debugOption->is_set()) | |
163 | { | |
164 | AixLog::Log::instance().add_logsink<AixLog::SinkCout>(AixLog::Severity::trace, AixLog::Type::all, "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"); | |
165 | if (!debugOption->value().empty()) | |
166 | AixLog::Log::instance().add_logsink<AixLog::SinkFile>(AixLog::Severity::trace, AixLog::Type::all, debugOption->value(), | |
167 | "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"); | |
168 | } | |
227 | settings.logging.filter = logfilterOption->value(); | |
228 | if (logfilterOption->is_set()) | |
229 | { | |
230 | for (size_t n = 1; n < logfilterOption->count(); ++n) | |
231 | settings.logging.filter += "," + logfilterOption->value(n); | |
232 | } | |
233 | ||
234 | if (settings.logging.sink.empty()) | |
235 | { | |
236 | settings.logging.sink = "stdout"; | |
237 | #ifdef HAS_DAEMON | |
238 | if (daemonOption->is_set()) | |
239 | settings.logging.sink = "system"; | |
240 | #endif | |
241 | } | |
242 | AixLog::Filter logfilter; | |
243 | auto filters = utils::string::split(settings.logging.filter, ','); | |
244 | for (const auto& filter : filters) | |
245 | logfilter.add_filter(filter); | |
246 | ||
247 | string logformat = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"; | |
248 | if (settings.logging.sink.find("file:") != string::npos) | |
249 | { | |
250 | string logfile = settings.logging.sink.substr(settings.logging.sink.find(":") + 1); | |
251 | AixLog::Log::init<AixLog::SinkFile>(logfilter, logfile, logformat); | |
252 | } | |
253 | else if (settings.logging.sink == "stdout") | |
254 | AixLog::Log::init<AixLog::SinkCout>(logfilter, logformat); | |
255 | else if (settings.logging.sink == "stderr") | |
256 | AixLog::Log::init<AixLog::SinkCerr>(logfilter, logformat); | |
257 | else if (settings.logging.sink == "system") | |
258 | AixLog::Log::init<AixLog::SinkNative>("snapclient", logfilter); | |
259 | else if (settings.logging.sink == "null") | |
260 | AixLog::Log::init<AixLog::SinkNull>(); | |
169 | 261 | else |
170 | { | |
171 | AixLog::Log::instance().add_logsink<AixLog::SinkCout>(AixLog::Severity::info, AixLog::Type::all, "%Y-%m-%d %H-%M-%S [#severity] (#tag_func)"); | |
172 | } | |
262 | throw SnapException("Invalid log sink: " + settings.logging.sink); | |
173 | 263 | |
174 | 264 | #ifdef HAS_DAEMON |
175 | 265 | std::unique_ptr<Daemon> daemon; |
192 | 282 | group = user_group[1]; |
193 | 283 | } |
194 | 284 | daemon = std::make_unique<Daemon>(user, group, pidFile); |
195 | SLOG(NOTICE) << "daemonizing" << std::endl; | |
196 | daemon->daemonize(); | |
197 | if (processPriority < -20) | |
198 | processPriority = -20; | |
199 | else if (processPriority > 19) | |
200 | processPriority = 19; | |
285 | processPriority = std::min(std::max(-20, processPriority), 19); | |
201 | 286 | if (processPriority != 0) |
202 | 287 | setpriority(PRIO_PROCESS, 0, processPriority); |
203 | SLOG(NOTICE) << "daemon started" << std::endl; | |
288 | LOG(NOTICE, LOG_TAG) << "daemonizing" << std::endl; | |
289 | daemon->daemonize(); | |
290 | LOG(NOTICE, LOG_TAG) << "daemon started" << std::endl; | |
204 | 291 | } |
205 | 292 | #endif |
206 | 293 | |
225 | 312 | } |
226 | 313 | #endif |
227 | 314 | |
228 | bool active = true; | |
229 | std::shared_ptr<Controller> controller; | |
230 | auto signal_handler = install_signal_handler({SIGHUP, SIGTERM, SIGINT}, | |
231 | [&active, &controller](int signal, const std::string& strsignal) { | |
232 | SLOG(INFO) << "Received signal " << signal << ": " << strsignal << "\n"; | |
233 | active = false; | |
234 | if (controller) | |
235 | { | |
236 | LOG(INFO) << "Stopping controller\n"; | |
237 | controller->stop(); | |
238 | } | |
239 | }); | |
240 | if (settings.server.host.empty()) | |
241 | { | |
242 | #if defined(HAS_AVAHI) || defined(HAS_BONJOUR) | |
243 | BrowseZeroConf browser; | |
244 | mDNSResult avahiResult; | |
245 | while (active) | |
315 | #if defined(HAS_OBOE) || defined(HAS_WASAPI) | |
316 | settings.player.sharing_mode = (sharing_mode->value() == "exclusive") ? ClientSettings::SharingMode::exclusive : ClientSettings::SharingMode::shared; | |
317 | #endif | |
318 | ||
319 | settings.player.player_name = utils::string::split_left(settings.player.player_name, ':', settings.player.parameter); | |
320 | if (settings.player.parameter == "?") | |
321 | { | |
322 | if (settings.player.player_name == "file") | |
246 | 323 | { |
247 | signal_handler.wait_for(500ms); | |
248 | if (!active) | |
249 | break; | |
250 | try | |
251 | { | |
252 | if (browser.browse("_snapcast._tcp", avahiResult, 5000)) | |
253 | { | |
254 | settings.server.host = avahiResult.ip; | |
255 | settings.server.port = avahiResult.port; | |
256 | if (avahiResult.ip_version == IPVersion::IPv6) | |
257 | settings.server.host += "%" + cpt::to_string(avahiResult.iface_idx); | |
258 | LOG(INFO) << "Found server " << settings.server.host << ":" << settings.server.port << "\n"; | |
259 | break; | |
260 | } | |
261 | } | |
262 | catch (const std::exception& e) | |
263 | { | |
264 | SLOG(ERROR) << "Exception: " << e.what() << std::endl; | |
265 | } | |
324 | cout << "Options are a comma separated list of:\n" | |
325 | << " \"filename:<filename>\" - with <filename> = \"stdout\", \"stderr\" or a filename\n" | |
326 | << " \"mode:[w|a]\" - w: write (discarding the content), a: append (keeping the content)\n"; | |
266 | 327 | } |
267 | #endif | |
268 | } | |
269 | ||
270 | if (active) | |
271 | { | |
272 | // Setup metadata handling | |
273 | auto meta(metaStderr ? std::make_unique<MetaStderrAdapter>() : std::make_unique<MetadataAdapter>()); | |
274 | ||
275 | controller = make_shared<Controller>(settings, std::move(meta)); | |
276 | LOG(INFO) << "Latency: " << settings.player.latency << "\n"; | |
277 | controller->run(); | |
278 | // signal_handler.wait(); | |
279 | // controller->stop(); | |
280 | } | |
328 | else | |
329 | { | |
330 | cout << "No options available for \"" << settings.player.player_name << "\n"; | |
331 | } | |
332 | exit(EXIT_SUCCESS); | |
333 | } | |
334 | ||
335 | string mode = utils::string::split_left(mixer_mode->value(), ':', settings.player.mixer.parameter); | |
336 | if (mode == "software") | |
337 | settings.player.mixer.mode = ClientSettings::Mixer::Mode::software; | |
338 | else if ((mode == "hardware") && hw_mixer_supported) | |
339 | settings.player.mixer.mode = ClientSettings::Mixer::Mode::hardware; | |
340 | else if (mode == "script") | |
341 | settings.player.mixer.mode = ClientSettings::Mixer::Mode::script; | |
342 | else if (mode == "none") | |
343 | settings.player.mixer.mode = ClientSettings::Mixer::Mode::none; | |
344 | else if ((mode == "?") || (mode == "help")) | |
345 | { | |
346 | cout << "mixer can be one of 'software', " << (hw_mixer_supported ? "'hardware', " : "") << "'script', 'none'\n" | |
347 | << "followed by optional parameters:\n" | |
348 | << " * software[:poly[:<exponent>]|exp[:<base>]]\n" | |
349 | << (hw_mixer_supported ? " * hardware[:<mixer name>]\n" : "") << " * script[:<script filename>]\n"; | |
350 | exit(EXIT_SUCCESS); | |
351 | } | |
352 | else | |
353 | throw SnapException("Mixer mode not supported: " + mode); | |
354 | ||
355 | boost::asio::io_context io_context; | |
356 | // Construct a signal set registered for process termination. | |
357 | boost::asio::signal_set signals(io_context, SIGHUP, SIGINT, SIGTERM); | |
358 | signals.async_wait([&](const boost::system::error_code& ec, int signal) { | |
359 | if (!ec) | |
360 | LOG(INFO, LOG_TAG) << "Received signal " << signal << ": " << strsignal(signal) << "\n"; | |
361 | else | |
362 | LOG(INFO, LOG_TAG) << "Failed to wait for signal, error: " << ec.message() << "\n"; | |
363 | io_context.stop(); | |
364 | }); | |
365 | ||
366 | // Setup metadata handling | |
367 | auto meta(metaStderr ? std::make_unique<MetaStderrAdapter>() : std::make_unique<MetadataAdapter>()); | |
368 | auto controller = make_shared<Controller>(io_context, settings, std::move(meta)); | |
369 | controller->start(); | |
370 | ||
371 | int num_threads = 0; | |
372 | std::vector<std::thread> threads; | |
373 | for (int n = 0; n < num_threads; ++n) | |
374 | threads.emplace_back([&] { io_context.run(); }); | |
375 | io_context.run(); | |
376 | for (auto& t : threads) | |
377 | t.join(); | |
281 | 378 | } |
282 | 379 | catch (const std::exception& e) |
283 | 380 | { |
284 | SLOG(ERROR) << "Exception: " << e.what() << std::endl; | |
381 | LOG(FATAL, LOG_TAG) << "Exception: " << e.what() << std::endl; | |
285 | 382 | exitcode = EXIT_FAILURE; |
286 | 383 | } |
287 | 384 | |
288 | SLOG(NOTICE) << "daemon terminated." << endl; | |
385 | LOG(NOTICE, LOG_TAG) << "daemon terminated." << endl; | |
289 | 386 | exit(exitcode); |
290 | 387 | } |
14 | 14 | You should have received a copy of the GNU General Public License |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | ||
18 | #ifndef NOMINMAX | |
19 | #define NOMINMAX | |
20 | #endif // NOMINMAX | |
17 | 21 | |
18 | 22 | #include "stream.hpp" |
19 | 23 | #include "common/aixlog.hpp" |
22 | 26 | #include <iostream> |
23 | 27 | #include <string.h> |
24 | 28 | |
29 | ||
25 | 30 | using namespace std; |
26 | 31 | namespace cs = chronos; |
27 | 32 | |
28 | 33 | static constexpr auto LOG_TAG = "Stream"; |
29 | 34 | static constexpr auto kCorrectionBegin = 100us; |
30 | 35 | |
36 | // #define LOG_LATENCIES | |
31 | 37 | |
32 | 38 | Stream::Stream(const SampleFormat& in_format, const SampleFormat& out_format) |
33 | 39 | : in_format_(in_format), median_(0), shortMedian_(0), lastUpdate_(0), playedFrames_(0), correctAfterXFrames_(0), bufferMs_(cs::msec(500)), frame_delta_(0), |
36 | 42 | buffer_.setSize(500); |
37 | 43 | shortBuffer_.setSize(100); |
38 | 44 | miniBuffer_.setSize(20); |
45 | latencies_.setSize(100); | |
39 | 46 | |
40 | 47 | format_ = in_format_; |
41 | 48 | if (out_format.isInitialized()) |
52 | 59 | x = 1,000016667 / (1,000016667 - 1) |
53 | 60 | */ |
54 | 61 | // setRealSampleRate(format_.rate()); |
55 | #ifdef HAS_SOXR | |
56 | soxr_ = nullptr; | |
57 | if ((format_.rate() != in_format_.rate()) || (format_.bits() != in_format_.bits())) | |
58 | { | |
59 | LOG(INFO, LOG_TAG) << "Resampling from " << in_format_.getFormat() << " to " << format_.getFormat() << "\n"; | |
60 | soxr_error_t error; | |
61 | ||
62 | soxr_datatype_t in_type = SOXR_INT16_I; | |
63 | soxr_datatype_t out_type = SOXR_INT16_I; | |
64 | if (in_format_.sampleSize() > 2) | |
65 | in_type = SOXR_INT32_I; | |
66 | if (format_.sampleSize() > 2) | |
67 | out_type = SOXR_INT32_I; | |
68 | soxr_io_spec_t iospec = soxr_io_spec(in_type, out_type); | |
69 | // HQ should be fine: http://sox.sourceforge.net/Docs/FAQ | |
70 | soxr_quality_spec_t q_spec = soxr_quality_spec(SOXR_HQ, 0); | |
71 | soxr_ = soxr_create(static_cast<double>(in_format_.rate()), static_cast<double>(format_.rate()), format_.channels(), &error, &iospec, &q_spec, NULL); | |
72 | if (error) | |
73 | { | |
74 | LOG(ERROR, LOG_TAG) << "Error soxr_create: " << error << "\n"; | |
75 | soxr_ = nullptr; | |
76 | } | |
77 | // initialize the buffer with 20ms (~latency of the reampler) | |
78 | resample_buffer_.resize(format_.frameSize() * ceil(format_.msRate()) * 20); | |
79 | } | |
80 | #endif | |
62 | resampler_ = std::make_unique<Resampler>(in_format_, format_); | |
81 | 63 | } |
82 | 64 | |
83 | 65 | |
84 | 66 | Stream::~Stream() |
85 | 67 | { |
86 | #ifdef HAS_SOXR | |
87 | if (soxr_) | |
88 | soxr_delete(soxr_); | |
89 | #endif | |
90 | 68 | } |
91 | 69 | |
92 | 70 | |
98 | 76 | } |
99 | 77 | else |
100 | 78 | { |
101 | correctAfterXFrames_ = round((format_.rate() / sampleRate) / (format_.rate() / sampleRate - 1.)); | |
79 | correctAfterXFrames_ = static_cast<int32_t>(round((format_.rate() / sampleRate) / (format_.rate() / sampleRate - 1.))); | |
102 | 80 | // LOG(TRACE, LOG_TAG) << "Correct after X: " << correctAfterXFrames_ << " (Real rate: " << sampleRate << ", rate: " << format_.rate() << ")\n"; |
103 | 81 | } |
104 | 82 | } |
112 | 90 | |
113 | 91 | void Stream::clearChunks() |
114 | 92 | { |
93 | std::lock_guard<std::mutex> lock(mutex_); | |
115 | 94 | while (chunks_.size() > 0) |
116 | 95 | chunks_.pop(); |
117 | 96 | resetBuffers(); |
121 | 100 | void Stream::addChunk(unique_ptr<msg::PcmChunk> chunk) |
122 | 101 | { |
123 | 102 | // drop chunk if it's too old. Just in case, this shouldn't happen. |
124 | cs::usec age = std::chrono::duration_cast<cs::usec>(TimeProvider::serverNow() - chunk->start()); | |
103 | auto age = std::chrono::duration_cast<cs::msec>(TimeProvider::serverNow() - chunk->start()); | |
125 | 104 | if (age > 5s + bufferMs_) |
126 | 105 | return; |
127 | 106 | |
128 | // LOG(DEBUG, LOG_TAG) << "new chunk: " << chunk->durationMs() << " ms, Chunks: " << chunks_.size() << "\n"; | |
129 | ||
130 | #ifndef HAS_SOXR | |
131 | chunks_.push(move(chunk)); | |
132 | #else | |
133 | if (soxr_ == nullptr) | |
134 | { | |
135 | chunks_.push(move(chunk)); | |
136 | } | |
137 | else | |
138 | { | |
139 | if (in_format_.bits() == 24) | |
140 | { | |
141 | // sox expects 32 bit input, shift 8 bits left | |
142 | int32_t* frames = (int32_t*)chunk->payload; | |
143 | for (size_t n = 0; n < chunk->getSampleCount(); ++n) | |
144 | frames[n] = frames[n] << 8; | |
145 | } | |
146 | ||
147 | size_t idone; | |
148 | size_t odone; | |
149 | auto resample_buffer_framesize = resample_buffer_.size() / format_.frameSize(); | |
150 | auto error = soxr_process(soxr_, chunk->payload, chunk->getFrameCount(), &idone, resample_buffer_.data(), resample_buffer_framesize, &odone); | |
151 | if (error) | |
152 | { | |
153 | LOG(ERROR, LOG_TAG) << "Error soxr_process: " << error << "\n"; | |
154 | } | |
155 | else | |
156 | { | |
157 | LOG(TRACE, LOG_TAG) << "Resample idone: " << idone << "/" << chunk->getFrameCount() << ", odone: " << odone << "/" | |
158 | << resample_buffer_.size() / format_.frameSize() << ", delay: " << soxr_delay(soxr_) << "\n"; | |
159 | ||
160 | // some data has been resampled (odone frames) and some is still in the pipe (soxr_delay frames) | |
161 | if (odone > 0) | |
162 | { | |
163 | // get the resamples ts from the input ts | |
164 | auto input_end_ts = chunk->start() + chunk->duration<std::chrono::microseconds>(); | |
165 | double resampled_ms = (odone + soxr_delay(soxr_)) / format_.msRate(); | |
166 | auto resampled_start = input_end_ts - std::chrono::microseconds(static_cast<int>(resampled_ms * 1000.)); | |
167 | ||
168 | auto resampled_chunk = new msg::PcmChunk(format_, 0); | |
169 | auto us = chrono::duration_cast<chrono::microseconds>(resampled_start.time_since_epoch()).count(); | |
170 | resampled_chunk->timestamp.sec = us / 1000000; | |
171 | resampled_chunk->timestamp.usec = us % 1000000; | |
172 | ||
173 | // copy from the resample_buffer to the resampled chunk | |
174 | resampled_chunk->payloadSize = odone * format_.frameSize(); | |
175 | resampled_chunk->payload = (char*)realloc(resampled_chunk->payload, resampled_chunk->payloadSize); | |
176 | memcpy(resampled_chunk->payload, resample_buffer_.data(), resampled_chunk->payloadSize); | |
177 | ||
178 | if (format_.bits() == 24) | |
179 | { | |
180 | // sox has quantized to 32 bit, shift 8 bits right | |
181 | int32_t* frames = (int32_t*)resampled_chunk->payload; | |
182 | for (size_t n = 0; n < resampled_chunk->getSampleCount(); ++n) | |
183 | { | |
184 | // +128 to round to the nearest so that quantisation steps are distributed evenly | |
185 | frames[n] = (frames[n] + 128) >> 8; | |
186 | if (frames[n] > 0x7fffffff) | |
187 | frames[n] = 0x7fffffff; | |
188 | } | |
189 | } | |
190 | chunks_.push(shared_ptr<msg::PcmChunk>(resampled_chunk)); | |
191 | ||
192 | // check if the resample_buffer is large enough, or if soxr was using all available space | |
193 | if (odone == resample_buffer_framesize) | |
194 | { | |
195 | // buffer for resampled data too small, add space for 5ms | |
196 | resample_buffer_.resize(resample_buffer_.size() + format_.frameSize() * ceil(format_.msRate()) * 5); | |
197 | LOG(DEBUG, LOG_TAG) << "Resample buffer completely filled, adding space for 5ms; new buffer size: " << resample_buffer_.size() | |
198 | << " bytes\n"; | |
199 | } | |
200 | ||
201 | // //LOG(TRACE, LOG_TAG) << "ts: " << out->timestamp.sec << "s, " << out->timestamp.usec/1000.f << " ms, duration: " << odone / format_.msRate() | |
202 | // << "\n"; | |
203 | // int64_t next_us = us + static_cast<int64_t>(odone / format_.msRate() * 1000); | |
204 | // LOG(TRACE, LOG_TAG) << "ts: " << us << ", next: " << next_us << ", diff: " << next_us_ - us << "\n"; | |
205 | // next_us_ = next_us; | |
206 | } | |
207 | } | |
208 | } | |
209 | #endif | |
107 | auto resampled = resampler_->resample(std::move(chunk)); | |
108 | if (resampled) | |
109 | { | |
110 | std::lock_guard<std::mutex> lock(mutex_); | |
111 | recent_ = resampled; | |
112 | chunks_.push(resampled); | |
113 | ||
114 | std::shared_ptr<msg::PcmChunk> front_; | |
115 | while (chunks_.front_copy(front_)) | |
116 | { | |
117 | auto age = std::chrono::duration_cast<cs::msec>(TimeProvider::serverNow() - front_->start()); | |
118 | if ((age > 5s + bufferMs_) && chunks_.try_pop(front_)) | |
119 | LOG(TRACE, LOG_TAG) << "Oldest chunk too old: " << age.count() << " ms, removing. Chunks in queue left: " << chunks_.size() << "\n"; | |
120 | else | |
121 | break; | |
122 | } | |
123 | } | |
124 | // LOG(TRACE, LOG_TAG) << "new chunk: " << chunk->durationMs() << " ms, age: " << age.count() << " ms, Chunks: " << chunks_.size() << "\n"; | |
210 | 125 | } |
211 | 126 | |
212 | 127 | |
244 | 159 | if (framesCorrection < 0 && frames + framesCorrection <= 0) |
245 | 160 | { |
246 | 161 | // Avoid underflow in new char[] constructor. |
247 | framesCorrection = -frames + 1; | |
162 | framesCorrection = -static_cast<int32_t>(frames) + 1; | |
248 | 163 | } |
249 | 164 | |
250 | 165 | if (framesCorrection == 0) |
269 | 184 | slices = max; |
270 | 185 | } |
271 | 186 | // Size of each slice. The last slice may be bigger. |
272 | int size = max / slices; | |
187 | auto size = max / slices; | |
273 | 188 | |
274 | 189 | // LOG(TRACE, LOG_TAG) << "getNextPlayerChunk, frames: " << frames << ", correction: " << framesCorrection << " (" << toRead << "), slices: " << slices |
275 | 190 | // << "\n"; |
304 | 219 | } |
305 | 220 | |
306 | 221 | |
307 | void Stream::updateBuffers(int age) | |
222 | void Stream::updateBuffers(chronos::usec::rep age) | |
308 | 223 | { |
309 | 224 | buffer_.add(age); |
310 | 225 | miniBuffer_.add(age); |
329 | 244 | return false; |
330 | 245 | } |
331 | 246 | |
247 | std::lock_guard<std::mutex> lock(mutex_); | |
332 | 248 | time_t now = time(nullptr); |
333 | 249 | if (!chunk_ && !chunks_.try_pop(chunk_)) |
334 | 250 | { |
339 | 255 | } |
340 | 256 | return false; |
341 | 257 | } |
258 | ||
259 | #ifdef LOG_LATENCIES | |
260 | // calculate the estimated end to end latency | |
261 | if (recent_) | |
262 | { | |
263 | cs::nsec req_chunk_duration = cs::nsec(static_cast<cs::nsec::rep>(frames / format_.nsRate())); | |
264 | auto youngest = recent_->end() - req_chunk_duration; | |
265 | cs::msec age = std::chrono::duration_cast<cs::msec>(TimeProvider::serverNow() - youngest + outputBufferDacTime); | |
266 | latencies_.add(age.count()); | |
267 | } | |
268 | #endif | |
342 | 269 | |
343 | 270 | /// we have a chunk |
344 | 271 | /// age = chunk age (server now - rec time: some positive value) - buffer (e.g. 1000ms) + time to DAC |
366 | 293 | { |
367 | 294 | if (age.count() > 0) |
368 | 295 | { |
369 | LOG(DEBUG, LOG_TAG) << "age > 0: " << age.count() / 1000 << "ms\n"; | |
296 | LOG(DEBUG, LOG_TAG) << "age > 0: " << age.count() / 1000 << "ms, dropping old chunks\n"; | |
370 | 297 | // age > 0: the top of the stream is too old. We must fast foward. |
371 | 298 | // delete the current chunk, it's too old. This will avoid an endless loop if there is no chunk in the queue. |
372 | 299 | chunk_ = nullptr; |
376 | 303 | LOG(DEBUG, LOG_TAG) << "age: " << age.count() / 1000 << ", requested chunk_duration: " |
377 | 304 | << std::chrono::duration_cast<std::chrono::milliseconds>(req_chunk_duration).count() |
378 | 305 | << ", duration: " << chunk_->duration<std::chrono::milliseconds>().count() << "\n"; |
306 | // check if the current chunk's end is older than age => can be player | |
307 | if ((age.count() > 0) && (age < chunk_->duration<cs::usec>())) | |
308 | { | |
309 | // fast forward by "age" to get in sync, i.e. age = 0 | |
310 | chunk_->seek(static_cast<uint32_t>(chunk_->format.nsRate() * std::chrono::duration_cast<cs::nsec>(age).count())); | |
311 | age = 0s; | |
312 | } | |
379 | 313 | if (age.count() <= 0) |
380 | 314 | break; |
381 | 315 | } |
387 | 321 | // e.g. age = -20ms (=> should be played in 20ms) |
388 | 322 | // and the current chunk duration is 50ms, so we need to play 20ms silence (as we don't have data) |
389 | 323 | // and can play 30ms of the stream |
390 | uint32_t silent_frames = static_cast<size_t>(-chunk_->format.nsRate() * std::chrono::duration_cast<cs::nsec>(age).count()); | |
324 | uint32_t silent_frames = static_cast<uint32_t>(-chunk_->format.nsRate() * std::chrono::duration_cast<cs::nsec>(age).count()); | |
391 | 325 | bool result = (silent_frames <= frames); |
392 | 326 | silent_frames = std::min(silent_frames, frames); |
393 | LOG(DEBUG, LOG_TAG) << "Silent frames: " << silent_frames << ", frames: " << frames | |
394 | << ", age: " << std::chrono::duration_cast<cs::usec>(age).count() / 1000. << "\n"; | |
395 | getSilentPlayerChunk(outputBuffer, silent_frames); | |
327 | if (silent_frames > 0) | |
328 | { | |
329 | LOG(DEBUG, LOG_TAG) << "Silent frames: " << silent_frames << ", frames: " << frames | |
330 | << ", age: " << std::chrono::duration_cast<cs::usec>(age).count() / 1000. << "\n"; | |
331 | getSilentPlayerChunk(outputBuffer, silent_frames); | |
332 | } | |
396 | 333 | getNextPlayerChunk((char*)outputBuffer + (chunk_->format.frameSize() * silent_frames), frames - silent_frames); |
397 | 334 | |
398 | 335 | if (result) |
471 | 408 | |
472 | 409 | updateBuffers(age.count()); |
473 | 410 | |
474 | // print sync stats | |
411 | // update median_ and shortMedian_ and print sync stats | |
475 | 412 | if (now != lastUpdate_) |
476 | 413 | { |
414 | // log buffer stats | |
477 | 415 | lastUpdate_ = now; |
478 | 416 | median_ = buffer_.median(); |
479 | 417 | shortMedian_ = shortBuffer_.median(); |
480 | LOG(INFO, LOG_TAG) << "Chunk: " << age.count() / 100 << "\t" << miniBuffer_.median() / 100 << "\t" << shortMedian_ / 100 << "\t" << median_ / 100 | |
481 | << "\t" << buffer_.size() << "\t" << cs::duration<cs::msec>(outputBufferDacTime) << "\t" << frame_delta_ << "\n"; | |
418 | LOG(DEBUG, "Stats") << "Chunk: " << age.count() / 100 << "\t" << miniBuffer_.median() / 100 << "\t" << shortMedian_ / 100 << "\t" << median_ / 100 | |
419 | << "\t" << buffer_.size() << "\t" << cs::duration<cs::msec>(outputBufferDacTime) << "\t" << frame_delta_ << "\n"; | |
482 | 420 | frame_delta_ = 0; |
421 | ||
422 | #ifdef LOG_LATENCIES | |
423 | // log latencies | |
424 | std::array<uint8_t, 5> percents = {100, 99, 95, 50, 5}; | |
425 | auto percentiles = latencies_.percentiles(percents); | |
426 | std::stringstream ss; | |
427 | for (std::size_t n = 0; n < percents.size(); ++n) | |
428 | ss << ((n > 0) ? ", " : "") << (int)percents[n] << "%: " << percentiles[n]; | |
429 | LOG(DEBUG, "Latency") << ss.str() << "\n"; | |
430 | #endif | |
483 | 431 | } |
484 | 432 | return (abs(cs::duration<cs::msec>(age)) < 500); |
485 | 433 | } |
486 | 434 | catch (int e) |
487 | 435 | { |
488 | LOG(INFO) << "Exception\n"; | |
436 | LOG(INFO, LOG_TAG) << "Exception: " << e << "\n"; | |
489 | 437 | hard_sync_ = true; |
490 | 438 | return false; |
491 | 439 | } |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef STREAM_H | |
19 | #define STREAM_H | |
18 | #ifndef STREAM_HPP | |
19 | #define STREAM_HPP | |
20 | 20 | |
21 | 21 | #include "common/queue.h" |
22 | 22 | #include "common/sample_format.hpp" |
23 | 23 | #include "double_buffer.hpp" |
24 | 24 | #include "message/message.hpp" |
25 | 25 | #include "message/pcm_chunk.hpp" |
26 | #include "resampler.hpp" | |
26 | 27 | #include <deque> |
27 | 28 | #include <memory> |
28 | 29 | #ifdef HAS_SOXR |
59 | 60 | bool waitForChunk(const std::chrono::milliseconds& timeout) const; |
60 | 61 | |
61 | 62 | private: |
62 | /// Request an audio chunk from the front of the stream. | |
63 | /// Request an audio chunk from the front of the stream. | |
63 | 64 | /// @param outputBuffer will be filled with the chunk |
64 | 65 | /// @param frames the number of requested frames |
65 | 66 | /// @return the timepoint when this chunk should be audible |
68 | 69 | /// Request an audio chunk from the front of the stream with a tempo adaption |
69 | 70 | /// @param outputBuffer will be filled with the chunk |
70 | 71 | /// @param frames the number of requested frames |
71 | /// @param framesCorrection number of frames that should be added or removed. | |
72 | /// @param framesCorrection number of frames that should be added or removed. | |
72 | 73 | /// The function will allways return "frames" frames, but will fit "frames + framesCorrection" frames into "frames" |
73 | 74 | /// so if frames is 100 and framesCorrection is 2, 102 frames will be read from the stream and 2 frames will be removed. |
74 | /// This makes us "fast-forward" by 2 frames, or if framesCorrection is -3, 97 frames will be read from the stream and | |
75 | /// This makes us "fast-forward" by 2 frames, or if framesCorrection is -3, 97 frames will be read from the stream and | |
75 | 76 | /// filled with 3 frames (simply by dublication), this makes us effectively slower |
76 | 77 | /// @return the timepoint when this chunk should be audible |
77 | 78 | chronos::time_point_clk getNextPlayerChunk(void* outputBuffer, uint32_t frames, int32_t framesCorrection); |
81 | 82 | /// @param frames the number of requested frames |
82 | 83 | void getSilentPlayerChunk(void* outputBuffer, uint32_t frames) const; |
83 | 84 | |
84 | void updateBuffers(int age); | |
85 | void updateBuffers(chronos::usec::rep age); | |
85 | 86 | void resetBuffers(); |
86 | 87 | void setRealSampleRate(double sampleRate); |
87 | 88 | |
92 | 93 | DoubleBuffer<chronos::usec::rep> miniBuffer_; |
93 | 94 | DoubleBuffer<chronos::usec::rep> shortBuffer_; |
94 | 95 | DoubleBuffer<chronos::usec::rep> buffer_; |
96 | /// current chunk (oldest, to be played) | |
95 | 97 | std::shared_ptr<msg::PcmChunk> chunk_; |
98 | /// most recent chunk (newly queued) | |
99 | std::shared_ptr<msg::PcmChunk> recent_; | |
100 | DoubleBuffer<chronos::msec::rep> latencies_; | |
96 | 101 | |
97 | int median_; | |
98 | int shortMedian_; | |
102 | chronos::usec::rep median_; | |
103 | chronos::usec::rep shortMedian_; | |
99 | 104 | time_t lastUpdate_; |
100 | 105 | uint32_t playedFrames_; |
101 | 106 | int32_t correctAfterXFrames_; |
102 | 107 | chronos::msec bufferMs_; |
103 | 108 | |
104 | #ifdef HAS_SOXR | |
105 | soxr_t soxr_; | |
106 | #endif | |
109 | std::unique_ptr<Resampler> resampler_; | |
110 | ||
107 | 111 | std::vector<char> resample_buffer_; |
108 | 112 | std::vector<char> read_buffer_; |
109 | 113 | int frame_delta_; |
110 | 114 | // int64_t next_us_; |
115 | ||
116 | mutable std::mutex mutex_; | |
111 | 117 | |
112 | 118 | bool hard_sync_; |
113 | 119 | }; |
18 | 18 | #include "time_provider.hpp" |
19 | 19 | #include "common/aixlog.hpp" |
20 | 20 | |
21 | #include <chrono> | |
22 | ||
23 | static constexpr auto LOG_TAG = "TimeProvider"; | |
21 | 24 | |
22 | 25 | TimeProvider::TimeProvider() : diffToServer_(0) |
23 | 26 | { |
24 | diffBuffer_.setSize(100); | |
27 | diffBuffer_.setSize(200); | |
25 | 28 | } |
26 | 29 | |
27 | 30 | |
36 | 39 | |
37 | 40 | void TimeProvider::setDiffToServer(double ms) |
38 | 41 | { |
39 | static int32_t lastTimeSync = 0; | |
40 | timeval now; | |
41 | chronos::steadytimeofday(&now); | |
42 | using namespace std::chrono_literals; | |
43 | auto now = std::chrono::system_clock::now(); | |
44 | static auto lastTimeSync = now; | |
45 | auto diff = chronos::abs(now - lastTimeSync); | |
42 | 46 | |
43 | 47 | /// clear diffBuffer if last update is older than a minute |
44 | if (!diffBuffer_.empty() && (std::abs(now.tv_sec - lastTimeSync) > 60)) | |
48 | if (!diffBuffer_.empty() && (diff > 60s)) | |
45 | 49 | { |
46 | LOG(INFO) << "Last time sync older than a minute. Clearing time buffer\n"; | |
47 | diffToServer_ = ms * 1000; | |
50 | LOG(INFO, LOG_TAG) << "Last time sync older than a minute. Clearing time buffer\n"; | |
51 | diffToServer_ = static_cast<chronos::usec::rep>(ms * 1000); | |
48 | 52 | diffBuffer_.clear(); |
49 | 53 | } |
50 | lastTimeSync = now.tv_sec; | |
54 | lastTimeSync = now; | |
51 | 55 | |
52 | diffBuffer_.add(ms * 1000); | |
56 | diffBuffer_.add(static_cast<chronos::usec::rep>(ms * 1000)); | |
53 | 57 | diffToServer_ = diffBuffer_.median(); |
54 | // LOG(INFO) << "setDiffToServer: " << ms << ", diff: " << diffToServer_ / 1000000 << " s, " << (diffToServer_ / 1000) % 1000 << "." << diffToServer_ % 1000 | |
55 | // << " ms\n"; | |
58 | // LOG(INFO, LOG_TAG) << "setDiffToServer: " << ms << ", diff: " << diffToServer_ / 1000000 << " s, " << (diffToServer_ / 1000) % 1000 << "." << | |
59 | // diffToServer_ % 1000 << " ms\n"; | |
56 | 60 | } |
57 | 61 | |
58 | 62 | /* |
0 | # Try to find the soxr library | |
1 | # | |
2 | # Copyright 2018 Thincast Technologies GmbH | |
3 | # Copyright 2018 Armin Novak <armin.novak@thincast.com> | |
4 | # | |
5 | # Licensed under the Apache License, Version 2.0 (the "License"); | |
6 | # you may not use this file except in compliance with the License. | |
7 | # You may obtain a copy of the License at | |
8 | # | |
9 | # http://www.apache.org/licenses/LICENSE-2.0 | |
10 | # | |
11 | # Unless required by applicable law or agreed to in writing, software | |
12 | # distributed under the License is distributed on an "AS IS" BASIS, | |
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
14 | # See the License for the specific language governing permissions and | |
15 | # | |
16 | # Once done this will define | |
17 | # | |
18 | # SOXR_ROOT - A list of search hints | |
19 | # | |
20 | # SOXR_FOUND - system has soxr | |
21 | # SOXR_INCLUDE_DIR - the soxr include directory | |
22 | # SOXR_LIBRARIES - libsoxr library | |
23 | ||
24 | if (UNIX AND NOT ANDROID) | |
25 | find_package(PkgConfig QUIET) | |
26 | pkg_check_modules(PC_SOXR QUIET soxr) | |
27 | endif (UNIX AND NOT ANDROID) | |
28 | ||
29 | if (SOXR_INCLUDE_DIR AND SOXR_LIBRARY) | |
30 | set(SOXR_FIND_QUIETLY TRUE) | |
31 | endif (SOXR_INCLUDE_DIR AND SOXR_LIBRARY) | |
32 | ||
33 | find_path(SOXR_INCLUDE_DIR NAMES soxr.h | |
34 | PATH_SUFFIXES include | |
35 | HINTS ${SOXR_ROOT} ${PC_SOXR_INCLUDE_DIRS}) | |
36 | find_library(SOXR_LIBRARY | |
37 | NAMES soxr | |
38 | PATH_SUFFIXES lib | |
39 | HINTS ${SOXR_ROOT} ${PC_SOXR_LIBRARY_DIRS}) | |
40 | ||
41 | include(FindPackageHandleStandardArgs) | |
42 | FIND_PACKAGE_HANDLE_STANDARD_ARGS(soxr DEFAULT_MSG SOXR_LIBRARY SOXR_INCLUDE_DIR) | |
43 | ||
44 | if (SOXR_INCLUDE_DIR AND SOXR_LIBRARY) | |
45 | set(SOXR_FOUND TRUE) | |
46 | set(SOXR_INCLUDE_DIRS ${SOXR_INCLUDE_DIR}) | |
47 | set(SOXR_LIBRARIES ${SOXR_LIBRARY}) | |
48 | endif (SOXR_INCLUDE_DIR AND SOXR_LIBRARY) | |
49 | ||
50 | if (SOXR_FOUND) | |
51 | if (NOT SOXR_FIND_QUIETLY) | |
52 | message(STATUS "Found soxr: ${SOXR_LIBRARIES}") | |
53 | endif (NOT SOXR_FIND_QUIETLY) | |
54 | else (SOXR_FOUND) | |
55 | if (SOXR_FIND_REQUIRED) | |
56 | message(FATAL_ERROR "soxr was not found") | |
57 | endif(SOXR_FIND_REQUIRED) | |
58 | endif (SOXR_FOUND) | |
59 | ||
60 | mark_as_advanced(SOXR_INCLUDE_DIR SOXR_LIBRARY) |
0 | ######## | |
1 | # Find systemd service dir | |
2 | ||
3 | pkg_check_modules(SYSTEMD "systemd") | |
4 | if (SYSTEMD_FOUND AND "${SYSTEMD_SERVICES_INSTALL_DIR}" STREQUAL "") | |
5 | execute_process(COMMAND ${PKG_CONFIG_EXECUTABLE} | |
6 | --variable=systemdsystemunitdir systemd | |
7 | OUTPUT_VARIABLE SYSTEMD_SERVICES_INSTALL_DIR) | |
8 | string(REGEX REPLACE "[ \t\n]+" "" SYSTEMD_SERVICES_INSTALL_DIR | |
9 | "${SYSTEMD_SERVICES_INSTALL_DIR}") | |
10 | elseif (NOT SYSTEMD_FOUND AND SYSTEMD_SERVICES_INSTALL_DIR) | |
11 | message (FATAL_ERROR "Variable SYSTEMD_SERVICES_INSTALL_DIR is\ | |
12 | defined, but we can't find systemd using pkg-config") | |
13 | endif() | |
14 | ||
15 | if (SYSTEMD_FOUND) | |
16 | set(WITH_SYSTEMD "ON") | |
17 | message(STATUS "systemd services install dir: ${SYSTEMD_SERVICES_INSTALL_DIR}") | |
18 | else() | |
19 | set(WITH_SYSTEMD "OFF") | |
20 | endif (SYSTEMD_FOUND) |
0 | add_library(common STATIC daemon.cpp sample_format.cpp) | |
0 | set(SOURCES | |
1 | resampler.cpp | |
2 | sample_format.cpp) | |
3 | ||
4 | if(NOT WIN32) | |
5 | list(APPEND SOURCES daemon.cpp) | |
6 | endif() | |
7 | ||
8 | if (SOXR_FOUND) | |
9 | include_directories(${SOXR_INCLUDE_DIRS}) | |
10 | endif (SOXR_FOUND) | |
11 | ||
12 | add_library(common STATIC ${SOURCES}) | |
13 | ||
14 | if (SOXR_FOUND) | |
15 | target_link_libraries(common ${SOXR_LIBRARIES}) | |
16 | endif (SOXR_FOUND) |
2 | 2 | / _\ ( )( \/ )( ) / \ / __) |
3 | 3 | / \ )( ) ( / (_/\( O )( (_ \ |
4 | 4 | \_/\_/(__)(_/\_)\____/ \__/ \___/ |
5 | version 1.2.5 | |
5 | version 1.4.0 | |
6 | 6 | https://github.com/badaix/aixlog |
7 | 7 | |
8 | 8 | This file is part of aixlog |
31 | 31 | #endif |
32 | 32 | |
33 | 33 | #include <algorithm> |
34 | #include <cctype> | |
34 | 35 | #include <chrono> |
35 | 36 | #include <cstdio> |
36 | 37 | #include <ctime> |
37 | 38 | #include <fstream> |
38 | 39 | #include <functional> |
39 | 40 | #include <iostream> |
41 | #include <map> | |
40 | 42 | #include <memory> |
41 | 43 | #include <mutex> |
42 | 44 | #include <sstream> |
45 | #include <thread> | |
43 | 46 | #include <vector> |
44 | 47 | |
45 | 48 | #ifdef __ANDROID__ |
92 | 95 | /// External logger macros |
93 | 96 | // usage: LOG(SEVERITY) or LOG(SEVERITY, TAG) |
94 | 97 | // e.g.: LOG(NOTICE) or LOG(NOTICE, "my tag") |
98 | #ifndef WIN32 | |
95 | 99 | #define LOG(...) AIXLOG_INTERNAL__LOG_MACRO_CHOOSER(__VA_ARGS__)(__VA_ARGS__) << TIMESTAMP << FUNC |
96 | #define SLOG(...) AIXLOG_INTERNAL__LOG_MACRO_CHOOSER(__VA_ARGS__)(__VA_ARGS__) << TIMESTAMP << SPECIAL << FUNC | |
100 | #endif | |
97 | 101 | |
98 | 102 | // usage: COLOR(TEXT_COLOR, BACKGROUND_COLOR) or COLOR(TEXT_COLOR) |
99 | 103 | // e.g.: COLOR(yellow, blue) or COLOR(red) |
102 | 106 | #define FUNC AixLog::Function(AIXLOG_INTERNAL__FUNC, __FILE__, __LINE__) |
103 | 107 | #define TAG AixLog::Tag |
104 | 108 | #define COND AixLog::Conditional |
105 | #define SPECIAL AixLog::Type::special | |
106 | 109 | #define TIMESTAMP AixLog::Timestamp(std::chrono::system_clock::now()) |
110 | ||
111 | ||
112 | // stijnvdb: sorry! :) LOG(SEV, "tag") was not working for Windows and I couldn't figure out how to fix it for windows without potentially breaking everything | |
113 | // else... | |
114 | // https://stackoverflow.com/questions/3046889/optional-parameters-with-c-macros (Jason Deng) | |
115 | #ifdef WIN32 | |
116 | #define LOG_2(severity, tag) AIXLOG_INTERNAL__LOG_SEVERITY_TAG(severity, tag) | |
117 | #define LOG_1(severity) AIXLOG_INTERNAL__LOG_SEVERITY(severity) | |
118 | #define LOG_0() LOG_1(0) | |
119 | ||
120 | #define FUNC_CHOOSER(_f1, _f2, _f3, ...) _f3 | |
121 | #define FUNC_RECOMPOSER(argsWithParentheses) FUNC_CHOOSER argsWithParentheses | |
122 | #define CHOOSE_FROM_ARG_COUNT(...) FUNC_RECOMPOSER((__VA_ARGS__, LOG_2, LOG_1, FUNC_, ...)) | |
123 | #define MACRO_CHOOSER(...) CHOOSE_FROM_ARG_COUNT(__VA_ARGS__()) | |
124 | #define LOG(...) MACRO_CHOOSER(__VA_ARGS__)(__VA_ARGS__) << TIMESTAMP << FUNC | |
125 | #endif | |
107 | 126 | |
108 | 127 | /** |
109 | 128 | * @brief |
155 | 174 | fatal = SEVERITY::FATAL |
156 | 175 | }; |
157 | 176 | |
158 | /** | |
159 | * @brief | |
160 | * Type of the log message or Sink | |
161 | * | |
162 | * "normal" messages will be logged by "normal" or "all" Sinks | |
163 | * "special" ones by "special" or "all" Sinks | |
164 | */ | |
165 | enum class Type | |
166 | { | |
167 | normal, | |
168 | special, | |
169 | all | |
170 | }; | |
177 | ||
178 | static Severity to_severity(std::string severity, Severity def = Severity::info) | |
179 | { | |
180 | std::transform(severity.begin(), severity.end(), severity.begin(), [](unsigned char c) { return std::tolower(c); }); | |
181 | if (severity == "trace") | |
182 | return Severity::trace; | |
183 | else if (severity == "debug") | |
184 | return Severity::debug; | |
185 | else if (severity == "info") | |
186 | return Severity::info; | |
187 | else if (severity == "notice") | |
188 | return Severity::notice; | |
189 | else if (severity == "warning") | |
190 | return Severity::warning; | |
191 | else if (severity == "error") | |
192 | return Severity::error; | |
193 | else if (severity == "fatal") | |
194 | return Severity::fatal; | |
195 | else | |
196 | return def; | |
197 | } | |
198 | ||
199 | ||
200 | static std::string to_string(Severity logSeverity) | |
201 | { | |
202 | switch (logSeverity) | |
203 | { | |
204 | case Severity::trace: | |
205 | return "Trace"; | |
206 | case Severity::debug: | |
207 | return "Debug"; | |
208 | case Severity::info: | |
209 | return "Info"; | |
210 | case Severity::notice: | |
211 | return "Notice"; | |
212 | case Severity::warning: | |
213 | return "Warn"; | |
214 | case Severity::error: | |
215 | return "Error"; | |
216 | case Severity::fatal: | |
217 | return "Fatal"; | |
218 | default: | |
219 | std::stringstream ss; | |
220 | ss << static_cast<int>(logSeverity); | |
221 | return ss.str(); | |
222 | } | |
223 | } | |
171 | 224 | |
172 | 225 | /** |
173 | 226 | * @brief |
324 | 377 | { |
325 | 378 | } |
326 | 379 | |
380 | Tag(const char* text) : text(text), is_null_(false) | |
381 | { | |
382 | } | |
383 | ||
327 | 384 | Tag(const std::string& text) : text(text), is_null_(false) |
328 | 385 | { |
329 | 386 | } |
337 | 394 | explicit operator bool() const |
338 | 395 | { |
339 | 396 | return !is_null_; |
397 | } | |
398 | ||
399 | bool operator<(const Tag& other) const | |
400 | { | |
401 | return (text < other.text); | |
340 | 402 | } |
341 | 403 | |
342 | 404 | std::string text; |
388 | 450 | */ |
389 | 451 | struct Metadata |
390 | 452 | { |
391 | Metadata() : severity(Severity::trace), tag(nullptr), type(Type::normal), function(nullptr), timestamp(nullptr) | |
453 | Metadata() : severity(Severity::trace), tag(nullptr), function(nullptr), timestamp(nullptr) | |
392 | 454 | { |
393 | 455 | } |
394 | 456 | |
395 | 457 | Severity severity; |
396 | 458 | Tag tag; |
397 | Type type; | |
398 | 459 | Function function; |
399 | 460 | Timestamp timestamp; |
400 | 461 | }; |
462 | ||
463 | ||
464 | class Filter | |
465 | { | |
466 | public: | |
467 | Filter() | |
468 | { | |
469 | } | |
470 | ||
471 | Filter(Severity severity) | |
472 | { | |
473 | add_filter(severity); | |
474 | } | |
475 | ||
476 | bool match(const Metadata& metadata) const | |
477 | { | |
478 | if (tag_filter_.empty()) | |
479 | return true; | |
480 | ||
481 | auto iter = tag_filter_.find(metadata.tag); | |
482 | if (iter != tag_filter_.end()) | |
483 | return (metadata.severity >= iter->second); | |
484 | ||
485 | iter = tag_filter_.find("*"); | |
486 | if (iter != tag_filter_.end()) | |
487 | return (metadata.severity >= iter->second); | |
488 | ||
489 | return false; | |
490 | } | |
491 | ||
492 | void add_filter(const Tag& tag, Severity severity) | |
493 | { | |
494 | tag_filter_[tag] = severity; | |
495 | } | |
496 | ||
497 | void add_filter(Severity severity) | |
498 | { | |
499 | tag_filter_["*"] = severity; | |
500 | } | |
501 | ||
502 | void add_filter(const std::string& filter) | |
503 | { | |
504 | auto pos = filter.find(":"); | |
505 | if (pos != std::string::npos) | |
506 | add_filter(filter.substr(0, pos), to_severity(filter.substr(pos + 1))); | |
507 | else | |
508 | add_filter(to_severity(filter)); | |
509 | } | |
510 | ||
511 | private: | |
512 | std::map<Tag, Severity> tag_filter_; | |
513 | }; | |
514 | ||
401 | 515 | |
402 | 516 | /** |
403 | 517 | * @brief |
407 | 521 | */ |
408 | 522 | struct Sink |
409 | 523 | { |
410 | Sink(Severity severity, Type type) : severity(severity), sink_type_(type) | |
524 | Sink(const Filter& filter) : filter(filter) | |
411 | 525 | { |
412 | 526 | } |
413 | 527 | |
414 | 528 | virtual ~Sink() = default; |
415 | 529 | |
416 | 530 | virtual void log(const Metadata& metadata, const std::string& message) = 0; |
417 | virtual Type get_type() const | |
418 | { | |
419 | return sink_type_; | |
420 | } | |
421 | ||
422 | virtual Sink& set_type(Type sink_type) | |
423 | { | |
424 | sink_type_ = sink_type; | |
425 | return *this; | |
426 | } | |
427 | ||
428 | Severity severity; | |
429 | ||
430 | protected: | |
431 | Type sink_type_; | |
531 | ||
532 | Filter filter; | |
432 | 533 | }; |
433 | 534 | |
434 | 535 | /// ostream operators << for the meta data structs |
435 | 536 | static std::ostream& operator<<(std::ostream& os, const Severity& log_severity); |
436 | static std::ostream& operator<<(std::ostream& os, const Type& log_type); | |
437 | 537 | static std::ostream& operator<<(std::ostream& os, const Timestamp& timestamp); |
438 | 538 | static std::ostream& operator<<(std::ostream& os, const Tag& tag); |
439 | 539 | static std::ostream& operator<<(std::ostream& os, const Function& function); |
499 | 599 | log_sinks_.erase(std::remove(log_sinks_.begin(), log_sinks_.end(), sink), log_sinks_.end()); |
500 | 600 | } |
501 | 601 | |
502 | static std::string to_string(Severity logSeverity) | |
503 | { | |
504 | switch (logSeverity) | |
505 | { | |
506 | case Severity::trace: | |
507 | return "Trace"; | |
508 | case Severity::debug: | |
509 | return "Debug"; | |
510 | case Severity::info: | |
511 | return "Info"; | |
512 | case Severity::notice: | |
513 | return "Notice"; | |
514 | case Severity::warning: | |
515 | return "Warn"; | |
516 | case Severity::error: | |
517 | return "Error"; | |
518 | case Severity::fatal: | |
519 | return "Fatal"; | |
520 | default: | |
521 | std::stringstream ss; | |
522 | ss << logSeverity; | |
523 | return ss.str(); | |
524 | } | |
525 | } | |
526 | ||
527 | 602 | protected: |
528 | Log() noexcept | |
603 | Log() noexcept : last_buffer_(nullptr) | |
529 | 604 | { |
530 | 605 | std::clog.rdbuf(this); |
531 | std::clog << Severity() << Type::normal << Tag() << Function() << Conditional() << AixLog::Color::NONE << std::flush; | |
606 | std::clog << Severity() << Tag() << Function() << Conditional() << AixLog::Color::NONE << std::flush; | |
532 | 607 | } |
533 | 608 | |
534 | 609 | virtual ~Log() |
539 | 614 | int sync() override |
540 | 615 | { |
541 | 616 | std::lock_guard<std::recursive_mutex> lock(mutex_); |
542 | if (!buffer_.str().empty()) | |
617 | if (!get_stream().str().empty()) | |
543 | 618 | { |
544 | 619 | if (conditional_.is_true()) |
545 | 620 | { |
546 | 621 | for (const auto& sink : log_sinks_) |
547 | 622 | { |
548 | if ((metadata_.type == Type::all) || (sink->get_type() == Type::all) || (metadata_.type == sink->get_type())) | |
549 | if (metadata_.severity >= sink->severity) | |
550 | sink->log(metadata_, buffer_.str()); | |
623 | if (sink->filter.match(metadata_)) | |
624 | sink->log(metadata_, get_stream().str()); | |
551 | 625 | } |
552 | 626 | } |
553 | buffer_.str(""); | |
554 | buffer_.clear(); | |
627 | get_stream().str(""); | |
628 | get_stream().clear(); | |
555 | 629 | } |
556 | 630 | |
557 | 631 | return 0; |
565 | 639 | if (c == '\n') |
566 | 640 | sync(); |
567 | 641 | else |
568 | buffer_ << static_cast<char>(c); | |
642 | get_stream() << static_cast<char>(c); | |
569 | 643 | } |
570 | 644 | else |
571 | 645 | { |
576 | 650 | |
577 | 651 | private: |
578 | 652 | friend std::ostream& operator<<(std::ostream& os, const Severity& log_severity); |
579 | friend std::ostream& operator<<(std::ostream& os, const Type& log_type); | |
580 | 653 | friend std::ostream& operator<<(std::ostream& os, const Timestamp& timestamp); |
581 | 654 | friend std::ostream& operator<<(std::ostream& os, const Tag& tag); |
582 | 655 | friend std::ostream& operator<<(std::ostream& os, const Function& function); |
583 | 656 | friend std::ostream& operator<<(std::ostream& os, const Conditional& conditional); |
584 | 657 | |
585 | std::stringstream buffer_; | |
658 | std::stringstream& get_stream() | |
659 | { | |
660 | auto id = std::this_thread::get_id(); | |
661 | if ((last_buffer_ == nullptr) || (last_id_ != id)) | |
662 | { | |
663 | last_id_ = id; | |
664 | last_buffer_ = &(buffer_[id]); | |
665 | } | |
666 | return *last_buffer_; | |
667 | } | |
668 | ||
669 | std::map<std::thread::id, std::stringstream> buffer_; | |
670 | std::thread::id last_id_; | |
671 | std::stringstream* last_buffer_ = nullptr; | |
586 | 672 | Metadata metadata_; |
587 | 673 | Conditional conditional_; |
588 | 674 | std::vector<log_sink_ptr> log_sinks_; |
589 | 675 | std::recursive_mutex mutex_; |
590 | 676 | }; |
677 | ||
678 | /** | |
679 | * @brief | |
680 | * Null log sink | |
681 | * | |
682 | * Discards all log messages | |
683 | */ | |
684 | struct SinkNull : public Sink | |
685 | { | |
686 | SinkNull() : Sink(Filter()) | |
687 | { | |
688 | } | |
689 | ||
690 | void log(const Metadata& /*metadata*/, const std::string& /*message*/) override | |
691 | { | |
692 | } | |
693 | }; | |
694 | ||
591 | 695 | |
592 | 696 | /** |
593 | 697 | * @brief |
605 | 709 | */ |
606 | 710 | struct SinkFormat : public Sink |
607 | 711 | { |
608 | SinkFormat(Severity severity, Type type, const std::string& format) : Sink(severity, type), format_(format) | |
712 | SinkFormat(const Filter& filter, const std::string& format) : Sink(filter), format_(format) | |
609 | 713 | { |
610 | 714 | } |
611 | 715 | |
625 | 729 | |
626 | 730 | size_t pos = result.find("#severity"); |
627 | 731 | if (pos != std::string::npos) |
628 | result.replace(pos, 9, Log::to_string(metadata.severity)); | |
732 | result.replace(pos, 9, to_string(metadata.severity)); | |
733 | ||
734 | pos = result.find("#color_severity"); | |
735 | if (pos != std::string::npos) | |
736 | { | |
737 | std::stringstream ss; | |
738 | ss << TextColor(Color::RED) << to_string(metadata.severity) << TextColor(Color::NONE); | |
739 | result.replace(pos, 15, ss.str()); | |
740 | } | |
629 | 741 | |
630 | 742 | pos = result.find("#tag_func"); |
631 | 743 | if (pos != std::string::npos) |
663 | 775 | */ |
664 | 776 | struct SinkCout : public SinkFormat |
665 | 777 | { |
666 | SinkCout(Severity severity, Type type, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") : SinkFormat(severity, type, format) | |
778 | SinkCout(const Filter& filter, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") : SinkFormat(filter, format) | |
667 | 779 | { |
668 | 780 | } |
669 | 781 | |
679 | 791 | */ |
680 | 792 | struct SinkCerr : public SinkFormat |
681 | 793 | { |
682 | SinkCerr(Severity severity, Type type, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") : SinkFormat(severity, type, format) | |
794 | SinkCerr(const Filter& filter, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") : SinkFormat(filter, format) | |
683 | 795 | { |
684 | 796 | } |
685 | 797 | |
695 | 807 | */ |
696 | 808 | struct SinkFile : public SinkFormat |
697 | 809 | { |
698 | SinkFile(Severity severity, Type type, const std::string& filename, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") | |
699 | : SinkFormat(severity, type, format) | |
810 | SinkFile(const Filter& filter, const std::string& filename, const std::string& format = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)") | |
811 | : SinkFormat(filter, format) | |
700 | 812 | { |
701 | 813 | ofs.open(filename.c_str(), std::ofstream::out | std::ofstream::trunc); |
702 | 814 | } |
724 | 836 | */ |
725 | 837 | struct SinkOutputDebugString : public Sink |
726 | 838 | { |
727 | SinkOutputDebugString(Severity severity, Type type = Type::all) : Sink(severity, type) | |
839 | SinkOutputDebugString(const Filter& filter) : Sink(filter) | |
728 | 840 | { |
729 | 841 | } |
730 | 842 | |
731 | 843 | void log(const Metadata& metadata, const std::string& message) override |
732 | 844 | { |
733 | OutputDebugString(message.c_str()); | |
845 | std::wstring wide = std::wstring(message.begin(), message.end()); | |
846 | OutputDebugString(wide.c_str()); | |
734 | 847 | } |
735 | 848 | }; |
736 | 849 | #endif |
742 | 855 | */ |
743 | 856 | struct SinkUnifiedLogging : public Sink |
744 | 857 | { |
745 | SinkUnifiedLogging(Severity severity, Type type = Type::all) : Sink(severity, type) | |
858 | SinkUnifiedLogging(const Filter& filter) : Sink(filter) | |
746 | 859 | { |
747 | 860 | } |
748 | 861 | |
782 | 895 | */ |
783 | 896 | struct SinkSyslog : public Sink |
784 | 897 | { |
785 | SinkSyslog(const char* ident, Severity severity, Type type) : Sink(severity, type) | |
898 | SinkSyslog(const char* ident, const Filter& filter) : Sink(filter) | |
786 | 899 | { |
787 | 900 | openlog(ident, LOG_PID, LOG_USER); |
788 | 901 | } |
831 | 944 | */ |
832 | 945 | struct SinkAndroid : public Sink |
833 | 946 | { |
834 | SinkAndroid(const std::string& ident, Severity severity, Type type = Type::all) : Sink(severity, type), ident_(ident) | |
947 | SinkAndroid(const std::string& ident, const Filter& filter) : Sink(filter), ident_(ident) | |
835 | 948 | { |
836 | 949 | } |
837 | 950 | |
888 | 1001 | */ |
889 | 1002 | struct SinkEventLog : public Sink |
890 | 1003 | { |
891 | SinkEventLog(const std::string& ident, Severity severity, Type type = Type::all) : Sink(severity, type) | |
892 | { | |
893 | event_log = RegisterEventSource(NULL, ident.c_str()); | |
1004 | SinkEventLog(const std::string& ident, const Filter& filter) : Sink(filter) | |
1005 | { | |
1006 | std::wstring wide = std::wstring(ident.begin(), ident.end()); // stijnvdb: RegisterEventSource expands to RegisterEventSourceW which takes wchar_t | |
1007 | event_log = RegisterEventSource(NULL, wide.c_str()); | |
894 | 1008 | } |
895 | 1009 | |
896 | 1010 | WORD get_type(Severity severity) const |
916 | 1030 | |
917 | 1031 | void log(const Metadata& metadata, const std::string& message) override |
918 | 1032 | { |
1033 | std::wstring wide = std::wstring(message.begin(), message.end()); | |
919 | 1034 | // We need this temp variable because we cannot take address of rValue |
920 | const char* c_str = message.c_str(); | |
1035 | const wchar_t* c_str = wide.c_str(); | |
1036 | ||
921 | 1037 | ReportEvent(event_log, get_type(metadata.severity), 0, 0, NULL, 1, 0, &c_str, NULL); |
922 | 1038 | } |
923 | 1039 | |
937 | 1053 | */ |
938 | 1054 | struct SinkNative : public Sink |
939 | 1055 | { |
940 | SinkNative(const std::string& ident, Severity severity, Type type = Type::all) : Sink(severity, type), log_sink_(nullptr), ident_(ident) | |
1056 | SinkNative(const std::string& ident, const Filter& filter) : Sink(filter), log_sink_(nullptr), ident_(ident) | |
941 | 1057 | { |
942 | 1058 | #ifdef __ANDROID__ |
943 | log_sink_ = std::make_shared<SinkAndroid>(ident_, severity, type); | |
1059 | log_sink_ = std::make_shared<SinkAndroid>(ident_, filter); | |
944 | 1060 | #elif HAS_APPLE_UNIFIED_LOG_ |
945 | log_sink_ = std::make_shared<SinkUnifiedLogging>(severity, type); | |
1061 | log_sink_ = std::make_shared<SinkUnifiedLogging>(filter); | |
946 | 1062 | #elif _WIN32 |
947 | log_sink_ = std::make_shared<SinkEventLog>(ident, severity, type); | |
1063 | log_sink_ = std::make_shared<SinkEventLog>(ident, filter); | |
948 | 1064 | #elif HAS_SYSLOG_ |
949 | log_sink_ = std::make_shared<SinkSyslog>(ident_.c_str(), severity, type); | |
1065 | log_sink_ = std::make_shared<SinkSyslog>(ident_.c_str(), filter); | |
950 | 1066 | #else |
951 | 1067 | /// will not throw or something. Use "get_logger()" to check for success |
952 | 1068 | log_sink_ = nullptr; |
981 | 1097 | { |
982 | 1098 | using callback_fun = std::function<void(const Metadata& metadata, const std::string& message)>; |
983 | 1099 | |
984 | SinkCallback(Severity severity, Type type, callback_fun callback) : Sink(severity, type), callback_(callback) | |
1100 | SinkCallback(const Filter& filter, callback_fun callback) : Sink(filter), callback_(callback) | |
985 | 1101 | { |
986 | 1102 | } |
987 | 1103 | |
1011 | 1127 | { |
1012 | 1128 | log->sync(); |
1013 | 1129 | log->metadata_.severity = log_severity; |
1014 | log->metadata_.type = Type::normal; | |
1015 | 1130 | log->metadata_.timestamp = nullptr; |
1016 | 1131 | log->metadata_.tag = nullptr; |
1017 | 1132 | log->metadata_.function = nullptr; |
1020 | 1135 | } |
1021 | 1136 | else |
1022 | 1137 | { |
1023 | os << Log::to_string(log_severity); | |
1024 | } | |
1025 | return os; | |
1026 | } | |
1027 | ||
1028 | static std::ostream& operator<<(std::ostream& os, const Type& log_type) | |
1029 | { | |
1030 | Log* log = dynamic_cast<Log*>(os.rdbuf()); | |
1031 | if (log != nullptr) | |
1032 | { | |
1033 | std::lock_guard<std::recursive_mutex> lock(log->mutex_); | |
1034 | log->metadata_.type = log_type; | |
1138 | os << to_string(log_severity); | |
1035 | 1139 | } |
1036 | 1140 | return os; |
1037 | 1141 | } |
64 | 64 | uid_t user_uid = (uid_t)-1; |
65 | 65 | gid_t user_gid = (gid_t)-1; |
66 | 66 | std::string user_name; |
67 | //#ifdef FREEBSD | |
68 | // bool had_group = false; | |
69 | //#endif | |
67 | // #ifdef FREEBSD | |
68 | // bool had_group = false; | |
69 | // #endif | |
70 | 70 | |
71 | 71 | if (!user_.empty()) |
72 | 72 | { |
86 | 86 | if (grp == nullptr) |
87 | 87 | throw SnapException("no such group \"" + group_ + "\""); |
88 | 88 | user_gid = grp->gr_gid; |
89 | //#ifdef FREEBSD | |
90 | // had_group = true; | |
91 | //#endif | |
89 | // #ifdef FREEBSD | |
90 | // had_group = true; | |
91 | // #endif | |
92 | 92 | } |
93 | 93 | |
94 | 94 | if (chown(pidfile_.c_str(), user_uid, user_gid) == -1) |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef CLIENT_INFO_H | |
19 | #define CLIENT_INFO_H | |
20 | ||
21 | #include "json_message.hpp" | |
22 | ||
23 | ||
24 | namespace msg | |
25 | { | |
26 | ||
27 | /// Client information sent from client to server | |
28 | /// Might also be used for sync stats and latency estimations | |
29 | class ClientInfo : public JsonMessage | |
30 | { | |
31 | public: | |
32 | ClientInfo() : JsonMessage(message_type::kClientInfo) | |
33 | { | |
34 | setVolume(100); | |
35 | setMuted(false); | |
36 | } | |
37 | ||
38 | ~ClientInfo() override = default; | |
39 | ||
40 | uint16_t getVolume() | |
41 | { | |
42 | return get("volume", 100); | |
43 | } | |
44 | ||
45 | bool isMuted() | |
46 | { | |
47 | return get("muted", false); | |
48 | } | |
49 | ||
50 | void setVolume(uint16_t volume) | |
51 | { | |
52 | msg["volume"] = volume; | |
53 | } | |
54 | ||
55 | void setMuted(bool muted) | |
56 | { | |
57 | msg["muted"] = muted; | |
58 | } | |
59 | }; | |
60 | } | |
61 | ||
62 | ||
63 | #endif |
29 | 29 | class CodecHeader : public BaseMessage |
30 | 30 | { |
31 | 31 | public: |
32 | CodecHeader(const std::string& codecName = "", size_t size = 0) | |
32 | CodecHeader(const std::string& codecName = "", uint32_t size = 0) | |
33 | 33 | : BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codecName) |
34 | 34 | { |
35 | 35 | if (size > 0) |
49 | 49 | |
50 | 50 | uint32_t getSize() const override |
51 | 51 | { |
52 | return sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize; | |
52 | return static_cast<uint32_t>(sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize); | |
53 | 53 | } |
54 | 54 | |
55 | 55 | uint32_t payloadSize; |
18 | 18 | #ifndef MESSAGE_FACTORY_HPP |
19 | 19 | #define MESSAGE_FACTORY_HPP |
20 | 20 | |
21 | #include "client_info.hpp" | |
21 | 22 | #include "codec_header.hpp" |
22 | 23 | #include "hello.hpp" |
24 | #include "pcm_chunk.hpp" | |
23 | 25 | #include "server_settings.hpp" |
24 | 26 | #include "stream_tags.hpp" |
25 | 27 | #include "time.hpp" |
26 | #include "wire_chunk.hpp" | |
27 | 28 | |
28 | 29 | #include "common/str_compat.hpp" |
29 | 30 | #include "common/utils.hpp" |
33 | 34 | |
34 | 35 | namespace msg |
35 | 36 | { |
37 | ||
38 | template <typename ToType> | |
39 | static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> message) | |
40 | { | |
41 | ToType* tmp = dynamic_cast<ToType*>(message.get()); | |
42 | std::unique_ptr<ToType> result; | |
43 | if (tmp != nullptr) | |
44 | { | |
45 | message.release(); | |
46 | result.reset(tmp); | |
47 | return result; | |
48 | } | |
49 | return nullptr; | |
50 | } | |
51 | ||
36 | 52 | namespace factory |
37 | 53 | { |
38 | 54 | |
45 | 61 | result->deserialize(base_message, buffer); |
46 | 62 | return result; |
47 | 63 | } |
48 | ||
49 | 64 | |
50 | 65 | static std::unique_ptr<BaseMessage> createMessage(const BaseMessage& base_message, char* buffer) |
51 | 66 | { |
63 | 78 | case kTime: |
64 | 79 | return createMessage<Time>(base_message, buffer); |
65 | 80 | case kWireChunk: |
66 | return createMessage<WireChunk>(base_message, buffer); | |
81 | // this is kind of cheated to safe the convertion from WireChunk to PcmChunk | |
82 | // the user of the factory must be aware that a PcmChunk will be created | |
83 | return createMessage<PcmChunk>(base_message, buffer); | |
84 | case kClientInfo: | |
85 | return createMessage<ClientInfo>(base_message, buffer); | |
67 | 86 | default: |
68 | 87 | return nullptr; |
69 | 88 | } |
46 | 46 | |
47 | 47 | uint32_t getSize() const override |
48 | 48 | { |
49 | return sizeof(uint32_t) + msg.dump().size(); | |
49 | return static_cast<uint32_t>(sizeof(uint32_t) + msg.dump().size()); | |
50 | 50 | } |
51 | 51 | |
52 | 52 | json msg; |
73 | 73 | } |
74 | 74 | } |
75 | 75 | }; |
76 | } | |
76 | } // namespace msg | |
77 | 77 | |
78 | 78 | |
79 | 79 | #endif |
24 | 24 | #include <cstring> |
25 | 25 | #include <iostream> |
26 | 26 | #include <streambuf> |
27 | #ifndef WINDOWS | |
27 | 28 | #include <sys/time.h> |
29 | #endif | |
28 | 30 | #include <vector> |
29 | 31 | |
30 | 32 | /* |
57 | 59 | kTime = 4, |
58 | 60 | kHello = 5, |
59 | 61 | kStreamTags = 6, |
62 | kClientInfo = 7, | |
60 | 63 | |
61 | 64 | kFirst = kBase, |
62 | kLast = kStreamTags | |
65 | kLast = kClientInfo | |
63 | 66 | }; |
64 | 67 | |
65 | 68 | |
228 | 231 | |
229 | 232 | void writeVal(std::ostream& stream, const std::string& val) const |
230 | 233 | { |
231 | uint32_t size = val.size(); | |
234 | uint32_t size = static_cast<uint32_t>(val.size()); | |
232 | 235 | writeVal(stream, val.c_str(), size); |
233 | 236 | } |
234 | 237 |
35 | 35 | class PcmChunk : public WireChunk |
36 | 36 | { |
37 | 37 | public: |
38 | PcmChunk(const SampleFormat& sampleFormat, size_t ms) | |
38 | PcmChunk(const SampleFormat& sampleFormat, uint32_t ms) | |
39 | 39 | : WireChunk((sampleFormat.rate() * ms / 1000) * sampleFormat.frameSize()), format(sampleFormat), idx_(0) |
40 | 40 | { |
41 | 41 | } |
57 | 57 | } |
58 | 58 | #endif |
59 | 59 | |
60 | int readFrames(void* outputBuffer, size_t frameCount) | |
60 | // std::unique_ptr<PcmChunk> consume(uint32_t frameCount) | |
61 | // { | |
62 | // auto result = std::make_unique<PcmChunk>(format, 0); | |
63 | // if (frameCount * format.frameSize() > payloadSize) | |
64 | // frameCount = payloadSize / format.frameSize(); | |
65 | // result->payload = payload; | |
66 | // result->payloadSize = frameCount * format.frameSize(); | |
67 | // payloadSize -= result->payloadSize; | |
68 | // payload = (char*)realloc(payload + result->payloadSize, payloadSize); | |
69 | // // payload += result->payloadSize; | |
70 | // return result; | |
71 | // } | |
72 | ||
73 | int readFrames(void* outputBuffer, uint32_t frameCount) | |
61 | 74 | { |
62 | 75 | // logd << "read: " << frameCount << ", total: " << (wireChunk->length / format.frameSize()) << ", idx: " << idx;// << std::endl; |
63 | 76 | int result = frameCount; |
76 | 89 | |
77 | 90 | int seek(int frames) |
78 | 91 | { |
79 | if ((frames < 0) && (-frames > (int)idx_)) | |
80 | frames = -idx_; | |
92 | if ((frames < 0) && (-frames > static_cast<int>(idx_))) | |
93 | frames = -static_cast<int>(idx_); | |
81 | 94 | |
82 | 95 | idx_ += frames; |
83 | 96 | if (idx_ > getFrameCount()) |
85 | 98 | |
86 | 99 | return idx_; |
87 | 100 | } |
88 | ||
89 | 101 | |
90 | 102 | chronos::time_point_clk start() const override |
91 | 103 | { |
104 | 116 | return std::chrono::duration_cast<T>(chronos::nsec(static_cast<chronos::nsec::rep>(1000000 * getFrameCount() / format.msRate()))); |
105 | 117 | } |
106 | 118 | |
119 | // void append(const PcmChunk& chunk) | |
120 | // { | |
121 | // auto newSize = payloadSize + chunk.payloadSize; | |
122 | // payload = (char*)realloc(payload, newSize); | |
123 | // memcpy(payload + payloadSize, chunk.payload, chunk.payloadSize); | |
124 | // payloadSize = newSize; | |
125 | // } | |
126 | ||
107 | 127 | double durationMs() const |
108 | 128 | { |
109 | 129 | return static_cast<double>(getFrameCount()) / format.msRate(); |
120 | 140 | return idx_ >= getFrameCount(); |
121 | 141 | } |
122 | 142 | |
123 | inline size_t getFrameCount() const | |
143 | inline uint32_t getFrameCount() const | |
124 | 144 | { |
125 | 145 | return (payloadSize / format.frameSize()); |
126 | 146 | } |
127 | 147 | |
128 | inline size_t getSampleCount() const | |
148 | inline uint32_t getSampleCount() const | |
129 | 149 | { |
130 | 150 | return (payloadSize / format.sampleSize()); |
131 | 151 | } |
133 | 153 | SampleFormat format; |
134 | 154 | |
135 | 155 | private: |
136 | uint32_t idx_; | |
156 | uint32_t idx_ = 0; | |
137 | 157 | }; |
138 | 158 | } // namespace msg |
139 | 159 |
38 | 38 | class WireChunk : public BaseMessage |
39 | 39 | { |
40 | 40 | public: |
41 | WireChunk(size_t size = 0) : BaseMessage(message_type::kWireChunk), payloadSize(size), payload(nullptr) | |
41 | WireChunk(uint32_t size = 0) : BaseMessage(message_type::kWireChunk), payloadSize(size), payload(nullptr) | |
42 | 42 | { |
43 | 43 | if (size > 0) |
44 | 44 | payload = (char*)malloc(size * sizeof(char)); |
32 | 32 | #include <sstream> |
33 | 33 | #include <stdexcept> |
34 | 34 | #include <vector> |
35 | #ifdef WINDOWS | |
36 | #include <cctype> | |
37 | #endif | |
35 | 38 | |
36 | 39 | |
37 | 40 | namespace popl |
763 | 766 | { |
764 | 767 | if (this->assign_to_) |
765 | 768 | { |
766 | if (this->is_set() || default_) | |
767 | *this->assign_to_ = value(); | |
769 | if (!this->is_set() && default_) | |
770 | *this->assign_to_ = *default_; | |
771 | else if (this->is_set()) | |
772 | *this->assign_to_ = values_.back(); | |
768 | 773 | } |
769 | 774 | } |
770 | 775 |
38 | 38 | auto val = queue_.front(); |
39 | 39 | queue_.pop_front(); |
40 | 40 | return val; |
41 | } | |
42 | ||
43 | T front() | |
44 | { | |
45 | std::unique_lock<std::mutex> mlock(mutex_); | |
46 | while (queue_.empty()) | |
47 | cond_.wait(mlock); | |
48 | ||
49 | return queue_.front(); | |
50 | 41 | } |
51 | 42 | |
52 | 43 | void abort_wait() |
106 | 97 | cond_.notify_one(); |
107 | 98 | } |
108 | 99 | |
100 | bool back_copy(T& copy) | |
101 | { | |
102 | std::lock_guard<std::mutex> mlock(mutex_); | |
103 | if (queue_.empty()) | |
104 | return false; | |
105 | copy = queue_.back(); | |
106 | return true; | |
107 | } | |
108 | ||
109 | bool front_copy(T& copy) | |
110 | { | |
111 | std::lock_guard<std::mutex> mlock(mutex_); | |
112 | if (queue_.empty()) | |
113 | return false; | |
114 | copy = queue_.front(); | |
115 | return true; | |
116 | } | |
117 | ||
109 | 118 | void push_front(T&& item) |
110 | 119 | { |
111 | 120 | { |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "resampler.hpp" | |
19 | #include "common/aixlog.hpp" | |
20 | #include "common/snap_exception.hpp" | |
21 | ||
22 | #include <cmath> | |
23 | ||
24 | using namespace std; | |
25 | ||
26 | static constexpr auto LOG_TAG = "Resampler"; | |
27 | ||
28 | Resampler::Resampler(const SampleFormat& in_format, const SampleFormat& out_format) : in_format_(in_format), out_format_(out_format) | |
29 | { | |
30 | #ifdef HAS_SOXR | |
31 | soxr_ = nullptr; | |
32 | if ((out_format_.rate() != in_format_.rate()) || (out_format_.bits() != in_format_.bits())) | |
33 | { | |
34 | LOG(INFO, LOG_TAG) << "Resampling from " << in_format_.toString() << " to " << out_format_.toString() << "\n"; | |
35 | soxr_error_t error; | |
36 | ||
37 | soxr_datatype_t in_type = SOXR_INT16_I; | |
38 | soxr_datatype_t out_type = SOXR_INT16_I; | |
39 | if (in_format_.sampleSize() > 2) | |
40 | in_type = SOXR_INT32_I; | |
41 | if (out_format_.sampleSize() > 2) | |
42 | out_type = SOXR_INT32_I; | |
43 | soxr_io_spec_t iospec = soxr_io_spec(in_type, out_type); | |
44 | // HQ should be fine: http://sox.sourceforge.net/Docs/FAQ | |
45 | soxr_quality_spec_t q_spec = soxr_quality_spec(SOXR_HQ, 0); | |
46 | soxr_ = | |
47 | soxr_create(static_cast<double>(in_format_.rate()), static_cast<double>(out_format_.rate()), in_format_.channels(), &error, &iospec, &q_spec, NULL); | |
48 | if (error) | |
49 | { | |
50 | LOG(ERROR, LOG_TAG) << "Error soxr_create: " << error << "\n"; | |
51 | soxr_ = nullptr; | |
52 | } | |
53 | // initialize the buffer with 20ms (~latency of the reampler) | |
54 | resample_buffer_.resize(out_format_.frameSize() * static_cast<uint16_t>(ceil(out_format_.msRate() * 20))); | |
55 | } | |
56 | #else | |
57 | LOG(WARNING, LOG_TAG) << "Soxr not available, resampling not supported\n"; | |
58 | if ((out_format_.rate() != in_format_.rate()) || (out_format_.bits() != in_format_.bits())) | |
59 | { | |
60 | throw SnapException("Resampling requested, but not supported"); | |
61 | } | |
62 | #endif | |
63 | // resampled_chunk_ = std::make_unique<msg::PcmChunk>(out_format_, 0); | |
64 | } | |
65 | ||
66 | ||
67 | bool Resampler::resamplingNeeded() const | |
68 | { | |
69 | #ifdef HAS_SOXR | |
70 | return soxr_ != nullptr; | |
71 | #else | |
72 | return false; | |
73 | #endif | |
74 | } | |
75 | ||
76 | ||
77 | // std::shared_ptr<msg::PcmChunk> Resampler::resample(std::shared_ptr<msg::PcmChunk> chunk, chronos::usec duration) | |
78 | // { | |
79 | // auto resampled_chunk = resample(chunk); | |
80 | // if (!resampled_chunk) | |
81 | // return nullptr; | |
82 | // std::cerr << "1\n"; | |
83 | // resampled_chunk_->append(*resampled_chunk); | |
84 | // std::cerr << "2\n"; | |
85 | // while (resampled_chunk_->duration<chronos::usec>() >= duration) | |
86 | // { | |
87 | // LOG(DEBUG, LOG_TAG) << "resampled duration: " << resampled_chunk_->durationMs() << ", consuming: " << out_format_.usRate() * duration.count() << | |
88 | // "\n"; | |
89 | // auto chunk = resampled_chunk_->consume(out_format_.usRate() * duration.count()); | |
90 | // LOG(DEBUG, LOG_TAG) << "consumed: " << chunk->durationMs() << ", resampled duration: " << resampled_chunk_->durationMs() << "\n"; | |
91 | // return chunk; | |
92 | // } | |
93 | // // resampled_chunks_.push_back(resampled_chunk); | |
94 | // // chronos::usec avail; | |
95 | // // for (const auto& chunk: resampled_chunks_) | |
96 | // // { | |
97 | // // avail += chunk->durationLeft<chronos::usec>(); | |
98 | // // if (avail >= duration) | |
99 | // // { | |
100 | ||
101 | // // } | |
102 | // // } | |
103 | // } | |
104 | ||
105 | ||
106 | std::shared_ptr<msg::PcmChunk> Resampler::resample(const msg::PcmChunk& chunk) | |
107 | { | |
108 | #ifndef HAS_SOXR | |
109 | return std::make_shared<msg::PcmChunk>(chunk); | |
110 | #else | |
111 | if (!resamplingNeeded()) | |
112 | { | |
113 | return std::make_shared<msg::PcmChunk>(chunk); | |
114 | } | |
115 | else | |
116 | { | |
117 | if (in_format_.bits() == 24) | |
118 | { | |
119 | // sox expects 32 bit input, shift 8 bits left | |
120 | int32_t* frames = (int32_t*)chunk.payload; | |
121 | for (size_t n = 0; n < chunk.getSampleCount(); ++n) | |
122 | frames[n] = frames[n] << 8; | |
123 | } | |
124 | ||
125 | size_t idone; | |
126 | size_t odone; | |
127 | auto resample_buffer_framesize = resample_buffer_.size() / out_format_.frameSize(); | |
128 | auto error = soxr_process(soxr_, chunk.payload, chunk.getFrameCount(), &idone, resample_buffer_.data(), resample_buffer_framesize, &odone); | |
129 | if (error) | |
130 | { | |
131 | LOG(ERROR, LOG_TAG) << "Error soxr_process: " << error << "\n"; | |
132 | } | |
133 | else | |
134 | { | |
135 | LOG(TRACE, LOG_TAG) << "Resample idone: " << idone << "/" << chunk.getFrameCount() << ", odone: " << odone << "/" | |
136 | << resample_buffer_.size() / out_format_.frameSize() << ", delay: " << soxr_delay(soxr_) << "\n"; | |
137 | ||
138 | // some data has been resampled (odone frames) and some is still in the pipe (soxr_delay frames) | |
139 | if (odone > 0) | |
140 | { | |
141 | // get the resampled ts from the input ts | |
142 | auto input_end_ts = chunk.start() + chunk.duration<std::chrono::microseconds>(); | |
143 | double resampled_ms = (odone + soxr_delay(soxr_)) / out_format_.msRate(); | |
144 | auto resampled_start = input_end_ts - std::chrono::microseconds(static_cast<int>(resampled_ms * 1000.)); | |
145 | ||
146 | auto resampled_chunk = std::make_shared<msg::PcmChunk>(out_format_, 0); | |
147 | auto us = chrono::duration_cast<chrono::microseconds>(resampled_start.time_since_epoch()).count(); | |
148 | resampled_chunk->timestamp.sec = static_cast<int32_t>(us / 1000000); | |
149 | resampled_chunk->timestamp.usec = static_cast<int32_t>(us % 1000000); | |
150 | ||
151 | // copy from the resample_buffer to the resampled chunk | |
152 | resampled_chunk->payloadSize = static_cast<uint32_t>(odone * out_format_.frameSize()); | |
153 | resampled_chunk->payload = (char*)realloc(resampled_chunk->payload, resampled_chunk->payloadSize); | |
154 | memcpy(resampled_chunk->payload, resample_buffer_.data(), resampled_chunk->payloadSize); | |
155 | ||
156 | if (out_format_.bits() == 24) | |
157 | { | |
158 | // sox has quantized to 32 bit, shift 8 bits right | |
159 | int32_t* frames = (int32_t*)resampled_chunk->payload; | |
160 | for (size_t n = 0; n < resampled_chunk->getSampleCount(); ++n) | |
161 | { | |
162 | // +128 to round to the nearest so that quantisation steps are distributed evenly | |
163 | frames[n] = (frames[n] + 128) >> 8; | |
164 | if (frames[n] > 0x7fffffff) | |
165 | frames[n] = 0x7fffffff; | |
166 | } | |
167 | } | |
168 | ||
169 | // check if the resample_buffer is large enough, or if soxr was using all available space | |
170 | if (odone == resample_buffer_framesize) | |
171 | { | |
172 | // buffer for resampled data too small, add space for 5ms | |
173 | resample_buffer_.resize(resample_buffer_.size() + out_format_.frameSize() * static_cast<uint16_t>(ceil(out_format_.msRate() * 5))); | |
174 | LOG(DEBUG, LOG_TAG) << "Resample buffer completely filled, adding space for 5ms; new buffer size: " << resample_buffer_.size() | |
175 | << " bytes\n"; | |
176 | } | |
177 | ||
178 | // //LOG(TRACE, LOG_TAG) << "ts: " << out->timestamp.sec << "s, " << out->timestamp.usec/1000.f << " ms, duration: " << odone / format_.msRate() | |
179 | // << "\n"; | |
180 | // int64_t next_us = us + static_cast<int64_t>(odone / format_.msRate() * 1000); | |
181 | // LOG(TRACE, LOG_TAG) << "ts: " << us << ", next: " << next_us << ", diff: " << next_us_ - us << "\n"; | |
182 | // next_us_ = next_us; | |
183 | ||
184 | return resampled_chunk; | |
185 | } | |
186 | } | |
187 | } | |
188 | return nullptr; | |
189 | #endif | |
190 | } | |
191 | ||
192 | ||
193 | shared_ptr<msg::PcmChunk> Resampler::resample(shared_ptr<msg::PcmChunk> chunk) | |
194 | { | |
195 | #ifndef HAS_SOXR | |
196 | return chunk; | |
197 | #else | |
198 | if (!resamplingNeeded()) | |
199 | { | |
200 | return chunk; | |
201 | } | |
202 | else | |
203 | { | |
204 | return resample(*chunk); | |
205 | } | |
206 | #endif | |
207 | } | |
208 | ||
209 | ||
210 | Resampler::~Resampler() | |
211 | { | |
212 | #ifdef HAS_SOXR | |
213 | if (soxr_) | |
214 | soxr_delete(soxr_); | |
215 | #endif | |
216 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef RESAMPLER_HPP | |
19 | #define RESAMPLER_HPP | |
20 | ||
21 | #include "common/message/pcm_chunk.hpp" | |
22 | #include "common/sample_format.hpp" | |
23 | #include <deque> | |
24 | #include <vector> | |
25 | #ifdef HAS_SOXR | |
26 | #include <soxr.h> | |
27 | #endif | |
28 | ||
29 | ||
30 | class Resampler | |
31 | { | |
32 | public: | |
33 | Resampler(const SampleFormat& in_format, const SampleFormat& out_format); | |
34 | virtual ~Resampler(); | |
35 | ||
36 | // std::shared_ptr<msg::PcmChunk> resample(std::shared_ptr<msg::PcmChunk> chunk, chronos::usec duration); | |
37 | std::shared_ptr<msg::PcmChunk> resample(std::shared_ptr<msg::PcmChunk> chunk); | |
38 | std::shared_ptr<msg::PcmChunk> resample(const msg::PcmChunk& chunk); | |
39 | bool resamplingNeeded() const; | |
40 | ||
41 | private: | |
42 | std::vector<char> resample_buffer_; | |
43 | // std::unique_ptr<msg::PcmChunk> resampled_chunk_; | |
44 | SampleFormat in_format_; | |
45 | SampleFormat out_format_; | |
46 | #ifdef HAS_SOXR | |
47 | soxr_t soxr_; | |
48 | #endif | |
49 | }; | |
50 | ||
51 | #endif |
48 | 48 | } |
49 | 49 | |
50 | 50 | |
51 | string SampleFormat::getFormat() const | |
51 | string SampleFormat::toString() const | |
52 | 52 | { |
53 | 53 | stringstream ss; |
54 | 54 | ss << rate_ << ":" << bits_ << ":" << channels_; |
61 | 61 | std::vector<std::string> strs; |
62 | 62 | strs = utils::string::split(format, ':'); |
63 | 63 | if (strs.size() == 3) |
64 | setFormat(strs[0] == "*" ? 0 : cpt::stoul(strs[0]), strs[1] == "*" ? 0 : cpt::stoul(strs[1]), strs[2] == "*" ? 0 : cpt::stoul(strs[2])); | |
64 | setFormat(strs[0] == "*" ? 0 : cpt::stoul(strs[0]), strs[1] == "*" ? 0 : static_cast<uint16_t>(cpt::stoul(strs[1])), | |
65 | strs[2] == "*" ? 0 : static_cast<uint16_t>(cpt::stoul(strs[2]))); | |
65 | 66 | else |
66 | 67 | throw SnapException("sampleformat must be <rate>:<bits>:<channels>"); |
67 | 68 | } |
40 | 40 | SampleFormat(const std::string& format); |
41 | 41 | SampleFormat(uint32_t rate, uint16_t bits, uint16_t channels); |
42 | 42 | |
43 | std::string getFormat() const; | |
43 | std::string toString() const; | |
44 | 44 | |
45 | 45 | void setFormat(const std::string& format); |
46 | 46 | void setFormat(uint32_t rate, uint16_t bits, uint16_t channels); |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef SIGNAL_HANDLER_HPP | |
19 | #define SIGNAL_HANDLER_HPP | |
20 | ||
21 | #include <functional> | |
22 | #include <future> | |
23 | #include <set> | |
24 | #include <signal.h> | |
25 | ||
26 | using signal_callback = std::function<void(int signal, const std::string& name)>; | |
27 | ||
28 | static std::future<int> install_signal_handler(std::set<int> signals, const signal_callback& on_signal = nullptr) | |
29 | { | |
30 | static std::promise<int> promise; | |
31 | std::future<int> future = promise.get_future(); | |
32 | static signal_callback callback = on_signal; | |
33 | ||
34 | for (auto signal : signals) | |
35 | { | |
36 | ::signal(signal, [](int sig) { | |
37 | if (callback) | |
38 | callback(sig, strsignal(sig)); | |
39 | try | |
40 | { | |
41 | promise.set_value(sig); | |
42 | } | |
43 | catch (const std::future_error&) | |
44 | { | |
45 | } | |
46 | }); | |
47 | } | |
48 | return future; | |
49 | } | |
50 | ||
51 | #endif |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef SNAP_EXCEPTION_H | |
19 | #define SNAP_EXCEPTION_H | |
18 | #ifndef SNAP_EXCEPTION_HPP | |
19 | #define SNAP_EXCEPTION_HPP | |
20 | 20 | |
21 | 21 | #include <cstring> // std::strlen, std::strcpy |
22 | 22 | #include <exception> |
26 | 26 | class SnapException : public std::exception |
27 | 27 | { |
28 | 28 | std::string text_; |
29 | int error_code_; | |
29 | 30 | |
30 | 31 | public: |
31 | SnapException(const char* text) : text_(text) | |
32 | SnapException(const char* text, int error_code = 0) : text_(text), error_code_(error_code) | |
32 | 33 | { |
33 | 34 | } |
34 | 35 | |
35 | SnapException(const std::string& text) : SnapException(text.c_str()) | |
36 | SnapException(const std::string& text, int error_code = 0) : SnapException(text.c_str(), error_code) | |
36 | 37 | { |
37 | 38 | } |
38 | 39 | |
39 | 40 | ~SnapException() throw() override = default; |
41 | ||
42 | int code() const noexcept | |
43 | { | |
44 | return error_code_; | |
45 | } | |
40 | 46 | |
41 | 47 | const char* what() const noexcept override |
42 | 48 | { |
45 | 51 | }; |
46 | 52 | |
47 | 53 | |
48 | ||
49 | class AsyncSnapException : public SnapException | |
50 | { | |
51 | public: | |
52 | AsyncSnapException(const char* text) : SnapException(text) | |
53 | { | |
54 | } | |
55 | ||
56 | AsyncSnapException(const std::string& text) : SnapException(text) | |
57 | { | |
58 | } | |
59 | ||
60 | ~AsyncSnapException() throw() override = default; | |
61 | }; | |
62 | ||
63 | ||
64 | ||
65 | 54 | #endif |
19 | 19 | #define TIME_DEFS_H |
20 | 20 | |
21 | 21 | #include <chrono> |
22 | #include <sys/time.h> | |
23 | 22 | #include <thread> |
24 | 23 | #ifdef MACOS |
25 | 24 | #include <mach/clock.h> |
26 | 25 | #include <mach/mach.h> |
27 | 26 | #endif |
28 | 27 | |
28 | #ifndef WINDOWS | |
29 | #include <sys/time.h> | |
30 | #else | |
31 | #include <Windows.h> | |
32 | #include <stdint.h> | |
33 | #include <winsock2.h> | |
34 | #endif | |
35 | ||
29 | 36 | namespace chronos |
30 | 37 | { |
31 | using clk = std::chrono::steady_clock; | |
38 | using clk = | |
39 | #ifndef WINDOWS | |
40 | std::chrono::steady_clock; | |
41 | #else | |
42 | std::chrono::system_clock; | |
43 | #endif | |
32 | 44 | using time_point_clk = std::chrono::time_point<clk>; |
33 | 45 | using sec = std::chrono::seconds; |
34 | 46 | using msec = std::chrono::milliseconds; |
40 | 52 | { |
41 | 53 | auto now = Clock::now(); |
42 | 54 | auto microsecs = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()); |
43 | tv->tv_sec = microsecs.count() / 1000000; | |
44 | tv->tv_usec = microsecs.count() % 1000000; | |
55 | tv->tv_sec = static_cast<long>(microsecs.count() / 1000000); | |
56 | tv->tv_usec = static_cast<long>(microsecs.count() % 1000000); | |
45 | 57 | } |
58 | ||
59 | #ifdef WINDOWS | |
60 | // Implementation from http://stackoverflow.com/a/26085827/2510022 | |
61 | inline static int gettimeofday(struct timeval* tp, struct timezone* tzp) | |
62 | { | |
63 | // Note: some broken versions only have 8 trailing zero's, the correct epoch has 9 trailing zero's | |
64 | static const uint64_t EPOCH = ((uint64_t)116444736000000000ULL); | |
65 | ||
66 | SYSTEMTIME system_time; | |
67 | FILETIME file_time; | |
68 | uint64_t time; | |
69 | ||
70 | GetSystemTime(&system_time); | |
71 | SystemTimeToFileTime(&system_time, &file_time); | |
72 | time = ((uint64_t)file_time.dwLowDateTime); | |
73 | time += ((uint64_t)file_time.dwHighDateTime) << 32; | |
74 | ||
75 | tp->tv_sec = (long)((time - EPOCH) / 10000000L); | |
76 | tp->tv_usec = (long)(system_time.wMilliseconds * 1000); | |
77 | return 0; | |
78 | } | |
79 | #endif | |
46 | 80 | |
47 | 81 | inline static void steadytimeofday(struct timeval* tv) |
48 | 82 | { |
83 | #ifndef WINDOWS | |
49 | 84 | timeofday<clk>(tv); |
85 | #else | |
86 | gettimeofday(tv, NULL); | |
87 | #endif | |
50 | 88 | } |
51 | 89 | |
52 | 90 | inline static void systemtimeofday(struct timeval* tv) |
67 | 105 | return std::chrono::duration_cast<ToDuration>(std::chrono::seconds(sec) + std::chrono::microseconds(usec)); |
68 | 106 | } |
69 | 107 | |
70 | ||
71 | inline static void addUs(timeval& tv, int us) | |
72 | { | |
73 | if (us < 0) | |
74 | { | |
75 | timeval t; | |
76 | t.tv_sec = -us / 1000000; | |
77 | t.tv_usec = (-us % 1000000); | |
78 | timersub(&tv, &t, &tv); | |
79 | return; | |
80 | } | |
81 | tv.tv_usec += us; | |
82 | tv.tv_sec += (tv.tv_usec / 1000000); | |
83 | tv.tv_usec %= 1000000; | |
84 | } | |
85 | ||
86 | 108 | inline static long getTickCount() |
87 | 109 | { |
88 | #ifdef MACOS | |
110 | #if defined(MACOS) | |
89 | 111 | clock_serv_t cclock; |
90 | 112 | mach_timespec_t mts; |
91 | 113 | host_get_clock_service(mach_host_self(), SYSTEM_CLOCK, &cclock); |
92 | 114 | clock_get_time(cclock, &mts); |
93 | 115 | mach_port_deallocate(mach_task_self(), cclock); |
94 | 116 | return mts.tv_sec * 1000 + mts.tv_nsec / 1000000; |
117 | #elif defined(WINDOWS) | |
118 | return getTickCount(); | |
95 | 119 | #else |
96 | 120 | struct timespec now; |
97 | 121 | clock_gettime(CLOCK_MONOTONIC, &now); |
20 | 20 | |
21 | 21 | #include "string_utils.hpp" |
22 | 22 | #include <fstream> |
23 | #ifndef WINDOWS | |
23 | 24 | #include <grp.h> |
24 | 25 | #include <pwd.h> |
26 | #endif | |
25 | 27 | #include <stdexcept> |
26 | 28 | #include <vector> |
27 | 29 | |
38 | 40 | return infile.good(); |
39 | 41 | } |
40 | 42 | |
41 | ||
43 | #ifndef WINDOWS | |
42 | 44 | static void do_chown(const std::string& file_path, const std::string& user_name, const std::string& group_name) |
43 | 45 | { |
44 | 46 | if (user_name.empty() && group_name.empty()) |
87 | 89 | } |
88 | 90 | return res; |
89 | 91 | } |
90 | ||
92 | #endif | |
91 | 93 | } // namespace file |
92 | 94 | } // namespace utils |
93 | 95 |
19 | 19 | #define STRING_UTILS_H |
20 | 20 | |
21 | 21 | #include <algorithm> |
22 | #include <map> | |
22 | 23 | #include <sstream> |
23 | 24 | #include <stdio.h> |
24 | 25 | #include <string> |
25 | 26 | #include <vector> |
27 | #ifdef WINDOWS | |
28 | #include <cctype> | |
29 | #endif | |
26 | 30 | |
27 | 31 | namespace utils |
28 | 32 | { |
110 | 114 | } |
111 | 115 | |
112 | 116 | |
117 | static std::string split_left(const std::string& s, char delim, std::string& right) | |
118 | { | |
119 | std::string left; | |
120 | split_left(s, delim, left, right); | |
121 | return left; | |
122 | } | |
123 | ||
124 | ||
113 | 125 | |
114 | 126 | static std::vector<std::string>& split(const std::string& s, char delim, std::vector<std::string>& elems) |
115 | 127 | { |
130 | 142 | return elems; |
131 | 143 | } |
132 | 144 | |
145 | ||
146 | static std::map<std::string, std::string> split_pairs(const std::string& s, char pair_delim, char key_value_delim) | |
147 | { | |
148 | std::map<std::string, std::string> result; | |
149 | auto keyValueList = split(s, pair_delim); | |
150 | for (auto& kv : keyValueList) | |
151 | { | |
152 | auto pos = kv.find(key_value_delim); | |
153 | if (pos != std::string::npos) | |
154 | { | |
155 | std::string key = trim_copy(kv.substr(0, pos)); | |
156 | std::string value = trim_copy(kv.substr(pos + 1)); | |
157 | result[key] = value; | |
158 | } | |
159 | } | |
160 | return result; | |
161 | } | |
162 | ||
163 | ||
133 | 164 | } // namespace string |
134 | 165 | } // namespace utils |
135 | 166 |
23 | 23 | |
24 | 24 | #include <cctype> |
25 | 25 | #include <cerrno> |
26 | // #include <chrono> | |
26 | 27 | #include <cstring> |
27 | 28 | #include <fstream> |
28 | 29 | #include <functional> |
29 | 30 | #include <iomanip> |
30 | #include <iomanip> | |
31 | 31 | #include <iterator> |
32 | 32 | #include <locale> |
33 | 33 | #include <memory> |
34 | #ifndef WINDOWS | |
34 | 35 | #include <net/if.h> |
35 | 36 | #include <netinet/in.h> |
37 | #include <sys/ioctl.h> | |
38 | #include <sys/utsname.h> | |
39 | #include <unistd.h> | |
40 | #endif | |
36 | 41 | #include <sstream> |
37 | 42 | #include <string> |
38 | #include <sys/ioctl.h> | |
39 | 43 | #include <sys/stat.h> |
40 | 44 | #include <sys/types.h> |
41 | #include <unistd.h> | |
42 | 45 | #include <vector> |
43 | #ifndef FREEBSD | |
46 | #if !defined(WINDOWS) && !defined(FREEBSD) | |
44 | 47 | #include <sys/sysinfo.h> |
45 | 48 | #endif |
46 | #include <sys/utsname.h> | |
47 | 49 | #ifdef MACOS |
48 | 50 | #include <IOKit/IOCFPlugIn.h> |
49 | 51 | #include <IOKit/IOTypes.h> |
53 | 55 | #ifdef ANDROID |
54 | 56 | #include <sys/system_properties.h> |
55 | 57 | #endif |
58 | #ifdef WINDOWS | |
59 | #include <chrono> | |
60 | #include <direct.h> | |
61 | #include <iphlpapi.h> | |
62 | #include <versionhelpers.h> | |
63 | #include <windows.h> | |
64 | #include <winsock2.h> | |
65 | #endif | |
56 | 66 | |
57 | 67 | |
58 | 68 | namespace strutils = utils::string; |
59 | 69 | |
60 | 70 | |
61 | ||
71 | #ifndef WINDOWS | |
62 | 72 | static std::string execGetOutput(const std::string& cmd) |
63 | 73 | { |
64 | 74 | std::shared_ptr<FILE> pipe(popen((cmd + " 2> /dev/null").c_str(), "r"), pclose); |
73 | 83 | } |
74 | 84 | return strutils::trim(result); |
75 | 85 | } |
86 | #endif | |
76 | 87 | |
77 | 88 | |
78 | 89 | #ifdef ANDROID |
89 | 100 | |
90 | 101 | static std::string getOS() |
91 | 102 | { |
92 | std::string os; | |
103 | static std::string os(""); | |
104 | ||
105 | if (!os.empty()) | |
106 | return os; | |
107 | ||
93 | 108 | #ifdef ANDROID |
94 | 109 | os = strutils::trim_copy("Android " + getProp("ro.build.version.release")); |
110 | #elif WINDOWS | |
111 | if (/*IsWindows10OrGreater()*/ FALSE) | |
112 | os = "Windows 10"; | |
113 | else if (IsWindows8Point1OrGreater()) | |
114 | os = "Windows 8.1"; | |
115 | else if (IsWindows8OrGreater()) | |
116 | os = "Windows 8"; | |
117 | else if (IsWindows7SP1OrGreater()) | |
118 | os = "Windows 7 SP1"; | |
119 | else if (IsWindows7OrGreater()) | |
120 | os = "Windows 7"; | |
121 | else if (IsWindowsVistaSP2OrGreater()) | |
122 | os = "Windows Vista SP2"; | |
123 | else if (IsWindowsVistaSP1OrGreater()) | |
124 | os = "Windows Vista SP1"; | |
125 | else if (IsWindowsVistaOrGreater()) | |
126 | os = "Windows Vista"; | |
127 | else if (IsWindowsXPSP3OrGreater()) | |
128 | os = "Windows XP SP3"; | |
129 | else if (IsWindowsXPSP2OrGreater()) | |
130 | os = "Windows XP SP2"; | |
131 | else if (IsWindowsXPSP1OrGreater()) | |
132 | os = "Windows XP SP1"; | |
133 | else if (IsWindowsXPOrGreater()) | |
134 | os = "Windows XP"; | |
135 | else | |
136 | os = "Unknown Windows"; | |
95 | 137 | #else |
96 | 138 | os = execGetOutput("lsb_release -d"); |
97 | 139 | if ((os.find(":") != std::string::npos) && (os.find("lsb_release") == std::string::npos)) |
98 | 140 | os = strutils::trim_copy(os.substr(os.find(":") + 1)); |
99 | 141 | #endif |
142 | ||
143 | #ifndef WINDOWS | |
100 | 144 | if (os.empty()) |
101 | 145 | { |
102 | 146 | os = strutils::trim_copy(execGetOutput("grep /etc/os-release /etc/openwrt_release -e PRETTY_NAME -e DISTRIB_DESCRIPTION")); |
113 | 157 | uname(&u); |
114 | 158 | os = u.sysname; |
115 | 159 | } |
116 | return strutils::trim_copy(os); | |
160 | #endif | |
161 | strutils::trim(os); | |
162 | return os; | |
117 | 163 | } |
118 | 164 | |
119 | 165 | |
142 | 188 | if (!arch.empty()) |
143 | 189 | return arch; |
144 | 190 | #endif |
191 | #ifndef WINDOWS | |
145 | 192 | arch = execGetOutput("arch"); |
146 | 193 | if (arch.empty()) |
147 | 194 | arch = execGetOutput("uname -i"); |
148 | 195 | if (arch.empty() || (arch == "unknown")) |
149 | 196 | arch = execGetOutput("uname -m"); |
197 | #else | |
198 | SYSTEM_INFO sysInfo; | |
199 | GetSystemInfo(&sysInfo); | |
200 | switch (sysInfo.wProcessorArchitecture) | |
201 | { | |
202 | case PROCESSOR_ARCHITECTURE_AMD64: | |
203 | arch = "amd64"; | |
204 | break; | |
205 | ||
206 | case PROCESSOR_ARCHITECTURE_ARM: | |
207 | arch = "arm"; | |
208 | break; | |
209 | ||
210 | case PROCESSOR_ARCHITECTURE_IA64: | |
211 | arch = "ia64"; | |
212 | break; | |
213 | ||
214 | case PROCESSOR_ARCHITECTURE_INTEL: | |
215 | arch = "intel"; | |
216 | break; | |
217 | ||
218 | default: | |
219 | case PROCESSOR_ARCHITECTURE_UNKNOWN: | |
220 | arch = "unknown"; | |
221 | break; | |
222 | } | |
223 | #endif | |
150 | 224 | return strutils::trim_copy(arch); |
151 | 225 | } |
152 | 226 | |
153 | ||
154 | static long uptime() | |
155 | { | |
156 | #ifndef FREEBSD | |
157 | struct sysinfo info; | |
158 | sysinfo(&info); | |
159 | return info.uptime; | |
160 | #else | |
161 | std::string uptime = execGetOutput("sysctl kern.boottime"); | |
162 | if ((uptime.find(" sec = ") != std::string::npos) && (uptime.find(",") != std::string::npos)) | |
163 | { | |
164 | uptime = strutils::trim_copy(uptime.substr(uptime.find(" sec = ") + 7)); | |
165 | uptime.resize(uptime.find(",")); | |
166 | timeval now; | |
167 | gettimeofday(&now, NULL); | |
168 | try | |
169 | { | |
170 | return now.tv_sec - cpt::stoul(uptime); | |
171 | } | |
172 | catch (...) | |
173 | { | |
174 | } | |
175 | } | |
176 | return 0; | |
177 | #endif | |
178 | } | |
227 | // Seems not to be used | |
228 | // static std::chrono::seconds uptime() | |
229 | // { | |
230 | // #ifndef WINDOWS | |
231 | // #ifndef FREEBSD | |
232 | // struct sysinfo info; | |
233 | // sysinfo(&info); | |
234 | // return std::chrono::seconds(info.uptime); | |
235 | // #else | |
236 | // std::string uptime = execGetOutput("sysctl kern.boottime"); | |
237 | // if ((uptime.find(" sec = ") != std::string::npos) && (uptime.find(",") != std::string::npos)) | |
238 | // { | |
239 | // uptime = strutils::trim_copy(uptime.substr(uptime.find(" sec = ") + 7)); | |
240 | // uptime.resize(uptime.find(",")); | |
241 | // timeval now; | |
242 | // gettimeofday(&now, NULL); | |
243 | // try | |
244 | // { | |
245 | // return std::chrono::seconds(now.tv_sec - cpt::stoul(uptime)); | |
246 | // } | |
247 | // catch (...) | |
248 | // { | |
249 | // } | |
250 | // } | |
251 | // return 0s; | |
252 | // #endif | |
253 | // #else | |
254 | // return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::milliseconds(GetTickCount())); | |
255 | // #endif | |
256 | // } | |
179 | 257 | |
180 | 258 | |
181 | 259 | /// http://stackoverflow.com/questions/2174768/generating-random-uuids-in-linux |
184 | 262 | static bool initialized(false); |
185 | 263 | if (!initialized) |
186 | 264 | { |
187 | std::srand(std::time(nullptr)); | |
265 | std::srand(static_cast<unsigned int>(std::time(nullptr))); | |
188 | 266 | initialized = true; |
189 | 267 | } |
190 | 268 | std::stringstream ss; |
195 | 273 | } |
196 | 274 | |
197 | 275 | |
276 | #ifndef WINDOWS | |
198 | 277 | /// https://gist.github.com/OrangeTide/909204 |
199 | 278 | static std::string getMacAddress(int sock) |
200 | 279 | { |
300 | 379 | #endif |
301 | 380 | return mac; |
302 | 381 | } |
303 | ||
382 | #else | |
383 | static std::string getMacAddress(const std::string& address) | |
384 | { | |
385 | IP_ADAPTER_INFO* first; | |
386 | IP_ADAPTER_INFO* pos; | |
387 | ULONG bufferLength = sizeof(IP_ADAPTER_INFO); | |
388 | first = (IP_ADAPTER_INFO*)malloc(bufferLength); | |
389 | ||
390 | if (GetAdaptersInfo(first, &bufferLength) == ERROR_BUFFER_OVERFLOW) | |
391 | { | |
392 | free(first); | |
393 | first = (IP_ADAPTER_INFO*)malloc(bufferLength); | |
394 | } | |
395 | ||
396 | char mac[19]; | |
397 | if (GetAdaptersInfo(first, &bufferLength) == NO_ERROR) | |
398 | for (pos = first; pos != NULL; pos = pos->Next) | |
399 | { | |
400 | IP_ADDR_STRING* firstAddr = &pos->IpAddressList; | |
401 | IP_ADDR_STRING* posAddr; | |
402 | for (posAddr = firstAddr; posAddr != NULL; posAddr = posAddr->Next) | |
403 | if (_stricmp(posAddr->IpAddress.String, address.c_str()) == 0) | |
404 | { | |
405 | sprintf(mac, "%02x:%02x:%02x:%02x:%02x:%02x", pos->Address[0], pos->Address[1], pos->Address[2], pos->Address[3], pos->Address[4], | |
406 | pos->Address[5]); | |
407 | ||
408 | free(first); | |
409 | return mac; | |
410 | } | |
411 | } | |
412 | else | |
413 | free(first); | |
414 | ||
415 | return mac; | |
416 | } | |
417 | #endif | |
304 | 418 | |
305 | 419 | static std::string getHostId(const std::string defaultId = "") |
306 | 420 | { |
8 | 8 | ```bash |
9 | 9 | curl --header "Content-Type: application/json" --request POST --data '{"id":1, "jsonrpc":"2.0", "method": "Server.GetStatus"}' http://127.0.0.1:1780/jsonrpc | jq .result.server.groups[].clients[].config.name |
10 | 10 | ``` |
11 | ||
12 | ### Set latency | |
13 | ||
14 | ```bash | |
15 | curl --header "Content-Type: application/json" --request POST --data '{"id":7,"jsonrpc":"2.0","method":"Client.SetLatency","params":{"id":"713aefd7-e6cb-4c3b-9f5e-91bbcf9bcbc2","latency":10}}' http://127.0.0.1:1780/jsonrpc | |
16 | ``` | |
17 | ||
18 | ### Remove all disconnected clients | |
19 | ```bash | |
20 | curl -s -H "Content-Type: application/json" -d '{"id":1, "jsonrpc":"2.0", "method": "Server.GetStatus"}' http://127.0.0.1:1780/jsonrpc | jq '.result.server.groups[].clients[] | select(.connected==false) .id' | while read ln; do curl -s -H "Content-Type: application/json" -d '{"id":1, "jsonrpc":"2.0", "method": "Server.DeleteClient", "params": {"id":'$ln'}}' http://127.0.0.1:1780/jsonrpc; done | |
21 | ``` |
0 | snapcast (0.22.0-1) unstable; urgency=medium | |
1 | ||
2 | * Features | |
3 | -Server: Add Meta stream source (Issue #402, #569, #666) | |
4 | -Client: Add file audio backend (Issue #681) | |
5 | * Bugfixes | |
6 | -Add missing define for alsa stream to makefile (Issue #692) | |
7 | -Fix playback when plugging the headset on Android (Issue #699) | |
8 | -Server discards old chunks if not consumed (Issue #708) | |
9 | * General | |
10 | -Less verbose logging during pipe reconnects (Issue #696) | |
11 | -Add null encoder for streams used only as input for meta streams | |
12 | -Snapweb: Change latency range to [-10s, 10s] (Issue #695) | |
13 | -Update Snapweb, including PR #11, #12, #13, Issues #16, #17 | |
14 | ||
15 | -- Johannes Pohl <snapcast@badaix.de> Thu, 15 Oct 2020 00:13:37 +0200 | |
16 | ||
17 | snapcast (0.21.0-1) unstable; urgency=medium | |
18 | ||
19 | * Features | |
20 | -Server: Support for WebSocket streaming clients | |
21 | -Server: Install Snapweb web client (Issue #579) | |
22 | -Server: Resample input to 48000:16:2 when using opus codec | |
23 | -Server: Add Alsa stream source | |
24 | * Bugfixes | |
25 | -make install will setup the snapserver home dir (Issue #643) | |
26 | -Client retries to open a blocked alsa device (Issue #652) | |
27 | * General | |
28 | -debian packag generation switched from make to CMake buildsystem | |
29 | -Reintroduce MACOS define, hopefully not breaking anything on macOS | |
30 | -Snapcast uses GitHub actions for automated CI/CD | |
31 | -CMake installs man files (Issue #507) | |
32 | -Update documentation (Issue #615, #617) | |
33 | ||
34 | -- Johannes Pohl <snapcast@badaix.de> Sun, 13 Sep 2020 00:13:37 +0200 | |
35 | ||
36 | snapcast (0.20.0-1) unstable; urgency=medium | |
37 | ||
38 | * Features | |
39 | -Client: Windows support (Issue #24) | |
40 | -Client: add hardware mixer (Issue #318) | |
41 | -Client: add "script" and "none" mixer (Issue #302) | |
42 | -Client: add sharingmode for audio device (if supported) | |
43 | -Logging: configurable sink and filters (Issue #30, #561, #122, #559) | |
44 | -Librespot: add option "disable-audio-cache=[false|true]" | |
45 | * Bugfixes | |
46 | -Fix build failure on FreeBSD (Issue #565) | |
47 | -Fix calling lsb_release multiple times (Issue #470) | |
48 | -Client: high CPU load and crash during playback (Issue #609, #628) | |
49 | -Client: improved handling of USB audio disconnects (Issue #64) | |
50 | -Client: latency is forgotten (Issue #476, #588, Snapdroid #11) | |
51 | -Client: fix segfault on mac when playback is paused (Issue #560) | |
52 | -Client: fix bonjour on mac reports empty IP (Issue #632) | |
53 | -Client: fix buzzing tone on Android (Issue #23, #24) | |
54 | -Server: fix crash if client disconnects during connect (Issue #639) | |
55 | -Server: fix reading metadata from shairport-sync (Issue #624) | |
56 | -Server: fix crash on FreeBSD if settings.json is empty (Issue #620) | |
57 | -Server: fix warning about unknown command line options (Issue #635) | |
58 | -Readme: openWrt documentation (Issue #633) | |
59 | -Fix setting the daemon's process priority (PR #448) | |
60 | * General | |
61 | -Client: use less threads and thus less ressources | |
62 | -Update links to xiph externals (Issue #637, PR #616) | |
63 | ||
64 | -- Johannes Pohl <snapcast@badaix.de> Sat, 13 Jun 2020 00:13:37 +0200 | |
65 | ||
0 | 66 | snapcast (0.19.0-1) unstable; urgency=medium |
1 | 67 | |
2 | 68 | * Features |
4 | 4 | include /usr/share/dpkg/buildflags.mk |
5 | 5 | |
6 | 6 | %: |
7 | dh $@ | |
7 | dh $@ --buildsystem=cmake | |
8 | 8 | |
9 | override_dh_auto_build: | |
10 | dh_auto_build -- STRIP=: DEBUG= ADD_CFLAGS="$(CXXFLAGS) $(CPPFLAGS)" ADD_LDFLAGS="$(LDFLAGS)" | |
9 | override_dh_auto_configure: | |
10 | dh_auto_configure -- $(CMAKEFLAGS) | |
11 | 11 | |
12 | override_dh_auto_install: | |
13 | dh_auto_install --sourcedir=client --destdir=debian/snapclient/ | |
14 | dh_auto_install --sourcedir=server --destdir=debian/snapserver/ | |
12 | #override_dh_auto_build: | |
13 | # dh_auto_build -- STRIP=: DEBUG= ADD_CFLAGS="$(CXXFLAGS) $(CPPFLAGS)" ADD_LDFLAGS="$(LDFLAGS)" |
25 | 25 | |
26 | 26 | # Read configuration variable file if it is present |
27 | 27 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME |
28 | SNAPCLIENT_OPTS="--daemon $SNAPCLIENT_OPTS" | |
28 | SNAPCLIENT_OPTS="--daemon --logsink=system --user $USERNAME:$USERNAME $SNAPCLIENT_OPTS" | |
29 | 29 | |
30 | 30 | if [ "$START_SNAPCLIENT" != "true" ] ; then |
31 | 31 | exit 0 |
0 | usr/bin/snapclient usr/bin/⏎ |
0 | client/snapclient.1⏎ |
5 | 5 | |
6 | 6 | [Service] |
7 | 7 | EnvironmentFile=-/etc/default/snapclient |
8 | ExecStart=/usr/bin/snapclient $SNAPCLIENT_OPTS | |
8 | ExecStart=/usr/bin/snapclient --logsink=system $SNAPCLIENT_OPTS | |
9 | 9 | User=snapclient |
10 | 10 | Group=snapclient |
11 | # very noisy on stdout | |
12 | StandardOutput=null | |
13 | 11 | Restart=on-failure |
14 | 12 | |
15 | 13 | [Install] |
25 | 25 | |
26 | 26 | # Read configuration variable file if it is present |
27 | 27 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME |
28 | SNAPSERVER_OPTS="--daemon $SNAPSERVER_OPTS" | |
28 | SNAPSERVER_OPTS="--daemon --logging.sink=system --server.datadir=$USERNAME $SNAPSERVER_OPTS" | |
29 | 29 | |
30 | 30 | if [ "$START_SNAPSERVER" != "true" ] ; then |
31 | 31 | exit 0 |
0 | server/snapserver.1⏎ |
5 | 5 | HOMEDIR=/var/lib/snapserver |
6 | 6 | |
7 | 7 | if [ "$1" = configure ]; then |
8 | adduser --system --quiet --group --home $HOMEDIR --no-create-home --force-badname $USERNAME | |
8 | if ! getent passwd $USERNAME >/dev/null; then | |
9 | adduser --system --quiet --group --home $HOMEDIR --no-create-home --force-badname $USERNAME | |
10 | # adduser $USERNAME audio | |
11 | fi | |
12 | adduser --quiet $USERNAME audio > /dev/null || true | |
9 | 13 | |
10 | 14 | if [ ! -d $HOMEDIR ]; then |
11 | 15 | mkdir -m 0750 $HOMEDIR |
5 | 5 | |
6 | 6 | [Service] |
7 | 7 | EnvironmentFile=-/etc/default/snapserver |
8 | ExecStart=/usr/bin/snapserver $SNAPSERVER_OPTS | |
8 | ExecStart=/usr/bin/snapserver --logging.sink=system --server.datadir=${HOME} $SNAPSERVER_OPTS | |
9 | 9 | User=snapserver |
10 | 10 | Group=snapserver |
11 | 11 | Restart=on-failure |
Binary diff not shown
0 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | |
1 | <!-- Created with Inkscape (http://www.inkscape.org/) --> | |
2 | ||
3 | <svg | |
4 | xmlns:dc="http://purl.org/dc/elements/1.1/" | |
5 | xmlns:cc="http://creativecommons.org/ns#" | |
6 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | |
7 | xmlns:svg="http://www.w3.org/2000/svg" | |
8 | xmlns="http://www.w3.org/2000/svg" | |
9 | xmlns:xlink="http://www.w3.org/1999/xlink" | |
10 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | |
11 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | |
12 | width="250mm" | |
13 | height="200mm" | |
14 | viewBox="0 0 885.82677 708.66141" | |
15 | id="svg2" | |
16 | version="1.1" | |
17 | inkscape:version="0.91 r13725" | |
18 | sodipodi:docname="Overview.svg" | |
19 | inkscape:export-filename="Overview.png" | |
20 | inkscape:export-xdpi="90" | |
21 | inkscape:export-ydpi="90"> | |
22 | <defs | |
23 | id="defs4"> | |
24 | <marker | |
25 | inkscape:stockid="Arrow2Mend" | |
26 | orient="auto" | |
27 | refY="0" | |
28 | refX="0" | |
29 | id="Arrow2Mend" | |
30 | style="overflow:visible" | |
31 | inkscape:isstock="true"> | |
32 | <path | |
33 | id="path4169" | |
34 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" | |
35 | d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" | |
36 | transform="scale(-0.6,-0.6)" | |
37 | inkscape:connector-curvature="0" /> | |
38 | </marker> | |
39 | <marker | |
40 | inkscape:stockid="Arrow1Send" | |
41 | orient="auto" | |
42 | refY="0" | |
43 | refX="0" | |
44 | id="Arrow1Send" | |
45 | style="overflow:visible" | |
46 | inkscape:isstock="true"> | |
47 | <path | |
48 | id="path4157" | |
49 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
50 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1" | |
51 | transform="matrix(-0.2,0,0,-0.2,-1.2,0)" | |
52 | inkscape:connector-curvature="0" /> | |
53 | </marker> | |
54 | <marker | |
55 | inkscape:stockid="Arrow1Mstart" | |
56 | orient="auto" | |
57 | refY="0" | |
58 | refX="0" | |
59 | id="Arrow1Mstart" | |
60 | style="overflow:visible" | |
61 | inkscape:isstock="true"> | |
62 | <path | |
63 | id="path4148" | |
64 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
65 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
66 | transform="matrix(0.4,0,0,0.4,4,0)" | |
67 | inkscape:connector-curvature="0" /> | |
68 | </marker> | |
69 | <marker | |
70 | inkscape:stockid="Arrow1Lend" | |
71 | orient="auto" | |
72 | refY="0" | |
73 | refX="0" | |
74 | id="marker4570" | |
75 | style="overflow:visible" | |
76 | inkscape:isstock="true"> | |
77 | <path | |
78 | id="path4572" | |
79 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
80 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1" | |
81 | transform="matrix(-0.8,0,0,-0.8,-10,0)" | |
82 | inkscape:connector-curvature="0" /> | |
83 | </marker> | |
84 | <marker | |
85 | inkscape:stockid="Arrow1Lstart" | |
86 | orient="auto" | |
87 | refY="0" | |
88 | refX="0" | |
89 | id="marker4566" | |
90 | style="overflow:visible" | |
91 | inkscape:isstock="true"> | |
92 | <path | |
93 | id="path4568" | |
94 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
95 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
96 | transform="matrix(0.8,0,0,0.8,10,0)" | |
97 | inkscape:connector-curvature="0" /> | |
98 | </marker> | |
99 | <marker | |
100 | inkscape:stockid="Arrow1Lstart" | |
101 | orient="auto" | |
102 | refY="0" | |
103 | refX="0" | |
104 | id="marker4520" | |
105 | style="overflow:visible" | |
106 | inkscape:isstock="true"> | |
107 | <path | |
108 | id="path4522" | |
109 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
110 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
111 | transform="matrix(0.8,0,0,0.8,10,0)" | |
112 | inkscape:connector-curvature="0" /> | |
113 | </marker> | |
114 | <marker | |
115 | inkscape:stockid="Arrow1Lstart" | |
116 | orient="auto" | |
117 | refY="0" | |
118 | refX="0" | |
119 | id="marker4480" | |
120 | style="overflow:visible" | |
121 | inkscape:isstock="true"> | |
122 | <path | |
123 | id="path4482" | |
124 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
125 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
126 | transform="matrix(0.8,0,0,0.8,10,0)" | |
127 | inkscape:connector-curvature="0" /> | |
128 | </marker> | |
129 | <marker | |
130 | inkscape:stockid="Arrow1Lstart" | |
131 | orient="auto" | |
132 | refY="0" | |
133 | refX="0" | |
134 | id="marker4446" | |
135 | style="overflow:visible" | |
136 | inkscape:isstock="true"> | |
137 | <path | |
138 | id="path4448" | |
139 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
140 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
141 | transform="matrix(0.8,0,0,0.8,10,0)" | |
142 | inkscape:connector-curvature="0" /> | |
143 | </marker> | |
144 | <marker | |
145 | inkscape:stockid="Arrow1Lend" | |
146 | orient="auto" | |
147 | refY="0" | |
148 | refX="0" | |
149 | id="marker4418" | |
150 | style="overflow:visible" | |
151 | inkscape:isstock="true"> | |
152 | <path | |
153 | id="path4420" | |
154 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
155 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1" | |
156 | transform="matrix(-0.8,0,0,-0.8,-10,0)" | |
157 | inkscape:connector-curvature="0" /> | |
158 | </marker> | |
159 | <marker | |
160 | inkscape:stockid="Arrow1Lstart" | |
161 | orient="auto" | |
162 | refY="0" | |
163 | refX="0" | |
164 | id="marker4414" | |
165 | style="overflow:visible" | |
166 | inkscape:isstock="true"> | |
167 | <path | |
168 | id="path4416" | |
169 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
170 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
171 | transform="matrix(0.8,0,0,0.8,10,0)" | |
172 | inkscape:connector-curvature="0" /> | |
173 | </marker> | |
174 | <marker | |
175 | inkscape:stockid="Arrow1Lend" | |
176 | orient="auto" | |
177 | refY="0" | |
178 | refX="0" | |
179 | id="Arrow1Lend" | |
180 | style="overflow:visible" | |
181 | inkscape:isstock="true"> | |
182 | <path | |
183 | id="path4145" | |
184 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
185 | style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt" | |
186 | transform="matrix(-0.8,0,0,-0.8,-10,0)" | |
187 | inkscape:connector-curvature="0" /> | |
188 | </marker> | |
189 | <marker | |
190 | inkscape:stockid="Arrow1Lstart" | |
191 | orient="auto" | |
192 | refY="0" | |
193 | refX="0" | |
194 | id="Arrow1Lstart" | |
195 | style="overflow:visible" | |
196 | inkscape:isstock="true"> | |
197 | <path | |
198 | id="path4142" | |
199 | d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z" | |
200 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1" | |
201 | transform="matrix(0.8,0,0,0.8,10,0)" | |
202 | inkscape:connector-curvature="0" /> | |
203 | </marker> | |
204 | <marker | |
205 | inkscape:stockid="Arrow2Mend" | |
206 | orient="auto" | |
207 | refY="0" | |
208 | refX="0" | |
209 | id="Arrow2Mend-6" | |
210 | style="overflow:visible" | |
211 | inkscape:isstock="true"> | |
212 | <path | |
213 | inkscape:connector-curvature="0" | |
214 | id="path4169-1" | |
215 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" | |
216 | d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" | |
217 | transform="scale(-0.6,-0.6)" /> | |
218 | </marker> | |
219 | <marker | |
220 | inkscape:stockid="Arrow2Mend" | |
221 | orient="auto" | |
222 | refY="0" | |
223 | refX="0" | |
224 | id="Arrow2Mend-0" | |
225 | style="overflow:visible" | |
226 | inkscape:isstock="true"> | |
227 | <path | |
228 | inkscape:connector-curvature="0" | |
229 | id="path4169-2" | |
230 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" | |
231 | d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" | |
232 | transform="scale(-0.6,-0.6)" /> | |
233 | </marker> | |
234 | <marker | |
235 | inkscape:stockid="Arrow2Mend" | |
236 | orient="auto" | |
237 | refY="0" | |
238 | refX="0" | |
239 | id="Arrow2Mend-0-6" | |
240 | style="overflow:visible" | |
241 | inkscape:isstock="true"> | |
242 | <path | |
243 | inkscape:connector-curvature="0" | |
244 | id="path4169-2-8" | |
245 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" | |
246 | d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" | |
247 | transform="scale(-0.6,-0.6)" /> | |
248 | </marker> | |
249 | <marker | |
250 | inkscape:stockid="Arrow2Mend" | |
251 | orient="auto" | |
252 | refY="0" | |
253 | refX="0" | |
254 | id="Arrow2Mend-0-9" | |
255 | style="overflow:visible" | |
256 | inkscape:isstock="true"> | |
257 | <path | |
258 | inkscape:connector-curvature="0" | |
259 | id="path4169-2-5" | |
260 | style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" | |
261 | d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" | |
262 | transform="scale(-0.6,-0.6)" /> | |
263 | </marker> | |
264 | </defs> | |
265 | <sodipodi:namedview | |
266 | id="base" | |
267 | pagecolor="#ffffff" | |
268 | bordercolor="#666666" | |
269 | borderopacity="1.0" | |
270 | inkscape:pageopacity="0.0" | |
271 | inkscape:pageshadow="2" | |
272 | inkscape:zoom="1" | |
273 | inkscape:cx="431.85737" | |
274 | inkscape:cy="354.9223" | |
275 | inkscape:document-units="px" | |
276 | inkscape:current-layer="layer1" | |
277 | showgrid="true" | |
278 | inkscape:snap-page="false" | |
279 | inkscape:snap-object-midpoints="false" | |
280 | inkscape:snap-others="false" | |
281 | inkscape:snap-nodes="false" | |
282 | inkscape:snap-center="false" | |
283 | inkscape:snap-midpoints="true" | |
284 | inkscape:object-paths="true" | |
285 | inkscape:window-width="1440" | |
286 | inkscape:window-height="847" | |
287 | inkscape:window-x="0" | |
288 | inkscape:window-y="0" | |
289 | inkscape:window-maximized="1" | |
290 | inkscape:snap-bbox="true" | |
291 | inkscape:bbox-nodes="true" | |
292 | inkscape:bbox-paths="true"> | |
293 | <inkscape:grid | |
294 | type="xygrid" | |
295 | id="grid5639" /> | |
296 | </sodipodi:namedview> | |
297 | <metadata | |
298 | id="metadata7"> | |
299 | <rdf:RDF> | |
300 | <cc:Work | |
301 | rdf:about=""> | |
302 | <dc:format>image/svg+xml</dc:format> | |
303 | <dc:type | |
304 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | |
305 | <dc:title /> | |
306 | </cc:Work> | |
307 | </rdf:RDF> | |
308 | </metadata> | |
309 | <g | |
310 | inkscape:label="Ebene 1" | |
311 | inkscape:groupmode="layer" | |
312 | id="layer1" | |
313 | transform="translate(0,-343.70079)"> | |
314 | <rect | |
315 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:12.12900639;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
316 | id="rect4136" | |
317 | width="327.871" | |
318 | height="227.871" | |
319 | x="46.064503" | |
320 | y="458.4267" /> | |
321 | <text | |
322 | xml:space="preserve" | |
323 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
324 | x="54.564339" | |
325 | y="479.50381" | |
326 | id="text4636" | |
327 | sodipodi:linespacing="125%"><tspan | |
328 | sodipodi:role="line" | |
329 | id="tspan4638" | |
330 | x="54.564339" | |
331 | y="479.50381" | |
332 | style="font-size:15px">Server</tspan></text> | |
333 | <rect | |
334 | style="fill:#ffd42a;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:4.96148014;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
335 | id="rect4640" | |
336 | width="115.15193" | |
337 | height="182.91074" | |
338 | x="237.36732" | |
339 | y="486.97073" /> | |
340 | <text | |
341 | xml:space="preserve" | |
342 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
343 | x="252.7113" | |
344 | y="507.64835" | |
345 | id="text4636-3" | |
346 | sodipodi:linespacing="125%"><tspan | |
347 | sodipodi:role="line" | |
348 | x="252.7113" | |
349 | y="507.64835" | |
350 | id="tspan4660">Snapserver</tspan></text> | |
351 | <rect | |
352 | style="fill:#c2e4ff;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
353 | id="rect4640-7" | |
354 | width="79.999985" | |
355 | height="39.999989" | |
356 | x="53.76844" | |
357 | y="508.371" /> | |
358 | <text | |
359 | xml:space="preserve" | |
360 | style="font-style:normal;font-weight:normal;font-size:11.25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
361 | x="62.33633" | |
362 | y="544.72687" | |
363 | id="text4636-3-5" | |
364 | sodipodi:linespacing="125%" | |
365 | transform="scale(1.024182,0.97638896)"><tspan | |
366 | sodipodi:role="line" | |
367 | x="62.33633" | |
368 | y="544.72687" | |
369 | id="tspan4660-3">MPD/UPnP</tspan></text> | |
370 | <path | |
371 | style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend)" | |
372 | d="m 133.76844,528.37099 100,0" | |
373 | id="path5641" | |
374 | inkscape:connector-curvature="0" /> | |
375 | <text | |
376 | xml:space="preserve" | |
377 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
378 | x="149.5442" | |
379 | y="522.92725" | |
380 | id="text4636-3-5-5" | |
381 | sodipodi:linespacing="125%"><tspan | |
382 | sodipodi:role="line" | |
383 | x="149.5442" | |
384 | y="522.92725" | |
385 | id="tspan4660-3-6" | |
386 | style="font-size:11.25px">Alsa</tspan></text> | |
387 | <text | |
388 | xml:space="preserve" | |
389 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
390 | x="295.77576" | |
391 | y="336.91849" | |
392 | id="text4636-3-5-5-2" | |
393 | sodipodi:linespacing="125%"><tspan | |
394 | sodipodi:role="line" | |
395 | x="295.77576" | |
396 | y="336.91849" | |
397 | id="tspan4660-3-6-9" /></text> | |
398 | <rect | |
399 | style="fill:#c2e4ff;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
400 | id="rect4640-7-0" | |
401 | width="79.999985" | |
402 | height="39.999989" | |
403 | x="53.76844" | |
404 | y="568.37097" /> | |
405 | <text | |
406 | xml:space="preserve" | |
407 | style="font-style:normal;font-weight:normal;font-size:11.25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
408 | x="61.081356" | |
409 | y="605.70264" | |
410 | id="text4636-3-5-9" | |
411 | sodipodi:linespacing="125%" | |
412 | transform="scale(1.024182,0.97638896)"><tspan | |
413 | sodipodi:role="line" | |
414 | x="61.081356" | |
415 | y="605.70264" | |
416 | id="tspan4660-3-3">Airplay</tspan></text> | |
417 | <rect | |
418 | style="fill:#c2e4ff;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
419 | id="rect4640-7-6" | |
420 | width="79.999985" | |
421 | height="39.999989" | |
422 | x="53.76844" | |
423 | y="628.37097" /> | |
424 | <text | |
425 | xml:space="preserve" | |
426 | style="font-style:normal;font-weight:normal;font-size:11.25px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
427 | x="62.031673" | |
428 | y="667.15356" | |
429 | id="text4636-3-5-0" | |
430 | sodipodi:linespacing="125%" | |
431 | transform="scale(1.024182,0.97638896)"><tspan | |
432 | sodipodi:role="line" | |
433 | x="62.031673" | |
434 | y="667.15356" | |
435 | id="tspan4660-3-62">Spotify</tspan></text> | |
436 | <path | |
437 | style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend-6)" | |
438 | d="m 133.76844,588.37099 100,0" | |
439 | id="path5641-8" | |
440 | inkscape:connector-curvature="0" /> | |
441 | <text | |
442 | xml:space="preserve" | |
443 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
444 | x="149.5442" | |
445 | y="582.92725" | |
446 | id="text4636-3-5-5-7" | |
447 | sodipodi:linespacing="125%"><tspan | |
448 | sodipodi:role="line" | |
449 | x="149.5442" | |
450 | y="582.92725" | |
451 | id="tspan4660-3-6-92" | |
452 | style="font-size:11.25px">Named Pipe</tspan></text> | |
453 | <path | |
454 | style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend-0)" | |
455 | d="m 133.76844,648.37099 100,0" | |
456 | id="path5641-3" | |
457 | inkscape:connector-curvature="0" /> | |
458 | <text | |
459 | xml:space="preserve" | |
460 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
461 | x="149.5442" | |
462 | y="642.92725" | |
463 | id="text4636-3-5-5-75" | |
464 | sodipodi:linespacing="125%"><tspan | |
465 | sodipodi:role="line" | |
466 | x="149.5442" | |
467 | y="642.92725" | |
468 | id="tspan4660-3-6-922" | |
469 | style="font-size:11.25px">Stdout</tspan></text> | |
470 | <rect | |
471 | style="fill:#fafafa;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:3.05648112;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
472 | id="rect9040-7-0-9" | |
473 | width="379.35333" | |
474 | height="589.72687" | |
475 | x="460.70465" | |
476 | y="411.10712" /> | |
477 | <text | |
478 | xml:space="preserve" | |
479 | style="font-style:normal;font-weight:normal;font-size:21.74483299px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
480 | x="519.18927" | |
481 | y="399.26215" | |
482 | id="text4636-7-2-6-7" | |
483 | sodipodi:linespacing="125%" | |
484 | transform="scale(0.91929956,1.0877847)"><tspan | |
485 | sodipodi:role="line" | |
486 | id="tspan4638-1-7-1-7" | |
487 | x="519.18927" | |
488 | y="399.26215" | |
489 | style="font-size:16.30862617px">Streaming Clients</tspan><tspan | |
490 | sodipodi:role="line" | |
491 | x="519.18927" | |
492 | y="419.64795" | |
493 | style="font-size:16.30862617px" | |
494 | id="tspan9253" /></text> | |
495 | <rect | |
496 | style="fill:#eaeaea;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:2.02899432;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
497 | id="rect9040" | |
498 | width="337.97101" | |
499 | height="248.39276" | |
500 | x="481.0145" | |
501 | y="452.95496" /> | |
502 | <rect | |
503 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.74250174;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
504 | id="rect4136-8" | |
505 | width="133.25751" | |
506 | height="173.25751" | |
507 | x="503.37125" | |
508 | y="505.73346" /> | |
509 | <text | |
510 | xml:space="preserve" | |
511 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
512 | x="509.35513" | |
513 | y="526.17291" | |
514 | id="text4636-9" | |
515 | sodipodi:linespacing="125%"><tspan | |
516 | sodipodi:role="line" | |
517 | id="tspan4638-7" | |
518 | x="509.35513" | |
519 | y="526.17291" | |
520 | style="font-size:15px">Streaming Client</tspan></text> | |
521 | <text | |
522 | xml:space="preserve" | |
523 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
524 | x="-652.45984" | |
525 | y="264.49707" | |
526 | id="text4636-3-3" | |
527 | sodipodi:linespacing="125%" | |
528 | transform="matrix(0,-1,1,0,0,0)"><tspan | |
529 | sodipodi:role="line" | |
530 | x="-652.45984" | |
531 | y="264.49707" | |
532 | id="tspan4660-6">Audio capture</tspan></text> | |
533 | <text | |
534 | xml:space="preserve" | |
535 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
536 | x="-653.58899" | |
537 | y="284.49707" | |
538 | id="text4636-3-3-1" | |
539 | sodipodi:linespacing="125%" | |
540 | transform="matrix(0,-1,1,0,0,0)"><tspan | |
541 | sodipodi:role="line" | |
542 | x="-653.58899" | |
543 | y="284.49707" | |
544 | id="tspan6685">Encoding</tspan><tspan | |
545 | sodipodi:role="line" | |
546 | x="-653.58899" | |
547 | y="300.12207" | |
548 | id="tspan6740" /></text> | |
549 | <text | |
550 | xml:space="preserve" | |
551 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
552 | x="-652.32556" | |
553 | y="304.49707" | |
554 | id="text4636-3-3-1-9" | |
555 | sodipodi:linespacing="125%" | |
556 | transform="matrix(0,-1,1,0,0,0)"><tspan | |
557 | sodipodi:role="line" | |
558 | x="-652.32556" | |
559 | y="304.49707" | |
560 | id="tspan4660-6-2-3">Timestamping</tspan><tspan | |
561 | sodipodi:role="line" | |
562 | x="-652.32556" | |
563 | y="320.12207" | |
564 | id="tspan6708" /><tspan | |
565 | sodipodi:role="line" | |
566 | x="-652.32556" | |
567 | y="335.74707" | |
568 | id="tspan6685-1" /></text> | |
569 | <text | |
570 | xml:space="preserve" | |
571 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
572 | x="-653.58899" | |
573 | y="324.49707" | |
574 | id="text4636-3-3-1-9-9" | |
575 | sodipodi:linespacing="125%" | |
576 | transform="matrix(0,-1,1,0,0,0)" | |
577 | inkscape:transform-center-x="-31.112698" | |
578 | inkscape:transform-center-y="32.526912"><tspan | |
579 | sodipodi:role="line" | |
580 | x="-653.58899" | |
581 | y="324.49707" | |
582 | id="tspan6734">Routing</tspan><tspan | |
583 | sodipodi:role="line" | |
584 | x="-653.58899" | |
585 | y="340.12207" | |
586 | id="tspan6708-7" /><tspan | |
587 | sodipodi:role="line" | |
588 | x="-653.58899" | |
589 | y="355.74707" | |
590 | id="tspan6685-1-8" /></text> | |
591 | <rect | |
592 | style="fill:#ffd42a;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:5.12594557;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
593 | id="rect4640-1" | |
594 | width="100.19241" | |
595 | height="120.45921" | |
596 | x="517.24463" | |
597 | y="546.5191" /> | |
598 | <text | |
599 | xml:space="preserve" | |
600 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
601 | x="-653.58899" | |
602 | y="544.49707" | |
603 | id="text4636-3-3-1-0" | |
604 | sodipodi:linespacing="125%" | |
605 | transform="matrix(0,-1,1,0,0,0)"><tspan | |
606 | sodipodi:role="line" | |
607 | x="-653.58899" | |
608 | y="544.49707" | |
609 | id="tspan6685-6">Decoding</tspan><tspan | |
610 | sodipodi:role="line" | |
611 | x="-653.58899" | |
612 | y="560.12207" | |
613 | id="tspan6823" /><tspan | |
614 | sodipodi:role="line" | |
615 | x="-653.58899" | |
616 | y="575.74707" | |
617 | id="tspan6740-3" /></text> | |
618 | <text | |
619 | xml:space="preserve" | |
620 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
621 | x="-652.32556" | |
622 | y="564.49707" | |
623 | id="text4636-3-3-1-9-2" | |
624 | sodipodi:linespacing="125%" | |
625 | transform="matrix(0,-1,1,0,0,0)"><tspan | |
626 | sodipodi:role="line" | |
627 | x="-652.32556" | |
628 | y="564.49707" | |
629 | id="tspan4660-6-2-3-0">Time sync</tspan><tspan | |
630 | sodipodi:role="line" | |
631 | x="-652.32556" | |
632 | y="580.12207" | |
633 | id="tspan6708-6" /><tspan | |
634 | sodipodi:role="line" | |
635 | x="-652.32556" | |
636 | y="595.74707" | |
637 | id="tspan6685-1-1" /></text> | |
638 | <text | |
639 | xml:space="preserve" | |
640 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
641 | x="-653.58899" | |
642 | y="584.49707" | |
643 | id="text4636-3-3-1-9-9-5" | |
644 | sodipodi:linespacing="125%" | |
645 | transform="matrix(0,-1,1,0,0,0)" | |
646 | inkscape:transform-center-x="-31.112698" | |
647 | inkscape:transform-center-y="32.526912"><tspan | |
648 | sodipodi:role="line" | |
649 | x="-653.58899" | |
650 | y="584.49707" | |
651 | id="tspan6734-5">Playback</tspan><tspan | |
652 | sodipodi:role="line" | |
653 | x="-653.58899" | |
654 | y="600.12207" | |
655 | id="tspan6708-7-4" /><tspan | |
656 | sodipodi:role="line" | |
657 | x="-653.58899" | |
658 | y="615.74707" | |
659 | id="tspan6685-1-8-7" /></text> | |
660 | <text | |
661 | xml:space="preserve" | |
662 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
663 | x="531.25085" | |
664 | y="568.19257" | |
665 | id="text4636-3-6" | |
666 | sodipodi:linespacing="125%"><tspan | |
667 | sodipodi:role="line" | |
668 | x="531.25085" | |
669 | y="568.19257" | |
670 | id="tspan4660-5">Snapclient</tspan></text> | |
671 | <rect | |
672 | style="fill:#fafafa;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:2.91317511;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
673 | id="rect9040-7-0" | |
674 | width="352.08682" | |
675 | height="247.08682" | |
676 | x="41.456585" | |
677 | y="733.81879" /> | |
678 | <text | |
679 | xml:space="preserve" | |
680 | style="font-style:normal;font-weight:normal;font-size:21.74483299px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
681 | x="55.79063" | |
682 | y="698.11395" | |
683 | id="text4636-7-2-6" | |
684 | sodipodi:linespacing="125%" | |
685 | transform="scale(0.91929956,1.0877847)"><tspan | |
686 | sodipodi:role="line" | |
687 | id="tspan4638-1-7-1" | |
688 | x="55.79063" | |
689 | y="698.11395" | |
690 | style="font-size:16.30862617px">Control Clients</tspan></text> | |
691 | <rect | |
692 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.74250031;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
693 | id="rect4136-8-6" | |
694 | width="133.25743" | |
695 | height="173.25752" | |
696 | x="233.37128" | |
697 | y="785.73346" /> | |
698 | <text | |
699 | xml:space="preserve" | |
700 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
701 | x="244.68234" | |
702 | y="809.31561" | |
703 | id="text4636-9-9" | |
704 | sodipodi:linespacing="125%"><tspan | |
705 | sodipodi:role="line" | |
706 | id="tspan4638-7-3" | |
707 | x="244.68234" | |
708 | y="809.31561" | |
709 | style="font-size:15px">Control Client</tspan></text> | |
710 | <rect | |
711 | style="fill:#ffd42a;fill-opacity:1;fill-rule:evenodd;stroke:#607d8b;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
712 | id="rect4640-1-7" | |
713 | width="95.229828" | |
714 | height="120.58515" | |
715 | x="252.50888" | |
716 | y="825.59882" /> | |
717 | <text | |
718 | xml:space="preserve" | |
719 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
720 | x="264.75754" | |
721 | y="883.74207" | |
722 | id="text4636-3-3-1-0-4" | |
723 | sodipodi:linespacing="125%"><tspan | |
724 | sodipodi:role="line" | |
725 | x="264.75754" | |
726 | y="883.74207" | |
727 | id="tspan6685-6-5">Volume</tspan><tspan | |
728 | sodipodi:role="line" | |
729 | x="264.75754" | |
730 | y="899.36707" | |
731 | id="tspan6823-2" /><tspan | |
732 | sodipodi:role="line" | |
733 | x="264.75754" | |
734 | y="914.99207" | |
735 | id="tspan6740-3-5" /></text> | |
736 | <text | |
737 | xml:space="preserve" | |
738 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
739 | x="264.03122" | |
740 | y="847.31903" | |
741 | id="text4636-3-6-6" | |
742 | sodipodi:linespacing="125%"><tspan | |
743 | sodipodi:role="line" | |
744 | x="264.03122" | |
745 | y="847.31903" | |
746 | id="tspan4660-5-8">Snapcontrol</tspan></text> | |
747 | <text | |
748 | xml:space="preserve" | |
749 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
750 | x="264.75754" | |
751 | y="903.41614" | |
752 | id="text4636-3-3-1-0-4-8" | |
753 | sodipodi:linespacing="125%"><tspan | |
754 | sodipodi:role="line" | |
755 | x="264.75754" | |
756 | y="903.41614" | |
757 | id="tspan6685-6-5-4">Stream sel.</tspan><tspan | |
758 | sodipodi:role="line" | |
759 | x="264.75754" | |
760 | y="919.04114" | |
761 | id="tspan6823-2-3" /><tspan | |
762 | sodipodi:role="line" | |
763 | x="264.75754" | |
764 | y="934.66614" | |
765 | id="tspan6740-3-5-1" /></text> | |
766 | <text | |
767 | xml:space="preserve" | |
768 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
769 | x="264.75754" | |
770 | y="923.74207" | |
771 | id="text4636-3-3-1-0-4-8-4" | |
772 | sodipodi:linespacing="125%"><tspan | |
773 | sodipodi:role="line" | |
774 | x="264.75754" | |
775 | y="923.74207" | |
776 | id="tspan6685-6-5-4-9">Grouping</tspan><tspan | |
777 | sodipodi:role="line" | |
778 | x="264.75754" | |
779 | y="939.36707" | |
780 | id="tspan6823-2-3-2" /><tspan | |
781 | sodipodi:role="line" | |
782 | x="264.75754" | |
783 | y="954.99207" | |
784 | id="tspan6740-3-5-1-0" /></text> | |
785 | <path | |
786 | style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.03357601;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend-0-6)" | |
787 | d="m 295.26392,672.36221 0,103.38579" | |
788 | id="path5641-3-9" | |
789 | inkscape:connector-curvature="0" /> | |
790 | <text | |
791 | xml:space="preserve" | |
792 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
793 | x="691.54559" | |
794 | y="-302.9559" | |
795 | id="text4636-3-5-5-75-6" | |
796 | sodipodi:linespacing="125%" | |
797 | transform="matrix(0,1,-1,0,0,0)"><tspan | |
798 | sodipodi:role="line" | |
799 | x="691.54559" | |
800 | y="-302.9559" | |
801 | style="font-size:11.25px" | |
802 | id="tspan7843">Websocket</tspan></text> | |
803 | <path | |
804 | style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.49713778;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend-0-9)" | |
805 | d="m 356.7966,591.91913 155.89243,0" | |
806 | id="path5641-3-0" | |
807 | inkscape:connector-curvature="0" /> | |
808 | <text | |
809 | xml:space="preserve" | |
810 | style="font-style:normal;font-weight:normal;font-size:12.5px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
811 | x="388.93747" | |
812 | y="585.46649" | |
813 | id="text4636-3-5-5-75-4" | |
814 | sodipodi:linespacing="125%"><tspan | |
815 | sodipodi:role="line" | |
816 | x="388.93747" | |
817 | y="585.46649" | |
818 | id="tspan4660-3-6-922-8" | |
819 | style="font-size:11.25px">TCP or Websocket</tspan></text> | |
820 | <text | |
821 | xml:space="preserve" | |
822 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
823 | x="497.5925" | |
824 | y="473.05191" | |
825 | id="text4636-7" | |
826 | sodipodi:linespacing="125%"><tspan | |
827 | sodipodi:role="line" | |
828 | id="tspan4638-1" | |
829 | x="497.5925" | |
830 | y="473.05191" | |
831 | style="font-size:15px">Group: Spotify</tspan></text> | |
832 | <rect | |
833 | style="fill:#eaeaea;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:2.0320344;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
834 | id="rect9040-7" | |
835 | width="337.96796" | |
836 | height="248.39128" | |
837 | x="481.01602" | |
838 | y="732.9549" /> | |
839 | <text | |
840 | xml:space="preserve" | |
841 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
842 | x="498.58371" | |
843 | y="753.05194" | |
844 | id="text4636-7-2" | |
845 | sodipodi:linespacing="125%"><tspan | |
846 | sodipodi:role="line" | |
847 | id="tspan4638-1-7" | |
848 | x="498.58371" | |
849 | y="753.05194" | |
850 | style="font-size:15px">Group: MPD</tspan></text> | |
851 | <rect | |
852 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.74250174;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
853 | id="rect4136-8-2" | |
854 | width="133.25752" | |
855 | height="173.25751" | |
856 | x="663.37122" | |
857 | y="505.73346" /> | |
858 | <text | |
859 | xml:space="preserve" | |
860 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
861 | x="669.35522" | |
862 | y="525.63593" | |
863 | id="text4636-9-6" | |
864 | sodipodi:linespacing="125%"><tspan | |
865 | sodipodi:role="line" | |
866 | id="tspan4638-7-1" | |
867 | x="669.35522" | |
868 | y="525.63593" | |
869 | style="font-size:15px">Snapdroid</tspan></text> | |
870 | <path | |
871 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.74250174;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
872 | d="M 663.37109 442.0332 L 663.37109 615.29102 L 796.62891 615.29102 L 796.62891 442.0332 L 663.37109 442.0332 z " | |
873 | id="rect4136-8-2-6-5" | |
874 | transform="translate(0,343.70079)" /> | |
875 | <text | |
876 | xml:space="preserve" | |
877 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
878 | x="669.35522" | |
879 | y="805.63593" | |
880 | id="text4636-9-6-7-6" | |
881 | sodipodi:linespacing="125%"><tspan | |
882 | sodipodi:role="line" | |
883 | id="tspan4638-7-1-3-3" | |
884 | x="669.35522" | |
885 | y="805.63593" | |
886 | style="font-size:15px">Snapclient</tspan></text> | |
887 | <rect | |
888 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.78228188;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
889 | id="rect4136-8-2-6-9" | |
890 | width="134.86554" | |
891 | height="173.21773" | |
892 | x="63.39114" | |
893 | y="786.19641" /> | |
894 | <text | |
895 | xml:space="preserve" | |
896 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
897 | x="71.003052" | |
898 | y="806.07898" | |
899 | id="text4636-9-6-7-4" | |
900 | sodipodi:linespacing="125%"><tspan | |
901 | sodipodi:role="line" | |
902 | id="tspan4638-7-1-3-8" | |
903 | x="71.003052" | |
904 | y="806.07898" | |
905 | style="font-size:15px">Snapdroid /</tspan><tspan | |
906 | sodipodi:role="line" | |
907 | x="71.003052" | |
908 | y="824.82898" | |
909 | style="font-size:15px" | |
910 | id="tspan9672">Snapweb</tspan></text> | |
911 | <image | |
912 | y="832.36218" | |
913 | x="77.333366" | |
914 | id="image9137-6-1" | |
915 | xlink:href="data:image/png;base64,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 " | |
916 | preserveAspectRatio="none" | |
917 | height="119.99996" | |
918 | width="106.66663" /> | |
919 | <rect | |
920 | style="fill:#c8c8c8;fill-opacity:1;fill-rule:evenodd;stroke:#c8c8c8;stroke-width:6.78228188;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" | |
921 | id="rect4136-8-2-6-9-2" | |
922 | width="134.86554" | |
923 | height="173.21773" | |
924 | x="501.74332" | |
925 | y="785.75336" /> | |
926 | <text | |
927 | xml:space="preserve" | |
928 | style="font-style:normal;font-weight:normal;font-size:20px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" | |
929 | x="509.35522" | |
930 | y="805.63593" | |
931 | id="text4636-9-6-7-4-9" | |
932 | sodipodi:linespacing="125%"><tspan | |
933 | sodipodi:role="line" | |
934 | id="tspan4638-7-1-3-8-3" | |
935 | x="509.35522" | |
936 | y="805.63593" | |
937 | style="font-size:15px">Snapdroid /</tspan><tspan | |
938 | sodipodi:role="line" | |
939 | x="509.35522" | |
940 | y="824.38593" | |
941 | style="font-size:15px" | |
942 | id="tspan9672-9">Snapweb</tspan></text> | |
943 | <image | |
944 | y="831.91913" | |
945 | x="515.68555" | |
946 | id="image9137-6-1-0" | |
947 | xlink:href="data:image/png;base64,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 " | |
948 | preserveAspectRatio="none" | |
949 | height="119.99996" | |
950 | width="106.66663" /> | |
951 | <image | |
952 | y="898.36926" | |
953 | x="664.93994" | |
954 | id="image4293" | |
955 | xlink:href="data:image/png;base64,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 | |
1172 | " | |
1173 | preserveAspectRatio="none" | |
1174 | height="58.992924" | |
1175 | width="55.060062" /> | |
1176 | <image | |
1177 | y="857.36218" | |
1178 | x="708.86682" | |
1179 | id="image4304" | |
1180 | xlink:href="data:image/png;base64,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 | |
1413 | gg== | |
1414 | " | |
1415 | preserveAspectRatio="none" | |
1416 | height="45.671844" | |
1417 | width="46.133179" /> | |
1418 | <image | |
1419 | y="807.36218" | |
1420 | x="745" | |
1421 | id="image4315" | |
1422 | xlink:href="data:image/png;base64,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 | |
1460 | " | |
1461 | preserveAspectRatio="none" | |
1462 | height="58.431087" | |
1463 | width="49.441689" /> | |
1464 | <image | |
1465 | y="902.7403" | |
1466 | x="740" | |
1467 | id="image4337" | |
1468 | xlink:href="data:image/png;base64,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 | |
1497 | RU5ErkJggg== | |
1498 | " | |
1499 | preserveAspectRatio="none" | |
1500 | height="55.621899" | |
1501 | width="55.621899" /> | |
1502 | <image | |
1503 | y="809.04144" | |
1504 | x="665" | |
1505 | id="image4349" | |
1506 | xlink:href="data:image/png;base64,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 | |
1715 | ||
1716 | " | |
1717 | preserveAspectRatio="none" | |
1718 | height="58.320763" | |
1719 | width="49.219746" /> | |
1720 | <image | |
1721 | y="548.36224" | |
1722 | x="676.33337" | |
1723 | id="image9137-6-1-0-3" | |
1724 | xlink:href="data:image/png;base64,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 " | |
1725 | preserveAspectRatio="none" | |
1726 | height="119.99996" | |
1727 | width="106.66663" /> | |
1728 | </g> | |
1729 | </svg> |
0 | 0 | # Build Snapcast |
1 | ||
1 | 2 | Clone the Snapcast repository. To do this, you need git. |
2 | 3 | For Debian derivates (e.g. Raspbian, Debian, Ubuntu, Mint): |
3 | 4 | |
4 | $ sudo apt-get install git | |
5 | ```sh | |
6 | sudo apt-get install git | |
7 | ``` | |
5 | 8 | |
6 | 9 | For Arch derivates: |
7 | 10 | |
8 | $ sudo pacman -S git | |
11 | ```sh | |
12 | sudo pacman -S git | |
13 | ``` | |
9 | 14 | |
10 | 15 | For FreeBSD: |
11 | 16 | |
12 | $ sudo pkg install git | |
17 | ```sh | |
18 | sudo pkg install git | |
19 | ``` | |
13 | 20 | |
14 | 21 | Clone Snapcast: |
15 | 22 | |
16 | $ git clone https://github.com/badaix/snapcast.git | |
23 | ```sh | |
24 | git clone https://github.com/badaix/snapcast.git | |
25 | ``` | |
17 | 26 | |
18 | 27 | this creates a directory `snapcast`, in the following referred to as `<snapcast dir>`. |
19 | 28 | Next clone the external submodules: |
20 | 29 | |
21 | $ cd <snapcast dir>/externals | |
22 | $ git submodule update --init --recursive | |
30 | ```sh | |
31 | cd <snapcast dir>/externals | |
32 | git submodule update --init --recursive | |
33 | ``` | |
23 | 34 | |
24 | 35 | Snapcast depends on boost 1.70 or higher. Since it depends on header only boost libs, boost does not need to be installed, but the boost include path must be set properly: download and extract the latest boost version and add the include path, e.g. calling `make` with prepended `ADD_CFLAGS`: `ADD_CFLAGS="-I/path/to/boost_1_7x_0/" make`. |
25 | 36 | For `cmake` you must add the path to the `-DBOOST_ROOT` flag: `cmake -DBOOST_ROOT=/path/to/boost_1_7x_0` |
26 | 37 | |
27 | 38 | ## Linux (Native) |
39 | ||
28 | 40 | Install the build tools and required libs: |
29 | 41 | For Debian derivates (e.g. Raspbian, Debian, Ubuntu, Mint): |
30 | 42 | |
31 | $ sudo apt-get install build-essential | |
32 | $ sudo apt-get install libasound2-dev libvorbisidec-dev libvorbis-dev libopus-dev libflac-dev libsoxr-dev alsa-utils libavahi-client-dev avahi-daemon expat | |
43 | ```sh | |
44 | sudo apt-get install build-essential | |
45 | sudo apt-get install libasound2-dev libvorbisidec-dev libvorbis-dev libopus-dev libflac-dev libsoxr-dev alsa-utils libavahi-client-dev avahi-daemon libexpat1-dev | |
46 | ``` | |
33 | 47 | |
34 | 48 | Compilation requires gcc 4.8 or higher, so it's highly recommended to use Debian (Raspbian) Jessie. |
35 | 49 | |
36 | 50 | For Arch derivates: |
37 | 51 | |
38 | $ sudo pacman -S base-devel | |
39 | $ sudo pacman -S alsa-lib avahi libvorbis opus-dev flac libsoxr alsa-utils boost expat | |
52 | ```sh | |
53 | sudo pacman -S base-devel | |
54 | sudo pacman -S alsa-lib avahi libvorbis opus-dev flac libsoxr alsa-utils boost expat | |
55 | ``` | |
40 | 56 | |
41 | 57 | For Fedora (and probably RHEL, CentOS, & Scientific Linux, but untested): |
42 | 58 | |
43 | $ sudo dnf install @development-tools | |
44 | $ sudo dnf install alsa-lib-devel avahi-devel libvorbis-devel opus-devel flac-devel soxr-devel libstdc++-static expat | |
59 | ```sh | |
60 | sudo dnf install @development-tools | |
61 | sudo dnf install alsa-lib-devel avahi-devel libvorbis-devel opus-devel flac-devel soxr-devel libstdc++-static expat boost-devel | |
62 | ``` | |
45 | 63 | |
46 | 64 | ### Build Snapclient and Snapserver |
65 | ||
47 | 66 | `cd` into the Snapcast src-root directory: |
48 | 67 | |
49 | $ cd <snapcast dir> | |
50 | $ make | |
68 | ```sh | |
69 | cd <snapcast dir> | |
70 | make | |
71 | ``` | |
51 | 72 | |
52 | 73 | Install Snapclient and/or Snapserver: |
53 | 74 | |
54 | $ sudo make installserver | |
55 | $ sudo make installclient | |
75 | ```sh | |
76 | sudo make installserver | |
77 | sudo make installclient | |
78 | ``` | |
56 | 79 | |
57 | 80 | This will copy the client and/or server binary to `/usr/bin` and update init.d/systemd to start the client/server as a daemon. |
58 | 81 | |
59 | 82 | ### Build Snapclient |
83 | ||
60 | 84 | `cd` into the Snapclient src-root directory: |
61 | 85 | |
62 | $ cd <snapcast dir>/client | |
63 | $ make | |
86 | ```sh | |
87 | cd <snapcast dir>/client | |
88 | make | |
89 | ``` | |
64 | 90 | |
65 | 91 | Install Snapclient |
66 | 92 | |
67 | $ sudo make install | |
93 | ```sh | |
94 | sudo make install | |
95 | ``` | |
68 | 96 | |
69 | 97 | This will copy the client binary to `/usr/bin` and update init.d/systemd to start the client as a daemon. |
70 | 98 | |
71 | 99 | ### Build Snapserver |
100 | ||
72 | 101 | `cd` into the Snapserver src-root directory: |
73 | 102 | |
74 | $ cd <snapcast dir>/server | |
75 | $ make | |
103 | ```sh | |
104 | cd <snapcast dir>/server | |
105 | make | |
106 | ``` | |
76 | 107 | |
77 | 108 | Install Snapserver |
78 | 109 | |
79 | $ sudo make install | |
110 | ```sh | |
111 | sudo make install | |
112 | ``` | |
80 | 113 | |
81 | 114 | This will copy the server binary to `/usr/bin` and update init.d/systemd to start the server as a daemon. |
82 | 115 | |
84 | 117 | |
85 | 118 | Debian packages can be made with |
86 | 119 | |
87 | $ sudo apt-get install debhelper | |
88 | $ cd <snapcast dir> | |
89 | $ fakeroot make -f debian/rules binary | |
120 | ```sh | |
121 | sudo apt-get install debhelper | |
122 | cd <snapcast dir> | |
123 | fakeroot make -f debian/rules binary | |
124 | ``` | |
90 | 125 | |
91 | 126 | If you don't have boost installed or in your standard include paths, you can call |
92 | ||
93 | $ fakeroot make -f debian/rules CPPFLAGS="-I/path/to/boost_1_7x_0" binary | |
127 | ||
128 | ```sh | |
129 | fakeroot make -f debian/rules CPPFLAGS="-I/path/to/boost_1_7x_0" binary | |
130 | ``` | |
94 | 131 | |
95 | 132 | ## FreeBSD (Native) |
133 | ||
96 | 134 | Install the build tools and required libs: |
97 | 135 | |
98 | $ sudo pkg install gmake gcc bash avahi libogg libvorbis libopus flac libsoxr | |
136 | ```sh | |
137 | sudo pkg install gmake gcc bash avahi libogg libvorbis libopus flac libsoxr | |
138 | ``` | |
99 | 139 | |
100 | 140 | ### Build Snapserver |
141 | ||
101 | 142 | `cd` into the Snapserver src-root directory: |
102 | 143 | |
103 | $ cd <snapcast dir>/server | |
104 | $ gmake TARGET=FREEBSD | |
144 | ```sh | |
145 | cd <snapcast dir>/server | |
146 | gmake TARGET=FREEBSD | |
147 | ``` | |
105 | 148 | |
106 | 149 | Install Snapserver |
107 | 150 | |
108 | $ sudo gmake TARGET=FREEBSD install | |
151 | ```sh | |
152 | sudo gmake TARGET=FREEBSD install | |
153 | ``` | |
109 | 154 | |
110 | 155 | This will copy the server binary to `/usr/local/bin` and the startup script to `/usr/local/etc/rc.d/snapserver`. To enable the Snapserver, add this line to `/etc/rc.conf`: |
111 | 156 | |
112 | snapserver_enable="YES" | |
157 | ```ini | |
158 | snapserver_enable="YES" | |
159 | ``` | |
113 | 160 | |
114 | 161 | For additional command line arguments, add in `/etc/rc.conf`: |
115 | 162 | |
116 | snapserver_opts="<your custom options>" | |
163 | ```ini | |
164 | snapserver_opts="<your custom options>" | |
165 | ``` | |
117 | 166 | |
118 | 167 | Start and stop the server with `sudo service snapserver start` and `sudo service snapserver stop`. |
119 | 168 | |
120 | ||
121 | 169 | ## Gentoo (native) |
122 | 170 | |
123 | 171 | Snapcast is available under Gentoo's [Portage](https://wiki.gentoo.org/wiki/Portage) package management system. Portage utilises `USE` flags to determine what components are built on compilation. The availabe options are... |
124 | 172 | |
125 | equery u snapcast | |
126 | [ Legend : U - final flag setting for installation] | |
127 | [ : I - package is installed with flag ] | |
128 | [ Colors : set, unset ] | |
129 | * Found these USE flags for media-sound/snapcast-9999: | |
130 | U I | |
131 | + - avahi : Build with avahi support | |
132 | + + client : Build and install Snapcast client component | |
133 | + - flac : Build with FLAC compression support | |
134 | + + server : Build and install Snapcast server component | |
135 | - - static-libs : Build static libs | |
136 | - - tremor : Build with TREMOR version of vorbis | |
137 | + - vorbis : Build with libvorbis support | |
138 | ||
173 | ```sh | |
174 | equery u snapcast | |
175 | [ Legend : U - final flag setting for installation] | |
176 | [ : I - package is installed with flag ] | |
177 | [ Colors : set, unset ] | |
178 | * Found these USE flags for media-sound/snapcast-9999: | |
179 | U I | |
180 | + - avahi : Build with avahi support | |
181 | + + client : Build and install Snapcast client component | |
182 | + - flac : Build with FLAC compression support | |
183 | + + server : Build and install Snapcast server component | |
184 | - - static-libs : Build static libs | |
185 | - - tremor : Build with TREMOR version of vorbis | |
186 | + - vorbis : Build with libvorbis support | |
187 | ``` | |
139 | 188 | |
140 | 189 | These can be set either in the [global configuration](https://wiki.gentoo.org/wiki//etc/portage/make.conf#USE) file `/etc/portage/make.conf` or on a per-package basis (as root): |
141 | 190 | |
142 | if [ ! -d "$DIRECTORY" ]; then | |
143 | mkdir /etc/portage/package.use/media-sound | |
144 | fi | |
145 | echo 'media-sound/snapcast client server flac | |
191 | ```sh | |
192 | if [ ! -d "$DIRECTORY" ]; then | |
193 | mkdir /etc/portage/package.use/media-sound | |
194 | fi | |
195 | echo 'media-sound/snapcast client server flac | |
196 | ``` | |
146 | 197 | |
147 | 198 | If for example you only wish to build the server and *not* the client then precede the server `USE` flag with `-` i.e. |
148 | 199 | |
149 | echo 'media-sound/snapcast client -server | |
200 | ```sh | |
201 | echo 'media-sound/snapcast client -server | |
202 | ``` | |
150 | 203 | |
151 | 204 | Once `USE` flags are configured emerge snapcast as root: |
152 | 205 | |
153 | $ emerge -av snapcast | |
154 | ||
206 | ```sh | |
207 | emerge -av snapcast | |
208 | ``` | |
155 | 209 | |
156 | 210 | Starting the client or server depends on whether you are using `systemd` or `openrc`. To start using `openrc`: |
157 | 211 | |
158 | /etc/init.d/snapclient start | |
159 | /etc/init.d/snapserver start | |
212 | ```sh | |
213 | /etc/init.d/snapclient start | |
214 | /etc/init.d/snapserver start | |
215 | ``` | |
160 | 216 | |
161 | 217 | To enable the serve and client to start under the default run-level: |
162 | 218 | |
163 | rc-update add snapserver default | |
164 | rc-update add snapclient default | |
165 | ||
219 | ```sh | |
220 | rc-update add snapserver default | |
221 | rc-update add snapclient default | |
222 | ``` | |
166 | 223 | |
167 | 224 | ## macOS (Native) |
168 | 225 | |
169 | *Warning: macOS support is experimental* | |
226 | *Warning:* macOS support is experimental | |
170 | 227 | |
171 | 228 | 1. Install Xcode from the App Store |
172 | 229 | 2. Install [Homebrew](http://brew.sh) |
173 | 230 | 3. Install the required libs |
174 | 231 | |
175 | ``` | |
176 | $ brew install flac libsoxr libvorbis boost opus | |
232 | ```ssh | |
233 | brew install flac libsoxr libvorbis boost opus | |
177 | 234 | ``` |
178 | 235 | |
179 | 236 | ### Build Snapclient |
237 | ||
180 | 238 | `cd` into the Snapclient src-root directory: |
181 | 239 | |
182 | $ cd <snapcast dir>/client | |
183 | $ make TARGET=MACOS | |
240 | ```sh | |
241 | cd <snapcast dir>/client | |
242 | make TARGET=MACOS | |
243 | ``` | |
184 | 244 | |
185 | 245 | Install Snapclient |
186 | 246 | |
187 | $ sudo make install TARGET=MACOS | |
247 | ```sh | |
248 | sudo make install TARGET=MACOS | |
249 | ``` | |
188 | 250 | |
189 | 251 | This will copy the client binary to `/usr/local/bin` and create a Launch Agent to start the client as a daemon. |
190 | 252 | |
191 | 253 | ### Build Snapserver |
254 | ||
192 | 255 | `cd` into the Snapserver src-root directory: |
193 | 256 | |
194 | $ cd <snapcast dir>/server | |
195 | $ make TARGET=MACOS | |
257 | ```sh | |
258 | cd <snapcast dir>/server | |
259 | make TARGET=MACOS | |
260 | ``` | |
196 | 261 | |
197 | 262 | Install Snapserver |
198 | 263 | |
199 | $ sudo make install TARGET=MACOS | |
264 | ```sh | |
265 | sudo make install TARGET=MACOS | |
266 | ``` | |
200 | 267 | |
201 | 268 | This will copy the server binary to `/usr/local/bin` and create a Launch Agent to start the server as a daemon. |
202 | 269 | |
203 | 270 | ## Android (Cross compile) |
204 | Cross compilation for Android is done with the [Android NDK](http://developer.android.com/tools/sdk/ndk/index.html) on a Linux host machine. | |
271 | ||
272 | Cross compilation for Android is done with the [Android NDK](http://developer.android.com/tools/sdk/ndk/index.html) on a Linux host machine. | |
205 | 273 | |
206 | 274 | ### Android NDK setup |
207 | http://developer.android.com/ndk/guides/standalone_toolchain.html | |
208 | 1. Download NDK: `https://dl.google.com/android/repository/android-ndk-r17b-linux-x86_64.zip` | |
209 | 2. Extract to: `/SOME/LOCAL/PATH/android-ndk-r17b` | |
210 | 3. Setup toolchains for arm and x86 somewhere in your home dir (`<android-ndk dir>`): | |
211 | ||
212 | ``` | |
213 | $ cd /SOME/LOCAL/PATH/android-ndk-r17/build/tools | |
214 | $ ./make_standalone_toolchain.py --arch arm --api 16 --stl libc++ --install-dir <android-ndk dir>-arm | |
215 | $ ./make_standalone_toolchain.py --arch arm64 --api 21 --stl libc++ --install-dir <android-ndk dir>-arm64 | |
216 | $ ./make_standalone_toolchain.py --arch x86 --api 16 --stl libc++ --install-dir <android-ndk dir>-x86 | |
217 | ``` | |
275 | ||
276 | Install the Android [NDK toolchain](http://developer.android.com/ndk/guides/standalone_toolchain.html) | |
277 | ||
278 | 1. Download NDK: `https://dl.google.com/android/repository/android-ndk-r21d-linux-x86_64.zip` | |
279 | 2. Extract to: `/SOME/LOCAL/PATH/android-ndk-r21d` | |
218 | 280 | |
219 | 281 | ### Build Snapclient |
282 | ||
220 | 283 | Cross compile and install FLAC, opus, ogg, and tremor (only needed once): |
221 | 284 | |
222 | $ cd <snapcast dir>/externals | |
223 | $ make NDK_DIR=<android-ndk dir>-arm ARCH=arm | |
224 | $ make NDK_DIR=<android-ndk dir>-arm64 ARCH=aarch64 | |
225 | $ make NDK_DIR=<android-ndk dir>-x86 ARCH=x86 | |
226 | ||
285 | ```sh | |
286 | cd <snapcast dir>/externals | |
287 | make NDK_DIR=<android-ndk dir> ARCH=arm | |
288 | make NDK_DIR=<android-ndk dir> ARCH=aarch64 | |
289 | make NDK_DIR=<android-ndk dir> ARCH=x86 | |
290 | ``` | |
291 | ||
227 | 292 | Compile the Snapclient: |
228 | 293 | |
229 | $ cd <snapcast dir>/client | |
230 | $ ./build_android_all.sh <android-ndk dir> <snapdroid jniLibs dir> | |
294 | ```sh | |
295 | cd <snapcast dir>/client | |
296 | ./build_android.sh <android-ndk dir> <snapdroid jniLibs dir> | |
297 | ``` | |
231 | 298 | |
232 | 299 | The binaries for `armeabi`, `arm64-v8a` and `x86` will be copied into the Android's jniLibs directory (`<snapdroid jniLibs dir>/`) and so will be bundled with the Snapcast App. |
233 | 300 | |
234 | ||
235 | 301 | ## OpenWrt/LEDE (Cross compile) |
236 | Cross compilation for OpenWrt is done with the [OpenWrt build system](https://wiki.openwrt.org/about/toolchain) on a Linux host machine: | |
237 | https://wiki.openwrt.org/doc/howto/build | |
238 | ||
239 | For LEDE: | |
240 | https://lede-project.org/docs/guide-developer/quickstart-build-images | |
241 | ||
242 | ### OpenWrt/LEDE build system setup | |
243 | https://wiki.openwrt.org/doc/howto/buildroot.exigence | |
244 | ||
245 | Clone OpenWrt to some place in your home directory (`<buildroot dir>`) | |
246 | ||
247 | $ git clone git://git.openwrt.org/15.05/openwrt.git | |
248 | ||
249 | ...LEDE | |
250 | ||
251 | $ git clone https://git.lede-project.org/source.git | |
252 | ||
253 | Download and install available feeds | |
254 | ||
255 | $ cd <buildroot dir> | |
256 | $ ./scripts/feeds update -a | |
257 | $ ./scripts/feeds install -a | |
258 | ||
259 | Within the `<buildroot dir>` directory create symbolic links to the Snapcast source directory `<snapcast source>` and to the OpenWrt Makefile: | |
260 | ||
261 | $ mkdir -p <buildroot dir>/package/sxx/snapcast | |
262 | $ cd <buildroot dir>/package/sxx/snapcast | |
263 | $ ln -s <snapcast source> src | |
264 | $ ln -s <snapcast source>/openWrt/Makefile.openwrt Makefile | |
265 | ||
266 | Build | |
267 | in menuconfig in `sxx/snapcast` select `Compile snapserver` and/or `Compile snapclient` | |
268 | ||
269 | $ cd <buildroot dir> | |
270 | $ make defconfig | |
271 | $ make menuconfig | |
272 | $ make | |
273 | ||
274 | Rebuild Snapcast: | |
275 | ||
276 | $ make package/sxx/snapcast/clean | |
277 | $ make package/sxx/snapcast/compile | |
278 | ||
279 | The packaged `ipk` files are for OpenWrt in `<buildroot dir>/bin/ar71xx/packages/base/snap[client|server]_x.x.x_ar71xx.ipk` and for LEDE `<buildroot dir>/bin/packages/mips_24kc/base/snap[client|server]_x.x.x_mips_24kc.ipk` | |
302 | ||
303 | To cross compile for OpenWrt, please follow the [OpenWrt flavored SnapOS guide](https://github.com/badaix/snapos/blob/master/openwrt/README.md) | |
280 | 304 | |
281 | 305 | ## Buildroot (Cross compile) |
282 | This example will show you how to add snapcast to [Buildroot](https://buildroot.org/). | |
283 | ||
284 | ### Buildroot setup | |
285 | Buildroot recommends [keeping customizations outside of the main Buildroot directory](https://buildroot.org/downloads/manual/manual.html#outside-br-custom) which is what this example will walk through. | |
286 | ||
287 | Clone Buildroot to some place in your home directory (`<buildroot dir>`): | |
288 | ||
289 | $ BUILDROOT_VERSION=2016.11.2 | |
290 | $ git clone --branch $BUILDROOT_VERSION --depth=1 git://git.buildroot.net/buildroot | |
291 | ||
292 | The `<snapcast dir>/buildroot` is currently set up as an external Buildroot folder following the [recommended structure](https://buildroot.org/downloads/manual/manual.html#customize-dir-structure). As of [Buildroot 2016.11](https://git.buildroot.net/buildroot/tag/?h=2016.11) you may specify multiple BR2_EXTERNAL trees. If you are using a version of Buildroot prior to this, then you will need to manually merge `<snapcast dir>/buildroot` with your existing Buildroot external tree. | |
293 | ||
294 | Now configure buildroot with the [required packages](/buildroot/configs/snapcast_defconfig) (you can also manually add them to your project's existing defconfig): | |
295 | ||
296 | $ cd <buildroot dir> && make BR2_EXTERNAL=<snapcast dir>/buildroot snapcast_defconfig | |
297 | ||
298 | Then use `menuconfig` to configure the rest of your project: | |
299 | ||
300 | $ cd <buildroot dir> && make BR2_EXTERNAL=<snapcast dir>/buildroot menuconfig | |
301 | ||
302 | And finally run the build: | |
303 | ||
304 | $ cd <buildroot dir> && make BR2_EXTERNAL=<snapcast dir>/buildroot | |
305 | ||
306 | ## Raspberry Pi (Cross compile) | |
307 | This example will show you how to add snapcast to [Buildroot](https://buildroot.org/) and compile for Raspberry Pi. | |
308 | ||
309 | * https://github.com/nickaknudson/snapcast-pi | |
306 | ||
307 | To integrate Snapcast into [Buildroot](https://buildroot.org/), please follow the [Buildroot flavored SnapOS guide](https://github.com/badaix/snapos/blob/master/buildroot-external/README.md) | |
308 | ||
309 | ## Windows (vcpkg) | |
310 | ||
311 | Prerequisites: | |
312 | ||
313 | 1. CMake | |
314 | 2. Visual Studio 2017 or 2019 with C++ | |
315 | ||
316 | Set up [vcpkg](https://github.com/Microsoft/vcpkg) | |
317 | ||
318 | Install dependencies | |
319 | ||
320 | ```sh | |
321 | vcpkg.exe install libflac libvorbis soxr opus boost-asio --triplet x64-windows | |
322 | ``` | |
323 | ||
324 | Build | |
325 | ||
326 | ```sh | |
327 | cd <snapcast dir> | |
328 | mkdir build | |
329 | cd build && cmake .. -DCMAKE_TOOLCHAIN_FILE=<vcpkg_dir>/scripts/buildsystems/vcpkg.cmake | |
330 | cmake --build . --config Release | |
331 | ``` |
0 | # Configuration | |
1 | ||
2 | ## Sources | |
3 | ||
4 | Audio sources are added to the server as `source` in the `[stream]` section of the configuration file `/etc/snapserver.conf`. Every source must be fed with a fixed sample format, that can be configured per stream (e.g. `48000:16:2`). | |
5 | ||
6 | The following notation is used in this paragraph: | |
7 | ||
8 | - `<angle brackets>`: the whole expression must be replaced with your specific setting | |
9 | - `[square brackets]`: the whole expression is optional and can be left out | |
10 | - `[key=value]`: if you leave this option out, "value" will be the default for "key" | |
11 | ||
12 | The general format of an audio source is: | |
13 | ||
14 | ```sh | |
15 | TYPE://host/path?name=<name>[&codec=<codec>][&sampleformat=<sampleformat>][&chunk_ms=<chunk ms>] | |
16 | ``` | |
17 | ||
18 | parameters have the form `key=value`, they are concatenated with an `&` character. | |
19 | Parameter `name` is mandatory for all sources, while `codec`, `sampleformat` and `chunk_ms` are optional | |
20 | and will override the default `codec`, `sampleformat` or `chunk_ms` settings. | |
21 | Non blocking sources support the `dryout_ms` parameter: when no new data is read from the source, send silence to the clients | |
22 | ||
23 | Available audio source types are: | |
24 | ||
25 | ### pipe | |
26 | ||
27 | Captures audio from a named pipe | |
28 | ||
29 | ```sh | |
30 | pipe:///<path/to/pipe>?name=<name>[&mode=create][&dryout_ms=2000] | |
31 | ``` | |
32 | ||
33 | `mode` can be `create` or `read`. Sometimes your audio source might insist in creating the pipe itself. So the pipe creation mode can by changed to "not create, but only read mode", using the `mode` option set to `read` | |
34 | ||
35 | ### librespot | |
36 | ||
37 | Launches librespot and reads audio from stdout | |
38 | ||
39 | ```sh | |
40 | librespot:///<path/to/librespot>?name=<name>[&dryout_ms=2000][&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][&cache=""][&disable_audio_cache=false][&killall=true][¶ms=extra-params] | |
41 | ``` | |
42 | ||
43 | Note that you need to have the librespot binary on your machine and the sampleformat will be set to `44100:16:2` | |
44 | ||
45 | #### Available parameters | |
46 | ||
47 | Parameters used to configure the librespot binary ([see librespot-org options](https://github.com/librespot-org/librespot/wiki/Options)): | |
48 | ||
49 | - `username`: Username to sign in with | |
50 | - `password`: Password | |
51 | - `devicename`: Device name | |
52 | - `bitrate`: Bitrate (96, 160 or 320). Defaults to 320 | |
53 | - `volume`: Initial volume in %, once connected [0-100] | |
54 | - `onevent`: The path to a script that gets run when one of librespot's events is triggered | |
55 | - `normalize`: Enables volume normalisation for librespot | |
56 | - `autoplay`: Autoplay similar songs when your music ends | |
57 | - `cache`: Path to a directory where files will be cached | |
58 | - `disable_audio_cache`: Disable caching of the audio data | |
59 | - `params`: Optional string appended to the librespot invocation. This allows for arbitrary flags to be passed to librespot, for instance `params=--device-type%20avr`. The value has to be properly URL-encoded. | |
60 | ||
61 | Parameters introduced by Snapclient: | |
62 | ||
63 | - `killall`: Kill all running librespot instances before launching librespot | |
64 | - `wd_timeout`: Restart librespot if it doesn't create log messages for x seconds | |
65 | ||
66 | ### airplay | |
67 | ||
68 | Launches [shairport-sync](https://github.com/mikebrady/shairport-sync) and reads audio from stdout | |
69 | ||
70 | ```sh | |
71 | airplay:///<path/to/shairport-sync>?name=<name>[&dryout_ms=2000][&devicename=Snapcast][&port=5000] | |
72 | ``` | |
73 | ||
74 | Note that you need to have the shairport-sync binary on your machine and the sampleformat will be set to `44100:16:2` | |
75 | ||
76 | #### Available parameters | |
77 | ||
78 | - `devicename`: Advertised name | |
79 | - `port`: RTSP listening port | |
80 | ||
81 | ### file | |
82 | ||
83 | Reads PCM audio from a file | |
84 | ||
85 | ```sh | |
86 | file:///<path/to/PCM/file>?name=<name> | |
87 | ``` | |
88 | ||
89 | ### process | |
90 | ||
91 | Launches a process and reads audio from stdout | |
92 | ||
93 | ```sh | |
94 | process:///<path/to/process>?name=<name>[&dryout_ms=2000][&wd_timeout=0][&log_stderr=false][¶ms=<process arguments>] | |
95 | ``` | |
96 | ||
97 | #### Available parameters | |
98 | ||
99 | - `wd_timeout`: kill and restart the process if there was no message logged for x seconds to stderr (0 = disabled) | |
100 | - `log_stderr`: Forward stderr log messages to Snapclient logging | |
101 | - `params`: Params to start the process with | |
102 | ||
103 | ### tcp server | |
104 | ||
105 | Receives audio from a TCP socket (acting as server) | |
106 | ||
107 | ```sh | |
108 | tcp://<listen IP, e.g. 127.0.0.1>:<port>?name=<name>[&mode=server] | |
109 | ``` | |
110 | ||
111 | default for `port` (if omitted) is 4953, default for `mode` is `server` | |
112 | ||
113 | Mopdiy configuration would look like this (running GStreamer in [client mode](https://www.freedesktop.org/software/gstreamer-sdk/data/docs/latest/gst-plugins-base-plugins-0.10/gst-plugins-base-plugins-tcpclientsink.html)) | |
114 | ||
115 | ```sh | |
116 | [audio] | |
117 | output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! wavenc ! tcpclientsink | |
118 | ``` | |
119 | ||
120 | ### tcp client | |
121 | ||
122 | Receives audio from a TCP socket (acting as client) | |
123 | ||
124 | ```sh | |
125 | tcp://<server IP, e.g. 127.0.0.1>:<port>?name=<name>&mode=client | |
126 | ``` | |
127 | ||
128 | Mopdiy configuration would look like this (running GStreamer in [server mode](https://www.freedesktop.org/software/gstreamer-sdk/data/docs/latest/gst-plugins-base-plugins-0.10/gst-plugins-base-plugins-tcpserversink.html)): | |
129 | ||
130 | ```sh | |
131 | [audio] | |
132 | output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! wavenc ! tcpserversink | |
133 | ``` | |
134 | ||
135 | ### alsa | |
136 | ||
137 | Captures audio from an alsa device | |
138 | ||
139 | ```sh | |
140 | alsa://?name=<name>&device=<alsa device> | |
141 | ``` | |
142 | ||
143 | `device` is an alsa device name or identifier, e.g. `default` or `hw:0,0` | |
144 | ||
145 | The output of any audio player that uses alsa can be redirected to Snapcast by using an alsa loopback device: | |
146 | ||
147 | 1. Setup the alsa loopback device by loading the kernel module: | |
148 | ||
149 | ```sh | |
150 | sudo modprobe snd-aloop | |
151 | ``` | |
152 | ||
153 | The loopback device can be created during boot by adding `snd-aloop` to `/etc/modules` | |
154 | ||
155 | 2. The loopback device should show up in `aplay -l` | |
156 | ||
157 | ```sh | |
158 | aplay -l | |
159 | **** List of PLAYBACK Hardware Devices **** | |
160 | card 0: Loopback [Loopback], device 0: Loopback PCM [Loopback PCM] | |
161 | Subdevices: 8/8 | |
162 | Subdevice #0: subdevice #0 | |
163 | Subdevice #1: subdevice #1 | |
164 | Subdevice #2: subdevice #2 | |
165 | Subdevice #3: subdevice #3 | |
166 | Subdevice #4: subdevice #4 | |
167 | Subdevice #5: subdevice #5 | |
168 | Subdevice #6: subdevice #6 | |
169 | Subdevice #7: subdevice #7 | |
170 | card 0: Loopback [Loopback], device 1: Loopback PCM [Loopback PCM] | |
171 | Subdevices: 8/8 | |
172 | Subdevice #0: subdevice #0 | |
173 | Subdevice #1: subdevice #1 | |
174 | Subdevice #2: subdevice #2 | |
175 | Subdevice #3: subdevice #3 | |
176 | Subdevice #4: subdevice #4 | |
177 | Subdevice #5: subdevice #5 | |
178 | Subdevice #6: subdevice #6 | |
179 | Subdevice #7: subdevice #7 | |
180 | card 1: Intel [HDA Intel], device 0: CX20561 Analog [CX20561 Analog] | |
181 | Subdevices: 1/1 | |
182 | Subdevice #0: subdevice #0 | |
183 | card 1: Intel [HDA Intel], device 1: CX20561 Digital [CX20561 Digital] | |
184 | Subdevices: 1/1 | |
185 | Subdevice #0: subdevice #0 | |
186 | card 2: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio] | |
187 | Subdevices: 0/1 | |
188 | Subdevice #0: subdevice #0 | |
189 | ``` | |
190 | ||
191 | In this example the loopback device is card 0 with devices 0 and 1, each having 8 subdevices. | |
192 | The devices are addressed with `hw:<card idx>,<device idx>,<subdevice num>`, e.g. `hw:0,0,0`. | |
193 | If a process plays audio using `hw:0,0,x`, then the audio will be looped back to `hw:0,1,x` | |
194 | ||
195 | 3. Configure your player to use a loopback device | |
196 | ||
197 | For mopidy (gstreamer) in `mopidy.conf`: | |
198 | ||
199 | ```sh | |
200 | output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! alsasink device=hw:0,0,0 | |
201 | ``` | |
202 | ||
203 | For mpd: in `mpd.conf` | |
204 | ||
205 | ```sh | |
206 | audio_output_format "48000:16:2" | |
207 | audio_output { | |
208 | type "alsa" | |
209 | name "My ALSA Device" | |
210 | device "hw:0,0,0" # optional | |
211 | # auto_resample "no" | |
212 | # mixer_type "hardware" # optional | |
213 | # mixer_device "default" # optional | |
214 | # mixer_control "PCM" # optional | |
215 | # mixer_index "0" # optional | |
216 | } | |
217 | ``` | |
218 | ||
219 | 4. Configure Snapserver to capture the loopback device: | |
220 | ||
221 | ```sh | |
222 | [stream] | |
223 | stream = alsa://?name=SomeName&sampleformat=48000:16:2&device=hw:0,1,0 | |
224 | ``` | |
225 | ||
226 | ### meta | |
227 | ||
228 | Read and mix audio from other stream sources | |
229 | ||
230 | ```sh | |
231 | meta:///<name of source#1>/<name of source#2>/.../<name of source#N>?name=<name> | |
232 | ``` | |
233 | ||
234 | Plays audio from the active source with the highest priority, with `source#1` having the highest priority and `source#N` the lowest. | |
235 | Use `codec=null` for stream sources that should only serve as input for meta streams |
0 | # Contributions |
0 | # Install Linux packages | |
1 | ||
2 | Snapcast packages are available for several Linux distributions: | |
3 | ||
4 | - [Debian](#debian) | |
5 | - [OpenWrt](#openwrt) | |
6 | - [Alpine Linux](#alpine-linux) | |
7 | - [Archlinux](#archlinux) | |
8 | - [Void Linux](#void-linux) | |
9 | ||
10 | ## Debian | |
11 | ||
12 | For Debian (and Debian-based systems, such as Ubuntu, Linux Mint, ElementaryOS) download the package for your CPU architecture from the [latest release page](https://github.com/badaix/snapcast/releases/latest). | |
13 | ||
14 | e.g. for Raspberry Pi `snapclient_0.x.x_armhf.deb`, for laptops `snapclient_0.x.x_amd64.deb` | |
15 | ||
16 | ### using apt 1.1 or later | |
17 | ||
18 | sudo apt install </path/to/snapclient_0.x.x_armhf.deb> | |
19 | ||
20 | or | |
21 | ||
22 | sudo apt install </path/to/snapserver_0.x.x_armhf.deb> | |
23 | ||
24 | ### using dpkg | |
25 | ||
26 | Install the package: | |
27 | ||
28 | sudo dpkg -i snapclient_0.x.x_armhf.deb | |
29 | ||
30 | or | |
31 | ||
32 | sudo dpkg -i snapclient_0.x.x_amd64.deb | |
33 | ||
34 | Install missing dependencies: | |
35 | ||
36 | sudo apt-get -f install | |
37 | ||
38 | ## OpenWrt | |
39 | ||
40 | On OpenWrt do: | |
41 | ||
42 | opkg install snapclient_0.x.x_ar71xx.ipk | |
43 | ||
44 | ## Alpine Linux | |
45 | ||
46 | On Alpine Linux do: | |
47 | ||
48 | apk add snapcast | |
49 | ||
50 | Or, for just the client: | |
51 | ||
52 | apk add snapcast-client | |
53 | ||
54 | Or, for just the server: | |
55 | ||
56 | apk add snapcast-server | |
57 | ||
58 | ## Gentoo Linux | |
59 | ||
60 | On Gentoo Linux do: | |
61 | ||
62 | emerge --ask media-sound/snapcast | |
63 | ||
64 | ## Archlinux | |
65 | ||
66 | On Archlinux, Snapcast is available through the AUR. To install, use your favorite AUR helper, or do: | |
67 | ||
68 | git clone https://aur.archlinux.org/snapcast | |
69 | cd snapcast | |
70 | makepkg -si | |
71 | ||
72 | ## Void Linux | |
73 | ||
74 | To install the client: | |
75 | ||
76 | # xbps-install snapclient | |
77 | ||
78 | To install the server: | |
79 | ||
80 | # xbps-install snapserver |
0 | 0 | Snapcast JSON RPC Control API |
1 | 1 | ============================= |
2 | Snapcast can be controlled with a [JSON-RPC 2.0](http://www.jsonrpc.org/specification) API over a raw TCP-Socket interface on port 1705. | |
3 | ||
4 | Single JSON Messages are new line delimited ([ndjson](http://ndjson.org/)). | |
5 | ||
6 | For simple tests you can fire JSON commands on a telnet connection and watch Notifications coming in: | |
2 | ||
3 | ## Raw TCP sockets | |
4 | ||
5 | Snapcast can be controlled with a [JSON-RPC 2.0](http://www.jsonrpc.org/specification) | |
6 | API over a raw TCP-Socket interface on port 1705. | |
7 | ||
8 | Single JSON Messages are new line delimited ([ndjson](http://ndjson.org/)). | |
9 | ||
10 | For simple tests you can fire JSON commands on a telnet connection and watch | |
11 | Notifications coming in: | |
7 | 12 | |
8 | 13 | ```json |
9 | 14 | $ telnet localhost 1705 |
18 | 23 | {"jsonrpc":"2.0","method":"Client.OnConnect","params":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":74}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488065507,"usec":820050},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.11.0-beta-1"}},"id":"00:21:6a:7d:74:fc"}} |
19 | 24 | ``` |
20 | 25 | |
21 | In the following the supported Requests and Notifications are described. | |
26 | ## HTTP | |
27 | ||
28 | Snapcast can also be controlled on port 1780 using the HTTP protocol to (1) send | |
29 | a single `POST` request or (2) create a long-lived `WebSocket`. | |
30 | ||
31 | For simple tests, you can fire JSON commands directly within your browser. | |
32 | One-shot `POST` requests receive only the immediate response of the request, | |
33 | whereas long-lived `WebSocket`s will also receive Notifications similar to the | |
34 | telnet connection above: | |
35 | ||
36 | ```js | |
37 | ||
38 | const host = '127.0.0.1:1780'; | |
39 | const request = { | |
40 | 'id': 0, | |
41 | 'jsonrpc': '2.0', | |
42 | 'method': 'Server.GetRPCVersion' | |
43 | }; | |
44 | ||
45 | // XHR | |
46 | const xhr = new XMLHttpRequest(); | |
47 | xhr.open('POST', `http://${host}/jsonrpc`); | |
48 | xhr.setRequestHeader('Content-Type', 'application/json'); | |
49 | xhr.setRequestHeader('Accept', 'application/json'); | |
50 | xhr.addEventListener('load', ({ currentTarget: xhr }) => { | |
51 | console.log(JSON.parse(xhr.responseText)); // {"id":1,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
52 | }); | |
53 | xhr.send(JSON.stringify(++request.id && request)); | |
54 | ||
55 | // Fetch | |
56 | fetch(`http://${host}/jsonrpc`, { | |
57 | method: 'POST', | |
58 | headers: { | |
59 | 'Accept': 'application/json', | |
60 | 'Content-Type': 'application/json' | |
61 | }, | |
62 | body: JSON.stringify(++request.id && request) | |
63 | }) | |
64 | .then(response => response.json()) | |
65 | .then(content => console.log(content)); // {"id":2,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
66 | ||
67 | // Fetch with await/async | |
68 | const response = await fetch(`http://${host}/jsonrpc`, { | |
69 | method: 'POST', | |
70 | headers: { | |
71 | 'Accept': 'application/json', | |
72 | 'Content-Type': 'application/json' | |
73 | }, | |
74 | body: JSON.stringify(++request.id && request) | |
75 | }); | |
76 | const content = await response.json(); | |
77 | console.log(content); // {"id":3,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
78 | ||
79 | ||
80 | // WebSocket | |
81 | const ws = new WebSocket(`ws://${host}/jsonrpc`); | |
82 | ws.addEventListener('message', (message) => { | |
83 | console.log(JSON.parse(message.data)); // {"id":4,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
84 | }); | |
85 | ws.addEventListener('open', () => ws.send(JSON.stringify(++request.id && request))); | |
86 | ||
87 | /* | |
88 | WebSockets receive Notifications of events. Connect a client, and you will eventually see in your console: | |
89 | ||
90 | { | |
91 | "jsonrpc": "2.0", | |
92 | "method": "Client.OnConnect", | |
93 | "params": { ... } | |
94 | } | |
95 | */ | |
96 | ``` | |
97 | ||
98 | ## Requests and Notifications | |
22 | 99 | |
23 | 100 | The client that sends a "Set" command will receive a Response, while the other connected control clients will receive a Notification "On" event. |
24 | 101 | Commands can be sent in a [Batch](http://www.jsonrpc.org/specification#batch). The server will reply with a Batch and send a Batch notification to the other clients. This way the volume of multiple Snapclients can be changed with a single Batch Request. |
0 | Setup of audio players/server | |
1 | ----------------------------- | |
0 | # Setup of audio players/server | |
1 | ||
2 | 2 | Snapcast can be used with a number of different audio players and servers, and so it can be integrated into your favorite audio-player solution and make it synced-multiroom capable. |
3 | 3 | The only requirement is that the player's audio can be redirected into the Snapserver's fifo `/tmp/snapfifo`. In the following configuration hints for [MPD](http://www.musicpd.org/) and [Mopidy](https://www.mopidy.com/) are given, which are the base of other audio player solutions, like [Volumio](https://volumio.org/) or [RuneAudio](http://www.runeaudio.com/) (both MPD) or [Pi MusicBox](http://www.pimusicbox.com/) (Mopidy). |
4 | 4 | |
5 | 5 | The goal is to build the following chain: |
6 | 6 | |
7 | audio player software -> snapfifo -> snapserver -> network -> snapclient -> alsa | |
8 | ||
9 | #### Streams | |
7 | ```plain_text | |
8 | audio player software -> snapfifo -> snapserver -> network -> snapclient -> alsa | |
9 | ``` | |
10 | ||
11 | ## Streams | |
12 | ||
10 | 13 | Snapserver can read audio from several input streams, which are configured in the `snapserver.conf` file (default location is `/etc/snapserver.conf`); the config file can be changed with the `-c` parameter. |
11 | 14 | Within the config file a list of input streams can be configured in the `[stream]` section: |
12 | 15 | |
13 | ``` | |
16 | ```ini | |
14 | 17 | [stream] |
15 | 18 | ... |
16 | 19 | # stream URI of the PCM input stream, can be configured multiple times |
17 | # Format: TYPE://host/path?name=NAME[&codec=CODEC][&sampleformat=SAMPLEFORMAT] | |
20 | # Format: TYPE://host/path?name=NAME[&codec=CODEC][&sampleformat=SAMPLEFORMAT] | |
18 | 21 | stream = pipe:///tmp/snapfifo?name=default |
19 | 22 | ... |
20 | 23 | ``` |
21 | 24 | |
22 | #### About the notation | |
25 | The sampleformat is a triple of `<samplerate>:<bit depth>:<channels>`, e.g. `44100:16:2`. | |
26 | The PCM samples (bit depth) must be encoded signed, little endian in 8, 16, 24, or 32 bit, where 24 is expected to be encoded in the lower three bytes of a 32 bit word. | |
27 | ||
28 | ## About the notation | |
29 | ||
23 | 30 | In this document some expressions are in brackets: |
31 | ||
24 | 32 | * `<angle brackets>`: the whole expression must be replaced with your specific setting |
25 | 33 | * `[square brackets]`: the whole expression is optional and can be left out |
26 | 34 | * `[key=value]`: if you leave this option out, `value` will be the default for `key` |
27 | 35 | |
28 | 36 | For example: |
29 | ``` | |
37 | ||
38 | ```ini | |
30 | 39 | stream = spotify:///librespot?name=Spotify[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320] |
31 | 40 | ``` |
41 | ||
32 | 42 | * `username` and `password` are both optional in this case. You need to specify neither or both of them. |
33 | 43 | * `bitrate` is optional. If not configured, `320` will be used. |
34 | 44 | * `devicename` is optional. If not configured, `Snapcast` will be used. |
35 | 45 | |
36 | 46 | For instance, a valid usage would be: |
37 | ``` | |
47 | ||
48 | ```ini | |
38 | 49 | stream = spotify:///librespot?name=Spotify&bitrate=160 |
39 | 50 | ``` |
40 | 51 | |
41 | 52 | ### MPD |
53 | ||
42 | 54 | To connect [MPD](http://www.musicpd.org/) to the Snapserver, edit `/etc/mpd.conf`, so that mpd will feed the audio into the snapserver's named pipe. |
43 | 55 | |
44 | 56 | Disable alsa audio output by commenting out this section: |
45 | 57 | |
46 | #audio_output { | |
47 | # type "alsa" | |
48 | # name "My ALSA Device" | |
49 | # device "hw:0,0" # optional | |
50 | # format "48000:16:2" # optional | |
51 | # mixer_device "default" # optional | |
52 | # mixer_control "PCM" # optional | |
53 | # mixer_index "0" # optional | |
54 | #} | |
58 | ```plain_text | |
59 | #audio_output { | |
60 | # type "alsa" | |
61 | # name "My ALSA Device" | |
62 | # device "hw:0,0" # optional | |
63 | # format "48000:16:2" # optional | |
64 | # mixer_device "default" # optional | |
65 | # mixer_control "PCM" # optional | |
66 | # mixer_index "0" # optional | |
67 | #} | |
68 | ``` | |
55 | 69 | |
56 | 70 | Add a new audio output of the type "fifo", which will let mpd play audio into the named pipe `/tmp/snapfifo`. |
57 | 71 | Make sure that the "format" setting is the same as the format setting of the Snapserver (default is "48000:16:2", which should make resampling unnecessary in most cases). |
58 | 72 | |
59 | audio_output { | |
60 | type "fifo" | |
61 | name "my pipe" | |
62 | path "/tmp/snapfifo" | |
63 | format "48000:16:2" | |
64 | mixer_type "software" | |
73 | ```plain_text | |
74 | audio_output { | |
75 | type "fifo" | |
76 | name "my pipe" | |
77 | path "/tmp/snapfifo" | |
78 | format "48000:16:2" | |
79 | mixer_type "software" | |
80 | } | |
81 | ``` | |
82 | ||
83 | To test your mpd installation, you can add a radio station by | |
84 | ||
85 | ```sh | |
86 | sudo su | |
87 | echo "http://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3" > /var/lib/mpd/playlists/einslive.m3u | |
88 | ``` | |
89 | ||
90 | ### Mopidy | |
91 | ||
92 | [Mopidy](https://www.mopidy.com/) can stream the audio output into the Snapserver's fifo with a `filesink` as audio output in `mopidy.conf`: | |
93 | ||
94 | ```ini | |
95 | [audio] | |
96 | #output = autoaudiosink | |
97 | output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! filesink location=/tmp/snapfifo | |
98 | ``` | |
99 | ||
100 | With newer kernels one might also have to change this sysctl-setting, as default settings have changed recently: `sudo sysctl fs.protected_fifos=0` | |
101 | ||
102 | See [stackexchange](https://unix.stackexchange.com/questions/503111/group-permissions-for-root-not-working-in-tmp) for more details. You need to run this after each reboot or add it to /etc/sysctl.conf or /etc/sysctl.d/50-default.conf depending on distribution. | |
103 | ||
104 | ### FFmpeg | |
105 | ||
106 | Pipe FFmpeg's audio output to the snapfifo: | |
107 | ||
108 | ```sh | |
109 | ffmpeg -y -i http://wms-15.streamsrus.com:11630 -f u16le -acodec pcm_s16le -ac 2 -ar 48000 /tmp/snapfifo | |
110 | ``` | |
111 | ||
112 | ### mpv | |
113 | ||
114 | Pipe mpv's audio output to the snapfifo. For version < 0.21.0: | |
115 | ||
116 | ```sh | |
117 | mpv http://wms-15.streamsrus.com:11630 --audio-display=no --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm:file=/tmp/snapfifo | |
118 | ``` | |
119 | ||
120 | For version >= 0.21.0: | |
121 | ||
122 | ```sh | |
123 | mpv http://wms-15.streamsrus.com:11630 --audio-display=no --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/tmp/snapfifo | |
124 | ``` | |
125 | ||
126 | ### MPlayer | |
127 | ||
128 | Use `-novideo` and `-ao` to pipe MPlayer's audio output to the snapfifo: | |
129 | ||
130 | ```sh | |
131 | mplayer http://wms-15.streamsrus.com:11630 -novideo -channels 2 -srate 48000 -af format=s16le -ao pcm:file=/tmp/snapfifo | |
132 | ``` | |
133 | ||
134 | ### Alsa | |
135 | ||
136 | If the player cannot be configured to route the audio stream into the snapfifo, Alsa or PulseAudio can be redirected, resulting in this chain: | |
137 | ||
138 | ```plain_text | |
139 | audio player software -> Alsa -> Alsa file plugin -> snapfifo -> snapserver -> network -> snapclient -> Alsa | |
140 | ``` | |
141 | ||
142 | Edit or create your Alsa config `/etc/asound.conf` like this: | |
143 | ||
144 | ```plain_text | |
145 | pcm.!default { | |
146 | type plug | |
147 | slave.pcm rate48000Hz | |
148 | } | |
149 | ||
150 | pcm.rate48000Hz { | |
151 | type rate | |
152 | slave { | |
153 | pcm writeFile # Direct to the plugin which will write to a file | |
154 | format S16_LE | |
155 | rate 48000 | |
65 | 156 | } |
66 | ||
67 | To test your mpd installation, you can add a radio station by | |
68 | ||
69 | $ sudo su | |
70 | $ echo "http://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3" > /var/lib/mpd/playlists/einslive.m3u | |
71 | ||
72 | ### Mopidy | |
73 | [Mopidy](https://www.mopidy.com/) can stream the audio output into the Snapserver's fifo with a `filesink` as audio output in `mopidy.conf`: | |
74 | ||
75 | [audio] | |
76 | #output = autoaudiosink | |
77 | output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! wavenc ! filesink location=/tmp/snapfifo | |
78 | ||
79 | With newer kernels one might also have to change this sysctl-setting, as default settings have changed recently: `sudo sysctl fs.protected_fifos=0` | |
80 | ||
81 | See https://unix.stackexchange.com/questions/503111/group-permissions-for-root-not-working-in-tmp for more details. You need to run this after each reboot or add it to /etc/sysctl.conf or /etc/sysctl.d/50-default.conf depending on distribution. | |
82 | ||
83 | ### FFmpeg | |
84 | Pipe FFmpeg's audio output to the snapfifo: | |
85 | ||
86 | ffmpeg -y -i http://wms-15.streamsrus.com:11630 -f u16le -acodec pcm_s16le -ac 2 -ar 48000 /tmp/snapfifo | |
87 | ||
88 | ### mpv | |
89 | Pipe mpv's audio output to the snapfifo: | |
90 | ||
91 | mpv http://wms-15.streamsrus.com:11630 --audio-display=no --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/tmp/snapfifo | |
92 | ||
93 | ### MPlayer | |
94 | Use `-novideo` and `-ao` to pipe MPlayer's audio output to the snapfifo: | |
95 | ||
96 | mplayer http://wms-15.streamsrus.com:11630 -novideo -channels 2 -srate 48000 -af format=s16le -ao pcm:file=/tmp/snapfifo | |
97 | ||
98 | ### Alsa | |
99 | If the player cannot be configured to route the audio stream into the snapfifo, Alsa or PulseAudio can be redirected, resulting in this chain: | |
100 | ||
101 | audio player software -> Alsa -> Alsa file plugin -> snapfifo -> snapserver -> network -> snapclient -> Alsa | |
102 | ||
103 | Edit or create your Alsa config `/etc/asound.conf` like this: | |
104 | ||
105 | ``` | |
106 | pcm.!default { | |
107 | type plug | |
108 | slave.pcm rate48000Hz | |
109 | } | |
110 | ||
111 | pcm.rate48000Hz { | |
112 | type rate | |
113 | slave { | |
114 | pcm writeFile # Direct to the plugin which will write to a file | |
115 | format S16_LE | |
116 | rate 48000 | |
117 | } | |
118 | 157 | } |
119 | 158 | |
120 | 159 | pcm.writeFile { |
121 | type file | |
122 | slave.pcm null | |
123 | file "/tmp/snapfifo" | |
124 | format "raw" | |
160 | type file | |
161 | slave.pcm null | |
162 | file "/tmp/snapfifo" | |
163 | format "raw" | |
125 | 164 | } |
126 | 165 | ``` |
127 | 166 | |
128 | 167 | ### PulseAudio |
168 | ||
129 | 169 | Redirect the PulseAudio stream into the snapfifo: |
130 | 170 | |
131 | audio player software -> PulseAudio -> PulseAudio pipe sink -> snapfifo -> snapserver -> network -> snapclient -> Alsa | |
171 | ```plain_text | |
172 | audio player software -> PulseAudio -> PulseAudio pipe sink -> snapfifo -> snapserver -> network -> snapclient -> Alsa | |
173 | ``` | |
132 | 174 | |
133 | 175 | PulseAudio will create the pipe file for itself and will fail if it already exists; see the [Configuration section](https://github.com/badaix/snapcast#configuration) in the main README file on how to change the pipe creation mode to read-only. |
134 | 176 | |
135 | 177 | Load the module `pipe-sink` like this: |
136 | 178 | |
137 | pacmd load-module module-pipe-sink file=/tmp/snapfifo sink_name=Snapcast format=s16le rate=48000 | |
138 | pacmd update-sink-proplist Snapcast device.description=Snapcast | |
179 | ```plain_text | |
180 | pacmd load-module module-pipe-sink file=/tmp/snapfifo sink_name=Snapcast format=s16le rate=48000 | |
181 | pacmd update-sink-proplist Snapcast device.description=Snapcast | |
182 | ``` | |
139 | 183 | |
140 | 184 | It might be neccessary to set the PulseAudio latency environment variable to 60 msec: `PULSE_LATENCY_MSEC=60` |
141 | 185 | |
142 | ||
143 | 186 | ### AirPlay |
187 | ||
144 | 188 | Snapserver supports [shairport-sync](https://github.com/mikebrady/shairport-sync) with the `stdout` backend. |
145 | 1. Build shairport-sync with `stdout` backend: `./configure --with-stdout --with-avahi --with-ssl=openssl --with-metadata` | |
189 | ||
190 | 1. Build shairport-sync (version 3.3 or later) with `stdout` backend: `./configure --with-stdout --with-avahi --with-ssl=openssl --with-metadata` | |
146 | 191 | 2. Copy the `shairport-sync` binary somewhere to your `PATH`, e.g. `/usr/local/bin/` |
147 | 192 | 3. Configure snapserver with `stream = airplay:///shairport-sync?name=Airplay[&devicename=Snapcast][&port=5000]` |
148 | ||
149 | 193 | |
150 | 194 | ### Spotify |
195 | ||
151 | 196 | Snapserver supports [librespot](https://github.com/librespot-org/librespot) with the `pipe` backend. |
197 | ||
152 | 198 | 1. Build and copy the `librespot` binary somewhere to your `PATH`, e.g. `/usr/local/bin/` |
153 | 2. Configure snapserver with `stream = spotify:///librespot?name=Spotify[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&onstart=<start command>][&onstop=<stop command>][&volume=<volume in percent>][&cache=<cache dir>][&killall=true]` | |
154 | * Valid bitrates are 96, 160, 320 | |
155 | * `start command` and `stop command` are executed by Librespot at start/stop | |
156 | * For example: `onstart=/usr/bin/logger -t Snapcast Starting spotify...` | |
157 | * If `killall` is `true` (default), all running instances of Librespot will be killed. This MUST be disabled on all spotify streams by setting it to `false` if you want to use multiple spotify streams. | |
199 | 2. Configure snapserver with `stream = spotify:///librespot?name=Spotify[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&onstart=<start command>][&onstop=<stop command>][&volume=<volume in percent>][&cache=<cache dir>][&disable_audio_cache=false][&killall=true]` | |
200 | * Valid bitrates are 96, 160, 320 | |
201 | * `start command` and `stop command` are executed by Librespot at start/stop | |
202 | * For example: `onstart=/usr/bin/logger -t Snapcast Starting spotify...` | |
203 | * If `killall` is `true` (default), all running instances of Librespot will be killed. This MUST be disabled on all spotify streams by setting it to `false` if you want to use multiple spotify streams. | |
204 | * If `disable_audio_cache` is `false` (default), downloaded audio files are cached in `<cache dir>`. If set to `true` audio files will not be cached on disk. | |
158 | 205 | |
159 | 206 | ### Process |
160 | Snapserver can start any process and read PCM data from the stdout of the process: | |
207 | ||
208 | Snapserver can start any process and read PCM data from the stdout of the process: | |
161 | 209 | |
162 | 210 | Configure snapserver with `stream = process:///path/to/process?name=Process[¶ms=<--my list --of params>][&log_stderr=false]` |
163 | 211 | |
212 | For example, you could install the minimalist **mpv** media player to pick up WebRadio from a given url ... | |
213 | ||
214 | ```ini | |
215 | [stream] | |
216 | stream = process:///usr/bin/mpv?name=Webradio&sampleformat=48000:16:2¶ms=http://129.122.92.10:88/broadwavehigh.mp3 --no-terminal --audio-display=no --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm:file=/dev/stdout | |
217 | ``` | |
164 | 218 | |
165 | 219 | ### Line-in |
220 | ||
166 | 221 | Audio captured from line-in can be redirected to the snapserver's pipe, e.g. by using: |
167 | 222 | |
168 | 223 | #### cpiped |
224 | ||
169 | 225 | [cpipe](https://github.com/b-fitzpatrick/cpiped) |
170 | 226 | |
171 | 227 | #### PulseAudio |
228 | ||
172 | 229 | `parec >/tmp/snapfifo` (defaults to 44.1kHz, 16bit, stereo) |
230 | ||
231 | ### VLC | |
232 | ||
233 | Use `--aout afile` and `--audiofile-file` to pipe VLC's audio output to the snapfifo: | |
234 | ||
235 | ```sh | |
236 | vlc --no-video --aout afile --audiofile-file /tmp/snapfifo | |
237 | ```⏎ |
Binary diff not shown
Binary diff not shown
33 | 33 | ifndef ARCH |
34 | 34 | $(error ARCH is not set ("arm" or "aarch64" or "x86")) |
35 | 35 | endif |
36 | ||
37 | $(eval TOOLCHAIN:=$(NDK_DIR)/toolchains/llvm/prebuilt/linux-x86_64) | |
38 | ||
36 | 39 | ifeq ($(ARCH), x86) |
37 | 40 | $(eval CPPFLAGS:=-DLITTLE_ENDIAN=1234 -DBIG_ENDIAN=4321 -DBYTE_ORDER=LITTLE_ENDIAN) |
38 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/i686-linux-android-) | |
41 | $(eval TARGET:=x86_64-linux-android) | |
42 | $(eval API:=21) | |
39 | 43 | else ifeq ($(ARCH), arm) |
40 | 44 | $(eval CPPFLAGS:=-U_ARM_ASSEM_) |
41 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/arm-linux-androideabi-) | |
45 | $(eval TARGET:=armv7a-linux-androideabi) | |
46 | $(eval API:=16) | |
42 | 47 | else ifeq ($(ARCH), aarch64) |
43 | 48 | $(eval CPPFLAGS:=-U_ARM_ASSEM_ -DLITTLE_ENDIAN=1234 -DBIG_ENDIAN=4321 -DBYTE_ORDER=LITTLE_ENDIAN) |
44 | $(eval PROGRAM_PREFIX:=$(NDK_DIR)/bin/aarch64-linux-android-) | |
49 | $(eval TARGET:=aarch64-linux-android) | |
50 | $(eval API:=21) | |
45 | 51 | else |
46 | 52 | $(error ARCH must be "arm" or "aarch64" or "x86") |
47 | 53 | endif |
48 | $(eval CC:=$(PROGRAM_PREFIX)clang) | |
49 | $(eval CXX:=$(PROGRAM_PREFIX)clang++) | |
50 | $(eval CPPFLAGS:=-I$(NDK_DIR)/include $(CPPFLAGS)) | |
54 | $(eval CC:=$(TOOLCHAIN)/bin/$(TARGET)$(API)-clang) | |
55 | $(eval CXX:=$(TOOLCHAIN)/bin/$(TARGET)$(API)-clang++) | |
56 | $(eval SYSROOT:=$(TOOLCHAIN)/sysroot/$(TARGET)) | |
57 | $(eval CPPFLAGS:=-I$(TOOLCHAIN)/sysroot/usr/include -I$(SYSROOT)/usr/local/include $(CPPFLAGS)) | |
51 | 58 | |
52 | 59 | flac: check-env |
53 | 60 | @cd flac; \ |
55 | 62 | export CXX="$(CXX)"; \ |
56 | 63 | export CPPFLAGS="$(CPPFLAGS)"; \ |
57 | 64 | ./autogen.sh; \ |
58 | ./configure --host=$(ARCH) --disable-ogg --prefix=$(NDK_DIR)/usr/local/; \ | |
65 | ./configure --host=$(ARCH) --disable-ogg --prefix=$(SYSROOT)/usr/local/; \ | |
59 | 66 | make; \ |
60 | 67 | make install; \ |
61 | 68 | make clean; |
66 | 73 | export CXX="$(CXX)"; \ |
67 | 74 | export CPPFLAGS="$(CPPFLAGS)"; \ |
68 | 75 | ./autogen.sh; \ |
69 | ./configure --host=$(ARCH) --prefix=$(NDK_DIR)/usr/local/; \ | |
76 | ./configure --host=$(ARCH) --prefix=$(SYSROOT)/usr/local/; \ | |
70 | 77 | make; \ |
71 | 78 | make install; \ |
72 | 79 | make clean; |
77 | 84 | export CXX="$(CXX)"; \ |
78 | 85 | export CPPFLAGS="$(CPPFLAGS)"; \ |
79 | 86 | ./autogen.sh; \ |
80 | ./configure --host=$(ARCH) --prefix=$(NDK_DIR)/usr/local/; \ | |
87 | ./configure --host=$(ARCH) --prefix=$(SYSROOT)/usr/local/; \ | |
81 | 88 | make; \ |
82 | 89 | make install; \ |
83 | 90 | make clean; |
88 | 95 | export CXX="$(CXX)"; \ |
89 | 96 | export CPPFLAGS="$(CPPFLAGS)"; \ |
90 | 97 | ./autogen.sh; \ |
91 | ./configure --host=$(ARCH) --prefix=$(NDK_DIR)/usr/local/ --with-ogg=$(NDK_DIR)/usr/local/; \ | |
98 | ./configure --host=$(ARCH) --prefix=$(SYSROOT)/usr/local/ --with-ogg=$(SYSROOT)/usr/local/; \ | |
92 | 99 | make; \ |
93 | 100 | make install; \ |
94 | 101 | make clean; \ |
123 | 130 | cd build; \ |
124 | 131 | cmake ..; \ |
125 | 132 | make; \ |
126 | make DESTDIR=$(NDK_DIR) install; \ | |
133 | make DESTDIR=$(SYSROOT) install; \ | |
127 | 134 | make clean; \ |
128 | 135 | cd ..; \ |
129 | 136 | rm -rf build; |
130 | 137 | |
131 | 138 | soxr: check-env |
132 | @cd /home/johannes/Develop/soxr; \ | |
139 | @cd soxr; \ | |
133 | 140 | export CC="$(CC)"; \ |
134 | 141 | export CXX="$(CXX)"; \ |
135 | 142 | export CPPFLAGS="$(CPPFLAGS)"; \ |
137 | 144 | cd build; \ |
138 | 145 | cmake -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTS=OFF -DWITH_OPENMP=OFF ..; \ |
139 | 146 | make; \ |
140 | make DESTDIR=$(NDK_DIR) install; \ | |
147 | make DESTDIR=$(SYSROOT) install; \ | |
141 | 148 | make clean; \ |
142 | 149 | cd ..; \ |
143 | 150 | rm -rf build; |
2 | 2 | control_server.cpp |
3 | 3 | control_session_tcp.cpp |
4 | 4 | control_session_http.cpp |
5 | control_session_ws.cpp | |
5 | 6 | snapserver.cpp |
7 | server.cpp | |
6 | 8 | stream_server.cpp |
7 | 9 | stream_session.cpp |
10 | stream_session_tcp.cpp | |
11 | stream_session_ws.cpp | |
8 | 12 | encoder/encoder_factory.cpp |
9 | 13 | encoder/pcm_encoder.cpp |
14 | encoder/null_encoder.cpp | |
10 | 15 | streamreader/base64.cpp |
11 | 16 | streamreader/stream_uri.cpp |
12 | 17 | streamreader/stream_manager.cpp |
17 | 22 | streamreader/file_stream.cpp |
18 | 23 | streamreader/airplay_stream.cpp |
19 | 24 | streamreader/librespot_stream.cpp |
25 | streamreader/meta_stream.cpp | |
20 | 26 | streamreader/watchdog.cpp |
21 | 27 | streamreader/process_stream.cpp) |
22 | 28 | |
67 | 73 | list(APPEND SERVER_INCLUDE ${OPUS_INCLUDE_DIRS}) |
68 | 74 | endif (OPUS_FOUND) |
69 | 75 | |
76 | if (ALSA_FOUND) | |
77 | list(APPEND SERVER_SOURCES streamreader/alsa_stream.cpp) | |
78 | list(APPEND SERVER_LIBRARIES ${ALSA_LIBRARIES}) | |
79 | list(APPEND SERVER_INCLUDE ${ALSA_INCLUDE_DIRS}) | |
80 | endif (ALSA_FOUND) | |
81 | ||
70 | 82 | if (EXPAT_FOUND) |
71 | 83 | list(APPEND SERVER_LIBRARIES ${EXPAT_LIBRARIES}) |
72 | 84 | list(APPEND SERVER_INCLUDE ${EXPAT_INCLUDE_DIRS}) |
78 | 90 | add_executable(snapserver ${SERVER_SOURCES}) |
79 | 91 | target_link_libraries(snapserver ${SERVER_LIBRARIES}) |
80 | 92 | |
81 | install(TARGETS snapserver COMPONENT server DESTINATION "${CMAKE_INSTALL_BINDIR}") | |
93 | install(TARGETS snapserver COMPONENT server DESTINATION ${CMAKE_INSTALL_BINDIR}) | |
94 | install(FILES snapserver.1 COMPONENT server DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) | |
95 | install(FILES etc/snapserver.conf COMPONENT server DESTINATION ${CMAKE_INSTALL_SYSCONFDIR}) | |
96 | install(DIRECTORY etc/snapweb/ DESTINATION ${CMAKE_INSTALL_DATADIR}/snapserver/snapweb) | |
97 | #install(FILES ../debian/snapserver.service DESTINATION ${SYSTEMD_SERVICES_INSTALL_DIR}) |
13 | 13 | # You should have received a copy of the GNU General Public License |
14 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 | VERSION = 0.19.0 | |
16 | VERSION = 0.22.0 | |
17 | 17 | BIN = snapserver |
18 | 18 | |
19 | 19 | ifeq ($(TARGET), FREEBSD) |
41 | 41 | LDFLAGS += -fsanitize=$(SANITIZE) |
42 | 42 | endif |
43 | 43 | |
44 | CXXFLAGS += $(ADD_CFLAGS) -std=c++14 -Wall -Wextra -Wpedantic -Wno-unused-function -DBOOST_ERROR_CODE_HEADER_ONLY -DHAS_FLAC -DHAS_OGG -DHAS_VORBIS -DHAS_VORBIS_ENC -DHAS_OPUS -DVERSION=\"$(VERSION)\" -I. -I.. -I../common | |
45 | LDFLAGS += $(ADD_LDFLAGS) -lvorbis -lvorbisenc -logg -lFLAC -lopus | |
46 | OBJ = snapserver.o config.o control_server.o control_session_tcp.o control_session_http.o stream_server.o stream_session.o streamreader/stream_uri.o streamreader/base64.o streamreader/stream_manager.o streamreader/pcm_stream.o streamreader/posix_stream.o streamreader/pipe_stream.o streamreader/file_stream.o streamreader/tcp_stream.o streamreader/process_stream.o streamreader/airplay_stream.o streamreader/librespot_stream.o streamreader/watchdog.o encoder/encoder_factory.o encoder/flac_encoder.o encoder/opus_encoder.o encoder/pcm_encoder.o encoder/ogg_encoder.o ../common/sample_format.o | |
44 | CXXFLAGS += $(ADD_CFLAGS) -std=c++14 -Wall -Wextra -Wpedantic -Wno-unused-function -DBOOST_ERROR_CODE_HEADER_ONLY -DHAS_FLAC -DHAS_OGG -DHAS_VORBIS -DHAS_VORBIS_ENC -DHAS_OPUS -DHAS_SOXR -DVERSION=\"$(VERSION)\" -I. -I.. -I../common | |
45 | LDFLAGS += $(ADD_LDFLAGS) -lvorbis -lvorbisenc -logg -lFLAC -lopus -lsoxr | |
46 | OBJ = snapserver.o server.o config.o control_server.o control_session_tcp.o control_session_http.o control_session_ws.o stream_server.o stream_session.o stream_session_tcp.o stream_session_ws.o streamreader/stream_uri.o streamreader/base64.o streamreader/stream_manager.o streamreader/pcm_stream.o streamreader/posix_stream.o streamreader/pipe_stream.o streamreader/file_stream.o streamreader/tcp_stream.o streamreader/process_stream.o streamreader/airplay_stream.o streamreader/meta_stream.o streamreader/librespot_stream.o streamreader/watchdog.o encoder/encoder_factory.o encoder/flac_encoder.o encoder/opus_encoder.o encoder/pcm_encoder.o encoder/null_encoder.o encoder/ogg_encoder.o ../common/sample_format.o ../common/resampler.o | |
47 | 47 | |
48 | 48 | ifneq (,$(TARGET)) |
49 | 49 | CXXFLAGS += -D$(TARGET) |
61 | 61 | |
62 | 62 | else ifeq ($(TARGET), OPENWRT) |
63 | 63 | |
64 | CXXFLAGS += -DNO_CPP11_STRING -DHAS_AVAHI -DHAS_DAEMON -pthread | |
64 | CXXFLAGS += -DNO_CPP11_STRING -DHAS_AVAHI -DHAS_DAEMON -DHAS_ALSA -pthread | |
65 | 65 | LDFLAGS += -lavahi-client -lavahi-common -latomic |
66 | OBJ += ../common/daemon.o publishZeroConf/publish_avahi.o | |
66 | OBJ += ../common/daemon.o publishZeroConf/publish_avahi.o streamreader/alsa_stream.o | |
67 | 67 | |
68 | 68 | else ifeq ($(TARGET), BUILDROOT) |
69 | 69 | |
70 | CXXFLAGS += -DHAS_AVAHI -DHAS_DAEMON -pthread | |
70 | CXXFLAGS += -DHAS_AVAHI -DHAS_DAEMON -DHAS_ALSA -pthread | |
71 | 71 | LDFLAGS += -lrt -lavahi-client -lavahi-common |
72 | OBJ += publishZeroConf/publish_avahi.o | |
72 | OBJ += publishZeroConf/publish_avahi.o streamreader/alsa_stream.o | |
73 | 73 | |
74 | 74 | else ifeq ($(TARGET), FREEBSD) |
75 | 75 | |
81 | 81 | else ifeq ($(TARGET), MACOS) |
82 | 82 | |
83 | 83 | CXX = g++ |
84 | CXXFLAGS += -DFREEBSD -DHAS_BONJOUR -DHAS_DAEMON -Wno-deprecated -I/usr/local/include | |
84 | CXXFLAGS += -DFREEBSD -DMACOS -DHAS_BONJOUR -DHAS_DAEMON -Wno-deprecated -I/usr/local/include | |
85 | 85 | LDFLAGS += -L/usr/local/lib -framework CoreFoundation -framework IOKit |
86 | 86 | OBJ += ../common/daemon.o publishZeroConf/publish_bonjour.o |
87 | 87 | |
88 | 88 | else |
89 | 89 | |
90 | 90 | CXX = g++ |
91 | CXXFLAGS += -DHAS_AVAHI -DHAS_DAEMON -pthread | |
92 | LDFLAGS += -lrt -lavahi-client -lavahi-common -latomic | |
93 | OBJ += ../common/daemon.o publishZeroConf/publish_avahi.o | |
91 | CXXFLAGS += -DHAS_AVAHI -DHAS_DAEMON -DHAS_ALSA -pthread | |
92 | LDFLAGS += -lrt -lasound -lavahi-client -lavahi-common -latomic | |
93 | OBJ += ../common/daemon.o publishZeroConf/publish_avahi.o streamreader/alsa_stream.o | |
94 | 94 | |
95 | 95 | endif |
96 | 96 | |
122 | 122 | echo BSD |
123 | 123 | install -s -g wheel -o root -m 555 $(BIN) $(TARGET_DIR)/local/bin/$(BIN) |
124 | 124 | install -g wheel -o root -m 555 $(BIN).1 $(TARGET_DIR)/local/man/man1/$(BIN).1 |
125 | install -g wheel -o root -m 555 ../debian/$(BIN).bsd $(TARGET_DIR)/local/etc/rc.d/$(BIN) | |
125 | install -g wheel -o root -m 555 etc/$(BIN).bsd $(TARGET_DIR)/local/etc/rc.d/$(BIN) | |
126 | install -g wheel -o root etc/$(BIN).conf /etc/$(BIN).conf | |
127 | for file in etc/snapweb/*\.*; do install -g wheel -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/"; done | |
128 | for file in etc/snapweb/3rd-party/*\.*; do install -g wheel -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/3rd-party/"; done | |
126 | 129 | |
127 | 130 | else ifeq ($(TARGET), MACOS) |
128 | 131 | |
132 | 135 | install -g wheel -o root $(BIN).1 $(TARGET_DIR)/local/share/man/man1/$(BIN).1 |
133 | 136 | install -g wheel -o root etc/$(BIN).plist /Library/LaunchAgents/de.badaix.snapcast.$(BIN).plist |
134 | 137 | install -g wheel -o root etc/$(BIN).conf /etc/$(BIN).conf |
138 | for file in etc/snapweb/*\.*; do install -g wheel -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/"; done | |
139 | for file in etc/snapweb/3rd-party/*\.*; do install -g wheel -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/3rd-party/"; done | |
135 | 140 | launchctl load /Library/LaunchAgents/de.badaix.snapcast.$(BIN).plist |
136 | 141 | |
137 | 142 | else |
156 | 161 | installfiles: |
157 | 162 | install -s -D -g root -o root $(BIN) $(TARGET_DIR)/bin/$(BIN) |
158 | 163 | install -D -g root -o root $(BIN).1 $(TARGET_DIR)/share/man/man1/$(BIN).1 |
164 | install -g root -o root etc/$(BIN).conf /etc/$(BIN).conf; | |
165 | for file in etc/snapweb/*\.*; do install -g root -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/"; done | |
166 | for file in etc/snapweb/3rd-party/*\.*; do install -g root -o root -m 644 $${file} -Dt "/usr/share/snapserver/snapweb/3rd-party/"; done | |
159 | 167 | |
160 | 168 | installsystemd: |
161 | 169 | @echo using systemd; \ |
162 | 170 | cp ../debian/$(BIN).service /lib/systemd/system/$(BIN).service; \ |
163 | 171 | cp -n ../debian/$(BIN).default /etc/default/$(BIN); \ |
164 | cp -n etc/$(BIN).conf /etc/$(BIN).conf; \ | |
165 | 172 | systemctl daemon-reload; \ |
166 | 173 | systemctl enable $(BIN); \ |
167 | 174 | systemctl start $(BIN); \ |
170 | 177 | @echo using sysv; \ |
171 | 178 | cp ../debian/$(BIN).init /etc/init.d/$(BIN); \ |
172 | 179 | cp -n ../debian/$(BIN).default /etc/default/$(BIN); \ |
173 | cp -n etc/$(BIN).conf /etc/$(BIN).conf; \ | |
174 | 180 | update-rc.d $(BIN) defaults; \ |
175 | 181 | /etc/init.d/$(BIN) start; \ |
176 | 182 | |
177 | installbsd: | |
178 | @echo using bsd; \ | |
179 | cp ../debian/$(BIN).bsd /usr/local/etc/rc.d/$(BIN); \ | |
180 | ||
181 | 183 | adduser: |
182 | @if ! getent passwd snapserver >/dev/null; then \ | |
183 | useradd --user-group --system --home-dir /var/lib/snapserver snapserver; \ | |
184 | fi; \ | |
185 | ||
184 | sh ../debian/snapserver.postinst configure | |
186 | 185 | |
187 | 186 | ifeq ($(TARGET), FREEBSD) |
188 | 187 | |
193 | 192 | rm -f $(TARGET_DIR)/local/man/man1/$(BIN).1; \ |
194 | 193 | rm -f $(TARGET_DIR)/local/etc/rc.d/$(BIN); \ |
195 | 194 | rm -f /etc/$(BIN).conf; \ |
196 | rm -f /var/lib/snapserver; \ | |
195 | rm -rf /var/lib/snapserver; \ | |
196 | rm -rf /usr/share/snapserver; \ | |
197 | 197 | |
198 | 198 | else ifeq ($(TARGET), MACOS) |
199 | 199 | |
204 | 204 | rm -f $(TARGET_DIR)/local/share/man/man1/$(BIN).1; \ |
205 | 205 | rm -f /Library/LaunchAgents/de.badaix.snapcast.$(BIN).plist; \ |
206 | 206 | rm -f /etc/$(BIN).conf; \ |
207 | rm -f /var/lib/snapserver; \ | |
207 | rm -rf /var/lib/snapserver; \ | |
208 | rm -rf /usr/share/snapserver; \ | |
208 | 209 | |
209 | 210 | else |
210 | 211 | |
220 | 221 | echo cannot tell; \ |
221 | 222 | fi; \ |
222 | 223 | rm -f /etc/$(BIN).conf; \ |
223 | rm -f /var/run/$(BIN); \ | |
224 | rm -f /var/lib/snapserver; \ | |
224 | rm -rf /var/run/$(BIN); \ | |
225 | rm -rf /var/lib/snapserver; \ | |
226 | rm -rf /usr/share/snapserver; \ | |
225 | 227 | $(MAKE) deluser |
226 | 228 | |
227 | 229 | endif |
244 | 246 | systemctl daemon-reload; \ |
245 | 247 | |
246 | 248 | deluser: |
247 | @userdel --force snapserver > /dev/null || true; \ | |
248 | groupdel snapserver > /dev/null || true; \ | |
249 | ||
249 | sh ../debian/snapserver.postrm purge | |
250 |
61 | 61 | throw SnapException("failed to create settings directory: \"" + dir + "\": " + cpt::to_string(errno)); |
62 | 62 | |
63 | 63 | filename_ = dir + "server.json"; |
64 | SLOG(NOTICE) << "Settings file: \"" << filename_ << "\"\n"; | |
64 | LOG(NOTICE) << "Settings file: \"" << filename_ << "\"\n"; | |
65 | 65 | |
66 | 66 | int fd; |
67 | 67 | if ((fd = open(filename_.c_str(), O_RDWR | O_CREAT, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)) == -1) |
82 | 82 | } |
83 | 83 | catch (const std::exception& e) |
84 | 84 | { |
85 | SLOG(ERROR) << "Exception in chown: " << e.what() << "\n"; | |
85 | LOG(ERROR) << "Exception in chown: " << e.what() << "\n"; | |
86 | 86 | } |
87 | 87 | } |
88 | 88 | |
89 | 89 | try |
90 | 90 | { |
91 | 91 | ifstream ifs(filename_, std::ifstream::in); |
92 | if (ifs.good()) | |
92 | if (ifs.good() && (ifs.peek() != std::ifstream::traits_type::eof())) | |
93 | 93 | { |
94 | 94 | json j; |
95 | 95 | ifs >> j; |
100 | 100 | { |
101 | 101 | GroupPtr group = make_shared<Group>(); |
102 | 102 | group->fromJson(jGroup); |
103 | // if (client->id.empty() || getClientInfo(client->id)) | |
104 | // continue; | |
103 | // if (client->id.empty() || getClientInfo(client->id)) | |
104 | // continue; | |
105 | 105 | groups.push_back(group); |
106 | 106 | } |
107 | 107 | } |
34 | 34 | struct ClientInfo; |
35 | 35 | struct Group; |
36 | 36 | |
37 | typedef std::shared_ptr<ClientInfo> ClientInfoPtr; | |
38 | typedef std::shared_ptr<Group> GroupPtr; | |
37 | using ClientInfoPtr = std::shared_ptr<ClientInfo>; | |
38 | using GroupPtr = std::shared_ptr<Group>; | |
39 | 39 | |
40 | 40 | |
41 | 41 | template <typename T> |
30 | 30 | using namespace std; |
31 | 31 | using json = nlohmann::json; |
32 | 32 | |
33 | ||
34 | ControlServer::ControlServer(boost::asio::io_context& io_context, const ServerSettings::TcpSettings& tcp_settings, | |
35 | const ServerSettings::HttpSettings& http_settings, ControlMessageReceiver* controlMessageReceiver) | |
33 | static constexpr auto LOG_TAG = "ControlServer"; | |
34 | ||
35 | ||
36 | ControlServer::ControlServer(boost::asio::io_context& io_context, const ServerSettings::Tcp& tcp_settings, const ServerSettings::Http& http_settings, | |
37 | ControlMessageReceiver* controlMessageReceiver) | |
36 | 38 | : io_context_(io_context), tcp_settings_(tcp_settings), http_settings_(http_settings), controlMessageReceiver_(controlMessageReceiver) |
37 | 39 | { |
38 | 40 | } |
50 | 52 | auto count = distance(new_end, sessions_.end()); |
51 | 53 | if (count > 0) |
52 | 54 | { |
53 | SLOG(ERROR) << "Removing " << count << " inactive session(s), active sessions: " << sessions_.size() - count << "\n"; | |
55 | LOG(ERROR, LOG_TAG) << "Removing " << count << " inactive session(s), active sessions: " << sessions_.size() - count << "\n"; | |
54 | 56 | sessions_.erase(new_end, sessions_.end()); |
55 | 57 | } |
56 | 58 | } |
71 | 73 | } |
72 | 74 | |
73 | 75 | |
74 | std::string ControlServer::onMessageReceived(ControlSession* connection, const std::string& message) | |
75 | { | |
76 | // LOG(DEBUG) << "received: \"" << message << "\"\n"; | |
76 | std::string ControlServer::onMessageReceived(ControlSession* session, const std::string& message) | |
77 | { | |
78 | // LOG(DEBUG, LOG_TAG) << "received: \"" << message << "\"\n"; | |
77 | 79 | if (controlMessageReceiver_ != nullptr) |
78 | return controlMessageReceiver_->onMessageReceived(connection, message); | |
80 | return controlMessageReceiver_->onMessageReceived(session, message); | |
79 | 81 | return ""; |
82 | } | |
83 | ||
84 | ||
85 | void ControlServer::onNewSession(const shared_ptr<ControlSession>& session) | |
86 | { | |
87 | std::lock_guard<std::recursive_mutex> mlock(session_mutex_); | |
88 | session->start(); | |
89 | sessions_.emplace_back(session); | |
90 | cleanup(); | |
91 | } | |
92 | ||
93 | ||
94 | void ControlServer::onNewSession(const std::shared_ptr<StreamSession>& session) | |
95 | { | |
96 | if (controlMessageReceiver_ != nullptr) | |
97 | controlMessageReceiver_->onNewSession(session); | |
80 | 98 | } |
81 | 99 | |
82 | 100 | |
86 | 104 | if (!ec) |
87 | 105 | handleAccept<ControlSessionTcp>(std::move(socket)); |
88 | 106 | else |
89 | LOG(ERROR) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
107 | LOG(ERROR, LOG_TAG) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
90 | 108 | }; |
91 | 109 | |
92 | 110 | auto accept_handler_http = [this](error_code ec, tcp::socket socket) { |
93 | 111 | if (!ec) |
94 | 112 | handleAccept<ControlSessionHttp>(std::move(socket), http_settings_); |
95 | 113 | else |
96 | LOG(ERROR) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
114 | LOG(ERROR, LOG_TAG) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
97 | 115 | }; |
98 | 116 | |
99 | 117 | for (auto& acceptor : acceptor_tcp_) |
115 | 133 | setsockopt(socket.native_handle(), SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); |
116 | 134 | setsockopt(socket.native_handle(), SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); |
117 | 135 | // socket->set_option(boost::asio::ip::tcp::no_delay(false)); |
118 | SLOG(NOTICE) << "ControlServer::NewConnection: " << socket.remote_endpoint().address().to_string() << endl; | |
136 | LOG(NOTICE, LOG_TAG) << "ControlServer::NewConnection: " << socket.remote_endpoint().address().to_string() << endl; | |
119 | 137 | shared_ptr<SessionType> session = make_shared<SessionType>(this, io_context_, std::move(socket), std::forward<Args>(args)...); |
120 | { | |
121 | std::lock_guard<std::recursive_mutex> mlock(session_mutex_); | |
122 | session->start(); | |
123 | sessions_.emplace_back(session); | |
124 | cleanup(); | |
125 | } | |
138 | onNewSession(session); | |
126 | 139 | } |
127 | 140 | catch (const std::exception& e) |
128 | 141 | { |
129 | SLOG(ERROR) << "Exception in ControlServer::handleAccept: " << e.what() << endl; | |
142 | LOG(ERROR, LOG_TAG) << "Exception in ControlServer::handleAccept: " << e.what() << endl; | |
130 | 143 | } |
131 | 144 | startAccept(); |
132 | 145 | } |
141 | 154 | { |
142 | 155 | try |
143 | 156 | { |
144 | LOG(INFO) << "Creating TCP acceptor for address: " << address << ", port: " << tcp_settings_.port << "\n"; | |
157 | LOG(INFO, LOG_TAG) << "Creating TCP acceptor for address: " << address << ", port: " << tcp_settings_.port << "\n"; | |
145 | 158 | acceptor_tcp_.emplace_back( |
146 | 159 | make_unique<tcp::acceptor>(io_context_, tcp::endpoint(boost::asio::ip::address::from_string(address), tcp_settings_.port))); |
147 | 160 | } |
148 | 161 | catch (const boost::system::system_error& e) |
149 | 162 | { |
150 | LOG(ERROR) << "error creating TCP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
163 | LOG(ERROR, LOG_TAG) << "error creating TCP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
151 | 164 | } |
152 | 165 | } |
153 | 166 | } |
157 | 170 | { |
158 | 171 | try |
159 | 172 | { |
160 | LOG(INFO) << "Creating HTTP acceptor for address: " << address << ", port: " << http_settings_.port << "\n"; | |
173 | LOG(INFO, LOG_TAG) << "Creating HTTP acceptor for address: " << address << ", port: " << http_settings_.port << "\n"; | |
161 | 174 | acceptor_http_.emplace_back( |
162 | 175 | make_unique<tcp::acceptor>(io_context_, tcp::endpoint(boost::asio::ip::address::from_string(address), http_settings_.port))); |
163 | 176 | } |
164 | 177 | catch (const boost::system::system_error& e) |
165 | 178 | { |
166 | LOG(ERROR) << "error creating HTTP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
179 | LOG(ERROR, LOG_TAG) << "error creating HTTP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
167 | 180 | } |
168 | 181 | } |
169 | 182 | } |
42 | 42 | class ControlServer : public ControlMessageReceiver |
43 | 43 | { |
44 | 44 | public: |
45 | ControlServer(boost::asio::io_context& io_context, const ServerSettings::TcpSettings& tcp_settings, const ServerSettings::HttpSettings& http_settings, | |
45 | ControlServer(boost::asio::io_context& io_context, const ServerSettings::Tcp& tcp_settings, const ServerSettings::Http& http_settings, | |
46 | 46 | ControlMessageReceiver* controlMessageReceiver = nullptr); |
47 | 47 | virtual ~ControlServer(); |
48 | 48 | |
49 | 49 | void start(); |
50 | 50 | void stop(); |
51 | 51 | |
52 | /// Send a message to all connceted clients | |
52 | /// Send a message to all connected clients | |
53 | 53 | void send(const std::string& message, const ControlSession* excludeSession = nullptr); |
54 | ||
55 | /// Clients call this when they receive a message. Implementation of MessageReceiver::onMessageReceived | |
56 | std::string onMessageReceived(ControlSession* connection, const std::string& message) override; | |
57 | 54 | |
58 | 55 | private: |
59 | 56 | void startAccept(); |
62 | 59 | void handleAccept(tcp::socket socket, Args&&... args); |
63 | 60 | void cleanup(); |
64 | 61 | |
62 | /// Implementation of ControlMessageReceiver | |
63 | std::string onMessageReceived(ControlSession* session, const std::string& message) override; | |
64 | void onNewSession(const std::shared_ptr<ControlSession>& session) override; | |
65 | void onNewSession(const std::shared_ptr<StreamSession>& session) override; | |
66 | ||
65 | 67 | mutable std::recursive_mutex session_mutex_; |
66 | 68 | std::vector<std::weak_ptr<ControlSession>> sessions_; |
67 | 69 | |
69 | 71 | std::vector<acceptor_ptr> acceptor_http_; |
70 | 72 | |
71 | 73 | boost::asio::io_context& io_context_; |
72 | ServerSettings::TcpSettings tcp_settings_; | |
73 | ServerSettings::HttpSettings http_settings_; | |
74 | ServerSettings::Tcp tcp_settings_; | |
75 | ServerSettings::Http http_settings_; | |
74 | 76 | ControlMessageReceiver* controlMessageReceiver_; |
75 | 77 | }; |
76 | 78 |
32 | 32 | |
33 | 33 | |
34 | 34 | class ControlSession; |
35 | ||
35 | class StreamSession; | |
36 | 36 | |
37 | 37 | /// Interface: callback for a received message. |
38 | 38 | class ControlMessageReceiver |
39 | 39 | { |
40 | 40 | public: |
41 | 41 | // TODO: rename, error handling |
42 | virtual std::string onMessageReceived(ControlSession* connection, const std::string& message) = 0; | |
42 | virtual std::string onMessageReceived(ControlSession* session, const std::string& message) = 0; | |
43 | virtual void onNewSession(const std::shared_ptr<ControlSession>& session) = 0; | |
44 | virtual void onNewSession(const std::shared_ptr<StreamSession>& session) = 0; | |
43 | 45 | }; |
44 | 46 | |
45 | 47 | |
49 | 51 | * Messages are sent to the client with the "send" method. |
50 | 52 | * Received messages from the client are passed to the ControlMessageReceiver callback |
51 | 53 | */ |
52 | class ControlSession | |
54 | class ControlSession : public std::enable_shared_from_this<ControlSession> | |
53 | 55 | { |
54 | 56 | public: |
55 | /// ctor. Received message from the client are passed to MessageReceiver | |
57 | /// ctor. Received message from the client are passed to ControlMessageReceiver | |
56 | 58 | ControlSession(ControlMessageReceiver* receiver) : message_receiver_(receiver) |
57 | 59 | { |
58 | 60 | } |
59 | 61 | virtual ~ControlSession() = default; |
60 | 62 | virtual void start() = 0; |
61 | 63 | virtual void stop() = 0; |
62 | ||
63 | /// Sends a message to the client (synchronous) | |
64 | virtual bool send(const std::string& message) = 0; | |
65 | 64 | |
66 | 65 | /// Sends a message to the client (asynchronous) |
67 | 66 | virtual void sendAsync(const std::string& message) = 0; |
17 | 17 | |
18 | 18 | #include "control_session_http.hpp" |
19 | 19 | #include "common/aixlog.hpp" |
20 | #include "control_session_ws.hpp" | |
20 | 21 | #include "message/pcm_chunk.hpp" |
22 | #include "stream_session_ws.hpp" | |
21 | 23 | #include <boost/beast/http/file_body.hpp> |
22 | 24 | #include <iostream> |
23 | 25 | |
24 | 26 | using namespace std; |
27 | ||
28 | static constexpr auto LOG_TAG = "ControlSessionHTTP"; | |
29 | ||
25 | 30 | |
26 | 31 | static constexpr const char* HTTP_SERVER_NAME = "Snapcast"; |
27 | 32 | |
98 | 103 | } // namespace |
99 | 104 | |
100 | 105 | ControlSessionHttp::ControlSessionHttp(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, tcp::socket&& socket, |
101 | const ServerSettings::HttpSettings& settings) | |
106 | const ServerSettings::Http& settings) | |
102 | 107 | : ControlSession(receiver), socket_(std::move(socket)), settings_(settings), strand_(ioc) |
103 | 108 | { |
104 | LOG(DEBUG) << "ControlSessionHttp\n"; | |
109 | LOG(DEBUG, LOG_TAG) << "ControlSessionHttp\n"; | |
105 | 110 | } |
106 | 111 | |
107 | 112 | |
108 | 113 | ControlSessionHttp::~ControlSessionHttp() |
109 | 114 | { |
110 | LOG(DEBUG) << "ControlSessionHttp::~ControlSessionHttp()\n"; | |
115 | LOG(DEBUG, LOG_TAG) << "ControlSessionHttp::~ControlSessionHttp()\n"; | |
111 | 116 | stop(); |
112 | 117 | } |
113 | 118 | |
192 | 197 | if (req.target().back() == '/') |
193 | 198 | path.append("index.html"); |
194 | 199 | |
195 | LOG(DEBUG) << "path: " << path << "\n"; | |
200 | LOG(DEBUG, LOG_TAG) << "path: " << path << "\n"; | |
196 | 201 | // Attempt to open the file |
197 | 202 | beast::error_code ec; |
198 | 203 | http::file_body::value_type body; |
241 | 246 | // Handle the error, if any |
242 | 247 | if (ec) |
243 | 248 | { |
244 | LOG(ERROR) << "ControlSessionHttp::on_read error: " << ec.message() << "\n"; | |
245 | return; | |
246 | } | |
247 | ||
248 | LOG(DEBUG) << "read: " << bytes_transferred << ", method: " << req_.method_string() << ", content type: " << req_[beast::http::field::content_type] | |
249 | << ", target: " << req_.target() << ", body: " << req_.body() << "\n"; | |
249 | LOG(ERROR, LOG_TAG) << "ControlSessionHttp::on_read error: " << ec.message() << "\n"; | |
250 | return; | |
251 | } | |
252 | ||
253 | LOG(DEBUG, LOG_TAG) << "read: " << bytes_transferred << ", method: " << req_.method_string() << ", content type: " << req_[beast::http::field::content_type] | |
254 | << ", target: " << req_.target() << ", body: " << req_.body() << "\n"; | |
250 | 255 | |
251 | 256 | // See if it is a WebSocket Upgrade |
252 | if (websocket::is_upgrade(req_) && (req_.target() == "/jsonrpc")) | |
253 | { | |
254 | // Create a WebSocket session by transferring the socket | |
255 | // std::make_shared<websocket_session>(std::move(socket_), state_)->run(std::move(req_)); | |
256 | ws_ = make_unique<websocket::stream<beast::tcp_stream>>(std::move(socket_)); | |
257 | ws_->async_accept(req_, [ this, self = shared_from_this() ](beast::error_code ec) { on_accept_ws(ec); }); | |
258 | LOG(DEBUG) << "websocket upgrade\n"; | |
257 | if (websocket::is_upgrade(req_)) | |
258 | { | |
259 | LOG(DEBUG, LOG_TAG) << "websocket upgrade, target: " << req_.target() << "\n"; | |
260 | if (req_.target() == "/jsonrpc") | |
261 | { | |
262 | // Create a WebSocket session by transferring the socket | |
263 | // std::make_shared<websocket_session>(std::move(socket_), state_)->run(std::move(req_)); | |
264 | auto ws = std::make_shared<websocket::stream<beast::tcp_stream>>(std::move(socket_)); | |
265 | ws->async_accept(req_, [ this, ws, self = shared_from_this() ](beast::error_code ec) { | |
266 | if (ec) | |
267 | { | |
268 | LOG(ERROR, LOG_TAG) << "Error during WebSocket handshake (control): " << ec.message() << "\n"; | |
269 | } | |
270 | else | |
271 | { | |
272 | auto ws_session = make_shared<ControlSessionWebsocket>(message_receiver_, strand_.context(), std::move(*ws)); | |
273 | message_receiver_->onNewSession(ws_session); | |
274 | } | |
275 | }); | |
276 | } | |
277 | else if (req_.target() == "/stream") | |
278 | { | |
279 | // Create a WebSocket session by transferring the socket | |
280 | // std::make_shared<websocket_session>(std::move(socket_), state_)->run(std::move(req_)); | |
281 | auto ws = std::make_shared<websocket::stream<beast::tcp_stream>>(std::move(socket_)); | |
282 | ws->async_accept(req_, [ this, ws, self = shared_from_this() ](beast::error_code ec) { | |
283 | if (ec) | |
284 | { | |
285 | LOG(ERROR, LOG_TAG) << "Error during WebSocket handshake (stream): " << ec.message() << "\n"; | |
286 | } | |
287 | else | |
288 | { | |
289 | auto ws_session = make_shared<StreamSessionWebsocket>(strand_.context(), nullptr, std::move(*ws)); | |
290 | message_receiver_->onNewSession(ws_session); | |
291 | } | |
292 | }); | |
293 | } | |
259 | 294 | return; |
260 | 295 | } |
261 | 296 | |
281 | 316 | // Handle the error, if any |
282 | 317 | if (ec) |
283 | 318 | { |
284 | LOG(ERROR) << "ControlSessionHttp::on_write, error: " << ec.message() << "\n"; | |
319 | LOG(ERROR, LOG_TAG) << "ControlSessionHttp::on_write, error: " << ec.message() << "\n"; | |
285 | 320 | return; |
286 | 321 | } |
287 | 322 | |
307 | 342 | { |
308 | 343 | } |
309 | 344 | |
310 | void ControlSessionHttp::sendAsync(const std::string& message) | |
311 | { | |
312 | if (!ws_) | |
313 | return; | |
314 | ||
315 | strand_.post([ this, self = shared_from_this(), message ]() { | |
316 | messages_.emplace_back(message); | |
317 | if (messages_.size() > 1) | |
318 | { | |
319 | LOG(DEBUG) << "HTTP session outstanding async_writes: " << messages_.size() << "\n"; | |
320 | return; | |
321 | } | |
322 | send_next(); | |
323 | }); | |
324 | } | |
325 | ||
326 | void ControlSessionHttp::send_next() | |
327 | { | |
328 | if (!ws_) | |
329 | return; | |
330 | ||
331 | auto message = messages_.front(); | |
332 | ws_->async_write(boost::asio::buffer(message), | |
333 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](std::error_code ec, std::size_t length) { | |
334 | messages_.pop_front(); | |
335 | if (ec) | |
336 | { | |
337 | LOG(ERROR) << "Error while writing to web socket: " << ec.message() << "\n"; | |
338 | } | |
339 | else | |
340 | { | |
341 | LOG(DEBUG) << "Wrote " << length << " bytes to web socket\n"; | |
342 | } | |
343 | if (!messages_.empty()) | |
344 | send_next(); | |
345 | })); | |
346 | } | |
347 | ||
348 | ||
349 | bool ControlSessionHttp::send(const std::string& message) | |
350 | { | |
351 | if (!ws_) | |
352 | return false; | |
353 | ||
354 | boost::system::error_code ec; | |
355 | ws_->write(boost::asio::buffer(message), ec); | |
356 | return !ec; | |
357 | } | |
358 | ||
359 | void ControlSessionHttp::on_accept_ws(beast::error_code ec) | |
360 | { | |
361 | if (ec) | |
362 | { | |
363 | LOG(ERROR) << "ControlSessionWs::on_accept, error: " << ec.message() << "\n"; | |
364 | return; | |
365 | } | |
366 | ||
367 | // Read a message | |
368 | do_read_ws(); | |
369 | } | |
370 | ||
371 | void ControlSessionHttp::do_read_ws() | |
372 | { | |
373 | // Read a message into our buffer | |
374 | ws_->async_read(buffer_, boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](beast::error_code ec, std::size_t bytes_transferred) { | |
375 | on_read_ws(ec, bytes_transferred); | |
376 | })); | |
377 | } | |
378 | ||
379 | ||
380 | void ControlSessionHttp::on_read_ws(beast::error_code ec, std::size_t bytes_transferred) | |
381 | { | |
382 | boost::ignore_unused(bytes_transferred); | |
383 | ||
384 | // This indicates that the session was closed | |
385 | if (ec == websocket::error::closed) | |
386 | return; | |
387 | ||
388 | if (ec) | |
389 | { | |
390 | LOG(ERROR) << "ControlSessionHttp::on_read_ws error: " << ec.message() << "\n"; | |
391 | return; | |
392 | } | |
393 | ||
394 | std::string line{boost::beast::buffers_to_string(buffer_.data())}; | |
395 | if (!line.empty()) | |
396 | { | |
397 | // LOG(DEBUG) << "received: " << line << "\n"; | |
398 | if ((message_receiver_ != nullptr) && !line.empty()) | |
399 | { | |
400 | string response = message_receiver_->onMessageReceived(this, line); | |
401 | if (!response.empty()) | |
402 | { | |
403 | sendAsync(response); | |
404 | } | |
405 | } | |
406 | } | |
407 | buffer_.consume(bytes_transferred); | |
408 | do_read_ws(); | |
409 | } | |
345 | ||
346 | void ControlSessionHttp::sendAsync(const std::string& /*message*/) | |
347 | { | |
348 | } |
35 | 35 | * Messages are sent to the client with the "send" method. |
36 | 36 | * Received messages from the client are passed to the ControlMessageReceiver callback |
37 | 37 | */ |
38 | class ControlSessionHttp : public ControlSession, public std::enable_shared_from_this<ControlSession> | |
38 | class ControlSessionHttp : public ControlSession | |
39 | 39 | { |
40 | 40 | public: |
41 | /// ctor. Received message from the client are passed to MessageReceiver | |
42 | ControlSessionHttp(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, tcp::socket&& socket, const ServerSettings::HttpSettings& settings); | |
41 | /// ctor. Received message from the client are passed to ControlMessageReceiver | |
42 | ControlSessionHttp(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, tcp::socket&& socket, const ServerSettings::Http& settings); | |
43 | 43 | ~ControlSessionHttp() override; |
44 | 44 | void start() override; |
45 | 45 | void stop() override; |
46 | ||
47 | /// Sends a message to the client (synchronous) | |
48 | bool send(const std::string& message) override; | |
49 | 46 | |
50 | 47 | /// Sends a message to the client (asynchronous) |
51 | 48 | void sendAsync(const std::string& message) override; |
58 | 55 | template <class Body, class Allocator, class Send> |
59 | 56 | void handle_request(http::request<Body, http::basic_fields<Allocator>>&& req, Send&& send); |
60 | 57 | |
61 | void send_next(); | |
62 | ||
63 | 58 | http::request<http::string_body> req_; |
64 | ||
65 | protected: | |
66 | // Websocket methods | |
67 | void on_accept_ws(beast::error_code ec); | |
68 | void on_read_ws(beast::error_code ec, std::size_t bytes_transferred); | |
69 | void do_read_ws(); | |
70 | ||
71 | std::unique_ptr<websocket::stream<beast::tcp_stream>> ws_; | |
72 | 59 | |
73 | 60 | protected: |
74 | 61 | tcp::socket socket_; |
75 | 62 | beast::flat_buffer buffer_; |
76 | ServerSettings::HttpSettings settings_; | |
63 | ServerSettings::Http settings_; | |
77 | 64 | boost::asio::io_context::strand strand_; |
78 | 65 | std::deque<std::string> messages_; |
79 | 66 | }; |
21 | 21 | |
22 | 22 | using namespace std; |
23 | 23 | |
24 | static constexpr auto LOG_TAG = "ControlSessionTCP"; | |
25 | ||
24 | 26 | // https://stackoverflow.com/questions/7754695/boost-asio-async-write-how-to-not-interleaving-async-write-calls/7756894 |
25 | 27 | |
26 | 28 | |
32 | 34 | |
33 | 35 | ControlSessionTcp::~ControlSessionTcp() |
34 | 36 | { |
35 | LOG(DEBUG) << "ControlSessionTcp::~ControlSessionTcp()\n"; | |
37 | LOG(DEBUG, LOG_TAG) << "ControlSessionTcp::~ControlSessionTcp()\n"; | |
36 | 38 | stop(); |
37 | 39 | } |
38 | 40 | |
45 | 47 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this(), delimiter ](const std::error_code& ec, std::size_t bytes_transferred) { |
46 | 48 | if (ec) |
47 | 49 | { |
48 | LOG(ERROR) << "Error while reading from control socket: " << ec.message() << "\n"; | |
50 | LOG(ERROR, LOG_TAG) << "Error while reading from control socket: " << ec.message() << "\n"; | |
49 | 51 | return; |
50 | 52 | } |
51 | 53 | |
55 | 57 | { |
56 | 58 | if (line.back() == '\r') |
57 | 59 | line.resize(line.size() - 1); |
58 | // LOG(DEBUG) << "received: " << line << "\n"; | |
60 | // LOG(DEBUG, LOG_TAG) << "received: " << line << "\n"; | |
59 | 61 | if ((message_receiver_ != nullptr) && !line.empty()) |
60 | 62 | { |
61 | 63 | string response = message_receiver_->onMessageReceived(this, line); |
77 | 79 | |
78 | 80 | void ControlSessionTcp::stop() |
79 | 81 | { |
80 | LOG(DEBUG) << "ControlSession::stop\n"; | |
82 | LOG(DEBUG, LOG_TAG) << "ControlSession::stop\n"; | |
81 | 83 | boost::system::error_code ec; |
82 | 84 | socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); |
83 | 85 | if (ec) |
84 | LOG(ERROR) << "Error in socket shutdown: " << ec.message() << "\n"; | |
86 | LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << "\n"; | |
85 | 87 | socket_.close(ec); |
86 | 88 | if (ec) |
87 | LOG(ERROR) << "Error in socket close: " << ec.message() << "\n"; | |
88 | LOG(DEBUG) << "ControlSession ControlSession stopped\n"; | |
89 | LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; | |
90 | LOG(DEBUG, LOG_TAG) << "ControlSession ControlSession stopped\n"; | |
89 | 91 | } |
90 | 92 | |
91 | 93 | |
95 | 97 | messages_.emplace_back(message + "\r\n"); |
96 | 98 | if (messages_.size() > 1) |
97 | 99 | { |
98 | LOG(DEBUG) << "TCP session outstanding async_writes: " << messages_.size() << "\n"; | |
100 | LOG(DEBUG, LOG_TAG) << "TCP session outstanding async_writes: " << messages_.size() << "\n"; | |
99 | 101 | return; |
100 | 102 | } |
101 | 103 | send_next(); |
109 | 111 | messages_.pop_front(); |
110 | 112 | if (ec) |
111 | 113 | { |
112 | LOG(ERROR) << "Error while writing to control socket: " << ec.message() << "\n"; | |
114 | LOG(ERROR, LOG_TAG) << "Error while writing to control socket: " << ec.message() << "\n"; | |
113 | 115 | } |
114 | 116 | else |
115 | 117 | { |
116 | LOG(DEBUG) << "Wrote " << length << " bytes to control socket\n"; | |
118 | LOG(DEBUG, LOG_TAG) << "Wrote " << length << " bytes to control socket\n"; | |
117 | 119 | } |
118 | 120 | if (!messages_.empty()) |
119 | 121 | send_next(); |
120 | 122 | })); |
121 | 123 | } |
122 | ||
123 | bool ControlSessionTcp::send(const std::string& message) | |
124 | { | |
125 | boost::system::error_code ec; | |
126 | boost::asio::write(socket_, boost::asio::buffer(message + "\r\n"), ec); | |
127 | return !ec; | |
128 | } |
27 | 27 | * Messages are sent to the client with the "send" method. |
28 | 28 | * Received messages from the client are passed to the ControlMessageReceiver callback |
29 | 29 | */ |
30 | class ControlSessionTcp : public ControlSession, public std::enable_shared_from_this<ControlSession> | |
30 | class ControlSessionTcp : public ControlSession | |
31 | 31 | { |
32 | 32 | public: |
33 | /// ctor. Received message from the client are passed to MessageReceiver | |
33 | /// ctor. Received message from the client are passed to ControlMessageReceiver | |
34 | 34 | ControlSessionTcp(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, tcp::socket&& socket); |
35 | 35 | ~ControlSessionTcp() override; |
36 | 36 | void start() override; |
37 | 37 | void stop() override; |
38 | ||
39 | /// Sends a message to the client (synchronous) | |
40 | bool send(const std::string& message) override; | |
41 | 38 | |
42 | 39 | /// Sends a message to the client (asynchronous) |
43 | 40 | void sendAsync(const std::string& message) override; |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "control_session_ws.hpp" | |
19 | #include "common/aixlog.hpp" | |
20 | #include "message/pcm_chunk.hpp" | |
21 | #include <iostream> | |
22 | ||
23 | using namespace std; | |
24 | ||
25 | static constexpr auto LOG_TAG = "ControlSessionWS"; | |
26 | ||
27 | ||
28 | ControlSessionWebsocket::ControlSessionWebsocket(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, websocket::stream<beast::tcp_stream>&& socket) | |
29 | : ControlSession(receiver), ws_(std::move(socket)), strand_(ioc) | |
30 | { | |
31 | LOG(DEBUG, LOG_TAG) << "ControlSessionWebsocket\n"; | |
32 | } | |
33 | ||
34 | ||
35 | ControlSessionWebsocket::~ControlSessionWebsocket() | |
36 | { | |
37 | LOG(DEBUG, LOG_TAG) << "ControlSessionWebsocket::~ControlSessionWebsocket()\n"; | |
38 | stop(); | |
39 | } | |
40 | ||
41 | ||
42 | void ControlSessionWebsocket::start() | |
43 | { | |
44 | // Read a message | |
45 | do_read_ws(); | |
46 | } | |
47 | ||
48 | ||
49 | void ControlSessionWebsocket::stop() | |
50 | { | |
51 | // if (ws_.is_open()) | |
52 | // { | |
53 | // boost::beast::error_code ec; | |
54 | // ws_.close(beast::websocket::close_code::normal, ec); | |
55 | // if (ec) | |
56 | // LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; | |
57 | // } | |
58 | } | |
59 | ||
60 | ||
61 | void ControlSessionWebsocket::sendAsync(const std::string& message) | |
62 | { | |
63 | strand_.post([ this, self = shared_from_this(), msg = message ]() { | |
64 | messages_.push_back(std::move(msg)); | |
65 | if (messages_.size() > 1) | |
66 | { | |
67 | LOG(DEBUG, LOG_TAG) << "HTTP session outstanding async_writes: " << messages_.size() << "\n"; | |
68 | return; | |
69 | } | |
70 | send_next(); | |
71 | }); | |
72 | } | |
73 | ||
74 | ||
75 | void ControlSessionWebsocket::send_next() | |
76 | { | |
77 | const std::string& message = messages_.front(); | |
78 | ws_.async_write(boost::asio::buffer(message), | |
79 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](std::error_code ec, std::size_t length) { | |
80 | messages_.pop_front(); | |
81 | if (ec) | |
82 | { | |
83 | LOG(ERROR, LOG_TAG) << "Error while writing to web socket: " << ec.message() << "\n"; | |
84 | } | |
85 | else | |
86 | { | |
87 | LOG(DEBUG, LOG_TAG) << "Wrote " << length << " bytes to web socket\n"; | |
88 | } | |
89 | if (!messages_.empty()) | |
90 | send_next(); | |
91 | })); | |
92 | } | |
93 | ||
94 | ||
95 | void ControlSessionWebsocket::do_read_ws() | |
96 | { | |
97 | // Read a message into our buffer | |
98 | ws_.async_read(buffer_, boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](beast::error_code ec, std::size_t bytes_transferred) { | |
99 | on_read_ws(ec, bytes_transferred); | |
100 | })); | |
101 | } | |
102 | ||
103 | ||
104 | void ControlSessionWebsocket::on_read_ws(beast::error_code ec, std::size_t bytes_transferred) | |
105 | { | |
106 | boost::ignore_unused(bytes_transferred); | |
107 | ||
108 | // This indicates that the session was closed | |
109 | if (ec == websocket::error::closed) | |
110 | return; | |
111 | ||
112 | if (ec) | |
113 | { | |
114 | LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n"; | |
115 | return; | |
116 | } | |
117 | ||
118 | std::string line{boost::beast::buffers_to_string(buffer_.data())}; | |
119 | if (!line.empty()) | |
120 | { | |
121 | // LOG(DEBUG, LOG_TAG) << "received: " << line << "\n"; | |
122 | if ((message_receiver_ != nullptr) && !line.empty()) | |
123 | { | |
124 | string response = message_receiver_->onMessageReceived(this, line); | |
125 | if (!response.empty()) | |
126 | { | |
127 | sendAsync(response); | |
128 | } | |
129 | } | |
130 | } | |
131 | buffer_.consume(bytes_transferred); | |
132 | do_read_ws(); | |
133 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef CONTROL_SESSION_WS_HPP | |
19 | #define CONTROL_SESSION_WS_HPP | |
20 | ||
21 | #include "control_session.hpp" | |
22 | #include <boost/beast/core.hpp> | |
23 | #include <boost/beast/websocket.hpp> | |
24 | #include <deque> | |
25 | ||
26 | namespace beast = boost::beast; // from <boost/beast.hpp> | |
27 | namespace http = beast::http; // from <boost/beast/http.hpp> | |
28 | namespace websocket = beast::websocket; // from <boost/beast/websocket.hpp> | |
29 | namespace net = boost::asio; // from <boost/asio.hpp> | |
30 | ||
31 | ||
32 | /// Endpoint for a connected control client. | |
33 | /** | |
34 | * Endpoint for a connected control client. | |
35 | * Messages are sent to the client with the "send" method. | |
36 | * Received messages from the client are passed to the ControlMessageReceiver callback | |
37 | */ | |
38 | class ControlSessionWebsocket : public ControlSession | |
39 | { | |
40 | public: | |
41 | /// ctor. Received message from the client are passed to ControlMessageReceiver | |
42 | ControlSessionWebsocket(ControlMessageReceiver* receiver, boost::asio::io_context& ioc, websocket::stream<beast::tcp_stream>&& socket); | |
43 | ~ControlSessionWebsocket() override; | |
44 | void start() override; | |
45 | void stop() override; | |
46 | ||
47 | /// Sends a message to the client (asynchronous) | |
48 | void sendAsync(const std::string& message) override; | |
49 | ||
50 | protected: | |
51 | // Websocket methods | |
52 | void on_read_ws(beast::error_code ec, std::size_t bytes_transferred); | |
53 | void do_read_ws(); | |
54 | void send_next(); | |
55 | ||
56 | websocket::stream<beast::tcp_stream> ws_; | |
57 | ||
58 | protected: | |
59 | beast::flat_buffer buffer_; | |
60 | boost::asio::io_context::strand strand_; | |
61 | std::deque<std::string> messages_; | |
62 | }; | |
63 | ||
64 | ||
65 | ||
66 | #endif |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef ENCODER_H | |
19 | #define ENCODER_H | |
18 | #ifndef ENCODER_HPP | |
19 | #define ENCODER_HPP | |
20 | 20 | |
21 | #include <functional> | |
21 | 22 | #include <memory> |
22 | 23 | #include <string> |
23 | 24 | |
28 | 29 | namespace encoder |
29 | 30 | { |
30 | 31 | |
31 | class Encoder; | |
32 | ||
33 | /// Callback interface for users of Encoder | |
34 | /** | |
35 | * Users of Encoder should implement this to get the encoded PCM data | |
36 | */ | |
37 | class EncoderListener | |
38 | { | |
39 | public: | |
40 | virtual void onChunkEncoded(const Encoder* encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration) = 0; | |
41 | }; | |
42 | ||
43 | ||
44 | ||
45 | 32 | /// Abstract Encoder class |
46 | 33 | /** |
47 | 34 | * Stream encoder. PCM chunks are fed into the encoder. |
50 | 37 | class Encoder |
51 | 38 | { |
52 | 39 | public: |
40 | using OnEncodedCallback = std::function<void(const Encoder&, std::shared_ptr<msg::PcmChunk>, double)>; | |
41 | ||
53 | 42 | /// ctor. Codec options (E.g. compression level) are passed as string and are codec dependend |
54 | 43 | Encoder(const std::string& codecOptions = "") : headerChunk_(nullptr), codecOptions_(codecOptions) |
55 | 44 | { |
58 | 47 | virtual ~Encoder() = default; |
59 | 48 | |
60 | 49 | /// The listener will receive the encoded stream |
61 | virtual void init(EncoderListener* listener, const SampleFormat& format) | |
50 | virtual void init(OnEncodedCallback callback, const SampleFormat& format) | |
62 | 51 | { |
63 | 52 | if (codecOptions_ == "") |
64 | 53 | codecOptions_ = getDefaultOptions(); |
65 | listener_ = listener; | |
54 | encoded_callback_ = callback; | |
66 | 55 | sampleFormat_ = format; |
67 | 56 | initEncoder(); |
68 | 57 | } |
69 | 58 | |
70 | 59 | /// Here the work is done. Encoded data is passed to the EncoderListener. |
71 | virtual void encode(const msg::PcmChunk* chunk) = 0; | |
60 | virtual void encode(const msg::PcmChunk& chunk) = 0; | |
72 | 61 | |
73 | 62 | virtual std::string name() const = 0; |
74 | 63 | |
93 | 82 | |
94 | 83 | SampleFormat sampleFormat_; |
95 | 84 | std::shared_ptr<msg::CodecHeader> headerChunk_; |
96 | EncoderListener* listener_; | |
97 | 85 | std::string codecOptions_; |
86 | OnEncodedCallback encoded_callback_; | |
98 | 87 | }; |
99 | 88 | |
100 | 89 | } // namespace encoder |
16 | 16 | ***/ |
17 | 17 | |
18 | 18 | #include "encoder_factory.hpp" |
19 | #include "null_encoder.hpp" | |
19 | 20 | #include "pcm_encoder.hpp" |
20 | 21 | #if defined(HAS_OGG) && defined(HAS_VORBIS) && defined(HAS_VORBIS_ENC) |
21 | 22 | #include "ogg_encoder.hpp" |
47 | 48 | } |
48 | 49 | if (codec == "pcm") |
49 | 50 | return std::make_unique<PcmEncoder>(codecOptions); |
51 | else if (codec == "null") | |
52 | return std::make_unique<NullEncoder>(codecOptions); | |
50 | 53 | #if defined(HAS_OGG) && defined(HAS_VORBIS) && defined(HAS_VORBIS_ENC) |
51 | 54 | else if (codec == "ogg") |
52 | 55 | return std::make_unique<OggEncoder>(codecOptions); |
27 | 27 | namespace encoder |
28 | 28 | { |
29 | 29 | |
30 | // static constexpr auto LOG_TAG = "FlacEnc"; | |
31 | ||
30 | 32 | FlacEncoder::FlacEncoder(const std::string& codecOptions) : Encoder(codecOptions), encoder_(nullptr), pcmBufferSize_(0), encodedSamples_(0), flacChunk_(nullptr) |
31 | 33 | { |
32 | 34 | headerChunk_.reset(new msg::CodecHeader("flac")); |
66 | 68 | } |
67 | 69 | |
68 | 70 | |
69 | void FlacEncoder::encode(const msg::PcmChunk* chunk) | |
71 | void FlacEncoder::encode(const msg::PcmChunk& chunk) | |
70 | 72 | { |
71 | 73 | if (flacChunk_ == nullptr) |
72 | flacChunk_ = make_shared<msg::PcmChunk>(chunk->format, 0); | |
73 | ||
74 | int samples = chunk->getSampleCount(); | |
75 | int frames = chunk->getFrameCount(); | |
76 | // LOG(INFO) << "payload: " << chunk->payloadSize << "\tframes: " << frames << "\tsamples: " << samples << "\tduration: " << | |
74 | flacChunk_ = make_shared<msg::PcmChunk>(chunk.format, 0); | |
75 | ||
76 | int samples = chunk.getSampleCount(); | |
77 | int frames = chunk.getFrameCount(); | |
78 | // LOG(INFO, LOG_TAG) << "payload: " << chunk->payloadSize << "\tframes: " << frames << "\tsamples: " << samples << "\tduration: " << | |
77 | 79 | // chunk->duration<chronos::msec>().count() << "\n"; |
78 | 80 | |
79 | 81 | if (pcmBufferSize_ < samples) |
84 | 86 | |
85 | 87 | if (sampleFormat_.sampleSize() == 1) |
86 | 88 | { |
87 | FLAC__int8* buffer = (FLAC__int8*)chunk->payload; | |
89 | FLAC__int8* buffer = (FLAC__int8*)chunk.payload; | |
88 | 90 | for (int i = 0; i < samples; i++) |
89 | 91 | pcmBuffer_[i] = (FLAC__int32)(buffer[i]); |
90 | 92 | } |
91 | 93 | else if (sampleFormat_.sampleSize() == 2) |
92 | 94 | { |
93 | FLAC__int16* buffer = (FLAC__int16*)chunk->payload; | |
95 | FLAC__int16* buffer = (FLAC__int16*)chunk.payload; | |
94 | 96 | for (int i = 0; i < samples; i++) |
95 | 97 | pcmBuffer_[i] = (FLAC__int32)(buffer[i]); |
96 | 98 | } |
97 | 99 | else if (sampleFormat_.sampleSize() == 4) |
98 | 100 | { |
99 | FLAC__int32* buffer = (FLAC__int32*)chunk->payload; | |
101 | FLAC__int32* buffer = (FLAC__int32*)chunk.payload; | |
100 | 102 | for (int i = 0; i < samples; i++) |
101 | 103 | pcmBuffer_[i] = (FLAC__int32)(buffer[i]); |
102 | 104 | } |
107 | 109 | if (encodedSamples_ > 0) |
108 | 110 | { |
109 | 111 | double resMs = static_cast<double>(encodedSamples_) / sampleFormat_.msRate(); |
110 | // LOG(INFO) << "encoded: " << chunk->payloadSize << "\tframes: " << encodedSamples_ << "\tres: " << resMs << "\n"; | |
112 | // LOG(INFO, LOG_TAG) << "encoded: " << chunk->payloadSize << "\tframes: " << encodedSamples_ << "\tres: " << resMs << "\n"; | |
111 | 113 | encodedSamples_ = 0; |
112 | listener_->onChunkEncoded(this, flacChunk_, resMs); | |
113 | flacChunk_ = make_shared<msg::PcmChunk>(chunk->format, 0); | |
114 | encoded_callback_(*this, flacChunk_, resMs); | |
115 | flacChunk_ = make_shared<msg::PcmChunk>(chunk.format, 0); | |
114 | 116 | } |
115 | 117 | } |
116 | 118 | |
118 | 120 | FLAC__StreamEncoderWriteStatus FlacEncoder::write_callback(const FLAC__StreamEncoder* /*encoder*/, const FLAC__byte buffer[], size_t bytes, unsigned samples, |
119 | 121 | unsigned current_frame) |
120 | 122 | { |
121 | // LOG(INFO) << "write_callback: " << bytes << ", " << samples << ", " << current_frame << "\n"; | |
123 | // LOG(INFO, LOG_TAG) << "write_callback: " << bytes << ", " << samples << ", " << current_frame << "\n"; | |
122 | 124 | if ((current_frame == 0) && (bytes > 0) && (samples == 0)) |
123 | 125 | { |
124 | 126 | headerChunk_->payload = (char*)realloc(headerChunk_->payload, headerChunk_->payloadSize + bytes); |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef FLAC_ENCODER_H | |
19 | #define FLAC_ENCODER_H | |
18 | #ifndef FLAC_ENCODER_HPP | |
19 | #define FLAC_ENCODER_HPP | |
20 | 20 | #include "encoder.hpp" |
21 | 21 | #include <stdio.h> |
22 | 22 | #include <stdlib.h> |
33 | 33 | public: |
34 | 34 | FlacEncoder(const std::string& codecOptions = ""); |
35 | 35 | ~FlacEncoder() override; |
36 | void encode(const msg::PcmChunk* chunk) override; | |
36 | void encode(const msg::PcmChunk& chunk) override; | |
37 | 37 | std::string getAvailableOptions() const override; |
38 | 38 | std::string getDefaultOptions() const override; |
39 | 39 | std::string name() const override; |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "null_encoder.hpp" | |
19 | ||
20 | ||
21 | namespace encoder | |
22 | { | |
23 | ||
24 | ||
25 | NullEncoder::NullEncoder(const std::string& codecOptions) : Encoder(codecOptions) | |
26 | { | |
27 | headerChunk_.reset(new msg::CodecHeader("null")); | |
28 | } | |
29 | ||
30 | ||
31 | void NullEncoder::encode(const msg::PcmChunk& chunk) | |
32 | { | |
33 | std::ignore = chunk; | |
34 | } | |
35 | ||
36 | ||
37 | void NullEncoder::initEncoder() | |
38 | { | |
39 | } | |
40 | ||
41 | ||
42 | std::string NullEncoder::name() const | |
43 | { | |
44 | return "null"; | |
45 | } | |
46 | ||
47 | } // namespace encoder |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef NULL_ENCODER_HPP | |
19 | #define NULL_ENCODER_HPP | |
20 | #include "encoder.hpp" | |
21 | ||
22 | namespace encoder | |
23 | { | |
24 | ||
25 | class NullEncoder : public Encoder | |
26 | { | |
27 | public: | |
28 | NullEncoder(const std::string& codecOptions = ""); | |
29 | void encode(const msg::PcmChunk& chunk) override; | |
30 | std::string name() const override; | |
31 | ||
32 | protected: | |
33 | void initEncoder() override; | |
34 | }; | |
35 | ||
36 | } // namespace encoder | |
37 | ||
38 | #endif |
30 | 30 | namespace encoder |
31 | 31 | { |
32 | 32 | |
33 | static constexpr auto LOG_TAG = "OggEnc"; | |
34 | ||
33 | 35 | OggEncoder::OggEncoder(const std::string& codecOptions) : Encoder(codecOptions), lastGranulepos_(0) |
34 | 36 | { |
35 | 37 | } |
63 | 65 | } |
64 | 66 | |
65 | 67 | |
66 | void OggEncoder::encode(const msg::PcmChunk* chunk) | |
68 | void OggEncoder::encode(const msg::PcmChunk& chunk) | |
67 | 69 | { |
68 | 70 | double res = 0; |
69 | // LOG(TRACE) << "payload: " << chunk->payloadSize << "\tframes: " << chunk->getFrameCount() << "\tduration: " << chunk->duration<chronos::msec>().count() | |
71 | // LOG(TRACE, LOG_TAG) << "payload: " << chunk->payloadSize << "\tframes: " << chunk->getFrameCount() << "\tduration: " << | |
72 | // chunk->duration<chronos::msec>().count() | |
70 | 73 | // << "\n"; |
71 | int frames = chunk->getFrameCount(); | |
74 | int frames = chunk.getFrameCount(); | |
72 | 75 | float** buffer = vorbis_analysis_buffer(&vd_, frames); |
73 | 76 | |
74 | 77 | /* uninterleave samples */ |
76 | 79 | { |
77 | 80 | if (sampleFormat_.sampleSize() == 1) |
78 | 81 | { |
79 | int8_t* chunkBuffer = (int8_t*)chunk->payload; | |
82 | int8_t* chunkBuffer = (int8_t*)chunk.payload; | |
80 | 83 | for (int i = 0; i < frames; i++) |
81 | 84 | buffer[channel][i] = chunkBuffer[sampleFormat_.channels() * i + channel] / 128.f; |
82 | 85 | } |
83 | 86 | else if (sampleFormat_.sampleSize() == 2) |
84 | 87 | { |
85 | int16_t* chunkBuffer = (int16_t*)chunk->payload; | |
88 | int16_t* chunkBuffer = (int16_t*)chunk.payload; | |
86 | 89 | for (int i = 0; i < frames; i++) |
87 | 90 | buffer[channel][i] = chunkBuffer[sampleFormat_.channels() * i + channel] / 32768.f; |
88 | 91 | } |
89 | 92 | else if (sampleFormat_.sampleSize() == 4) |
90 | 93 | { |
91 | int32_t* chunkBuffer = (int32_t*)chunk->payload; | |
94 | int32_t* chunkBuffer = (int32_t*)chunk.payload; | |
92 | 95 | for (int i = 0; i < frames; i++) |
93 | 96 | buffer[channel][i] = chunkBuffer[sampleFormat_.channels() * i + channel] / 2147483648.f; |
94 | 97 | } |
97 | 100 | /* tell the library how much we actually submitted */ |
98 | 101 | vorbis_analysis_wrote(&vd_, frames); |
99 | 102 | |
100 | auto oggChunk = make_shared<msg::PcmChunk>(chunk->format, 0); | |
103 | auto oggChunk = make_shared<msg::PcmChunk>(chunk.format, 0); | |
101 | 104 | |
102 | 105 | /* vorbis does some data preanalysis, then divvies up blocks for |
103 | 106 | more involved (potentially parallel) processing. Get a single |
141 | 144 | if (res > 0) |
142 | 145 | { |
143 | 146 | res /= sampleFormat_.msRate(); |
144 | // LOG(INFO) << "res: " << res << "\n"; | |
147 | // LOG(INFO, LOG_TAG) << "res: " << res << "\n"; | |
145 | 148 | lastGranulepos_ = os_.granulepos; |
146 | 149 | // make oggChunk smaller |
147 | 150 | oggChunk->payload = (char*)realloc(oggChunk->payload, pos); |
148 | 151 | oggChunk->payloadSize = pos; |
149 | listener_->onChunkEncoded(this, oggChunk, res); | |
152 | encoded_callback_(*this, oggChunk, res); | |
150 | 153 | } |
151 | 154 | } |
152 | 155 | |
219 | 222 | vorbis_comment_init(&vc_); |
220 | 223 | vorbis_comment_add_tag(&vc_, "TITLE", "SnapStream"); |
221 | 224 | vorbis_comment_add_tag(&vc_, "VERSION", VERSION); |
222 | vorbis_comment_add_tag(&vc_, "SAMPLE_FORMAT", sampleFormat_.getFormat().c_str()); | |
225 | vorbis_comment_add_tag(&vc_, "SAMPLE_FORMAT", sampleFormat_.toString().c_str()); | |
223 | 226 | |
224 | 227 | /* set up the analysis state and auxiliary encoding storage */ |
225 | 228 | vorbis_analysis_init(&vd_, &vi_); |
259 | 262 | break; |
260 | 263 | headerChunk_->payloadSize += og_.header_len + og_.body_len; |
261 | 264 | headerChunk_->payload = (char*)realloc(headerChunk_->payload, headerChunk_->payloadSize); |
262 | LOG(DEBUG) << "HeadLen: " << og_.header_len << ", bodyLen: " << og_.body_len << ", result: " << result << "\n"; | |
265 | LOG(DEBUG, LOG_TAG) << "HeadLen: " << og_.header_len << ", bodyLen: " << og_.body_len << ", result: " << result << "\n"; | |
263 | 266 | memcpy(headerChunk_->payload + pos, og_.header, og_.header_len); |
264 | 267 | pos += og_.header_len; |
265 | 268 | memcpy(headerChunk_->payload + pos, og_.body, og_.body_len); |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef OGG_ENCODER_H | |
19 | #define OGG_ENCODER_H | |
18 | #ifndef OGG_ENCODER_HPP | |
19 | #define OGG_ENCODER_HPP | |
20 | 20 | #include "encoder.hpp" |
21 | 21 | #include <ogg/ogg.h> |
22 | 22 | #include <vorbis/vorbisenc.h> |
30 | 30 | OggEncoder(const std::string& codecOptions = ""); |
31 | 31 | ~OggEncoder() override; |
32 | 32 | |
33 | void encode(const msg::PcmChunk* chunk) override; | |
33 | void encode(const msg::PcmChunk& chunk) override; | |
34 | 34 | std::string getAvailableOptions() const override; |
35 | 35 | std::string getDefaultOptions() const override; |
36 | 36 | std::string name() const override; |
29 | 29 | #define ID_OPUS 0x4F505553 |
30 | 30 | static constexpr opus_int32 const_min_bitrate = 6000; |
31 | 31 | static constexpr opus_int32 const_max_bitrate = 512000; |
32 | static constexpr int min_chunk_size = 10; | |
33 | ||
34 | static constexpr auto LOG_TAG = "OpusEnc"; | |
32 | 35 | |
33 | 36 | namespace |
34 | 37 | { |
76 | 79 | { |
77 | 80 | // Opus is quite restrictive in sample rate and bit depth |
78 | 81 | // It can handle mono signals, but we will check for stereo |
79 | if ((sampleFormat_.rate() != 48000) || (sampleFormat_.bits() != 16) || (sampleFormat_.channels() != 2)) | |
80 | throw SnapException("Opus sampleformat must be 48000:16:2"); | |
82 | // if ((sampleFormat_.rate() != 48000) || (sampleFormat_.bits() != 16) || (sampleFormat_.channels() != 2)) | |
83 | // throw SnapException("Opus sampleformat must be 48000:16:2"); | |
84 | if (sampleFormat_.channels() != 2) | |
85 | throw SnapException("Opus requires a stereo signal"); | |
86 | SampleFormat out{48000, 16, 2}; | |
87 | if ((sampleFormat_.rate() != 48000) || (sampleFormat_.bits() != 16)) | |
88 | LOG(INFO, LOG_TAG) << "Resampling input from " << sampleFormat_.toString() << " to " << out.toString() << " as required by Opus\n"; | |
89 | ||
90 | resampler_ = make_unique<Resampler>(sampleFormat_, out); | |
91 | sampleFormat_ = out; | |
81 | 92 | |
82 | 93 | opus_int32 bitrate = 192000; |
83 | 94 | opus_int32 complexity = 10; |
131 | 142 | throw SnapException("Opus error parsing options: " + codecOptions_); |
132 | 143 | } |
133 | 144 | |
134 | LOG(INFO) << "Opus bitrate: " << bitrate << " bps, complexity: " << complexity << "\n"; | |
145 | LOG(INFO, LOG_TAG) << "Opus bitrate: " << bitrate << " bps, complexity: " << complexity << "\n"; | |
135 | 146 | |
136 | 147 | int error; |
137 | 148 | enc_ = opus_encoder_create(sampleFormat_.rate(), sampleFormat_.channels(), OPUS_APPLICATION_RESTRICTED_LOWDELAY, &error); |
152 | 163 | assign(payload + 8, SWAP_16(sampleFormat_.bits())); |
153 | 164 | assign(payload + 10, SWAP_16(sampleFormat_.channels())); |
154 | 165 | |
155 | remainder_ = std::make_unique<msg::PcmChunk>(sampleFormat_, 10); | |
166 | remainder_ = std::make_unique<msg::PcmChunk>(sampleFormat_, min_chunk_size); | |
156 | 167 | remainder_max_size_ = remainder_->payloadSize; |
157 | 168 | remainder_->payloadSize = 0; |
158 | 169 | } |
163 | 174 | // 240, 480, 960, 1920, 2880 frames |
164 | 175 | // We will split the chunk into encodable sizes and store any remaining data in the remainder_ buffer |
165 | 176 | // and encode the buffer content in the next iteration |
166 | void OpusEncoder::encode(const msg::PcmChunk* chunk) | |
167 | { | |
168 | // LOG(TRACE) << "encode " << chunk->duration<std::chrono::milliseconds>().count() << "ms\n"; | |
177 | void OpusEncoder::encode(const msg::PcmChunk& chunk) | |
178 | { | |
179 | // chunk = | |
180 | // resampler_->resample(std::make_shared<msg::PcmChunk>(chunk)).get(); | |
181 | auto in = std::make_shared<msg::PcmChunk>(chunk); | |
182 | auto out = resampler_->resample(in); //, std::chrono::milliseconds(20)); | |
183 | if (out == nullptr) | |
184 | return; | |
185 | // chunk = out.get(); | |
186 | ||
187 | // LOG(TRACE, LOG_TAG) << "encode " << chunk->duration<std::chrono::milliseconds>().count() << "ms\n"; | |
169 | 188 | uint32_t offset = 0; |
170 | 189 | |
171 | 190 | // check if there is something left from the last call to encode and fill the remainder buffer to |
172 | // an encodable size of 10ms | |
191 | // an encodable size of 10ms (min_chunk_size) | |
173 | 192 | if (remainder_->payloadSize > 0) |
174 | 193 | { |
175 | offset = std::min(static_cast<uint32_t>(remainder_max_size_ - remainder_->payloadSize), chunk->payloadSize); | |
176 | memcpy(remainder_->payload + remainder_->payloadSize, chunk->payload, offset); | |
177 | // LOG(TRACE) << "remainder buffer size: " << remainder_->payloadSize << "/" << remainder_max_size_ << ", appending " << offset << " bytes\n"; | |
194 | offset = std::min(static_cast<uint32_t>(remainder_max_size_ - remainder_->payloadSize), out->payloadSize); | |
195 | memcpy(remainder_->payload + remainder_->payloadSize, out->payload, offset); | |
196 | // LOG(TRACE, LOG_TAG) << "remainder buffer size: " << remainder_->payloadSize << "/" << remainder_max_size_ << ", appending " << offset << " bytes\n"; | |
178 | 197 | remainder_->payloadSize += offset; |
179 | 198 | |
180 | 199 | if (remainder_->payloadSize < remainder_max_size_) |
181 | 200 | { |
182 | LOG(DEBUG) << "not enough data to encode (" << remainder_->payloadSize << " of " << remainder_max_size_ << " bytes)" | |
183 | << "\n"; | |
201 | LOG(DEBUG, LOG_TAG) << "not enough data to encode (" << remainder_->payloadSize << " of " << remainder_max_size_ << " bytes)" | |
202 | << "\n"; | |
184 | 203 | return; |
185 | 204 | } |
186 | encode(chunk->format, remainder_->payload, remainder_->payloadSize); | |
205 | encode(out->format, remainder_->payload, remainder_->payloadSize); | |
187 | 206 | remainder_->payloadSize = 0; |
188 | 207 | } |
189 | 208 | |
190 | 209 | // encode greedy 60ms, 40ms, 20ms, 10ms chunks |
191 | std::vector<size_t> chunk_durations{60, 40, 20, 10}; | |
210 | std::vector<size_t> chunk_durations{60, 40, 20, min_chunk_size}; | |
192 | 211 | for (const auto duration : chunk_durations) |
193 | 212 | { |
194 | 213 | auto ms2bytes = [this](size_t ms) { return (ms * sampleFormat_.msRate() * sampleFormat_.frameSize()); }; |
195 | 214 | uint32_t bytes = ms2bytes(duration); |
196 | while (chunk->payloadSize - offset >= bytes) | |
215 | while (out->payloadSize - offset >= bytes) | |
197 | 216 | { |
198 | // LOG(TRACE) << "encoding " << duration << "ms (" << bytes << "), offset: " << offset << ", chunk size: " << chunk->payloadSize - offset << "\n"; | |
199 | encode(chunk->format, chunk->payload + offset, bytes); | |
217 | // LOG(TRACE, LOG_TAG) << "encoding " << duration << "ms (" << bytes << "), offset: " << offset << ", chunk size: " << chunk->payloadSize - offset | |
218 | // << "\n"; | |
219 | encode(out->format, out->payload + offset, bytes); | |
200 | 220 | offset += bytes; |
201 | 221 | } |
202 | if (chunk->payloadSize == offset) | |
222 | if (out->payloadSize == offset) | |
203 | 223 | break; |
204 | 224 | } |
205 | 225 | |
206 | // something is left (must be less than 10ms) | |
207 | if (chunk->payloadSize > offset) | |
208 | { | |
209 | memcpy(remainder_->payload + remainder_->payloadSize, chunk->payload + offset, chunk->payloadSize - offset); | |
210 | remainder_->payloadSize = chunk->payloadSize - offset; | |
226 | // something is left (must be less than min_chunk_size (10ms)) | |
227 | if (out->payloadSize > offset) | |
228 | { | |
229 | memcpy(remainder_->payload + remainder_->payloadSize, out->payload + offset, out->payloadSize - offset); | |
230 | remainder_->payloadSize = out->payloadSize - offset; | |
211 | 231 | } |
212 | 232 | } |
213 | 233 | |
215 | 235 | void OpusEncoder::encode(const SampleFormat& format, const char* data, size_t size) |
216 | 236 | { |
217 | 237 | // void* buffer; |
218 | // LOG(INFO) << "frames: " << chunk->readFrames(buffer, std::chrono::milliseconds(10)) << "\n"; | |
238 | // LOG(INFO, LOG_TAG) << "frames: " << chunk->readFrames(buffer, std::chrono::milliseconds(10)) << "\n"; | |
219 | 239 | int samples_per_channel = size / format.frameSize(); |
220 | 240 | if (encoded_.size() < size) |
221 | 241 | encoded_.resize(size); |
222 | 242 | |
223 | 243 | opus_int32 len = opus_encode(enc_, (opus_int16*)data, samples_per_channel, encoded_.data(), size); |
224 | // LOG(TRACE) << "Encode " << samples_per_channel << " frames, size " << size << " bytes, encoded: " << len << " bytes" << '\n'; | |
244 | LOG(TRACE, LOG_TAG) << "Encode " << samples_per_channel << " frames, size " << size << " bytes, encoded: " << len << " bytes" << '\n'; | |
225 | 245 | |
226 | 246 | if (len > 0) |
227 | 247 | { |
230 | 250 | opusChunk->payloadSize = len; |
231 | 251 | opusChunk->payload = (char*)realloc(opusChunk->payload, opusChunk->payloadSize); |
232 | 252 | memcpy(opusChunk->payload, encoded_.data(), len); |
233 | listener_->onChunkEncoded(this, opusChunk, (double)samples_per_channel / sampleFormat_.msRate()); | |
253 | encoded_callback_(*this, opusChunk, (double)samples_per_channel / sampleFormat_.msRate()); | |
234 | 254 | } |
235 | 255 | else |
236 | 256 | { |
237 | LOG(ERROR) << "Failed to encode chunk: " << opus_strerror(len) << ", samples / channel: " << samples_per_channel << ", bytes: " << size << '\n'; | |
257 | LOG(ERROR, LOG_TAG) << "Failed to encode chunk: " << opus_strerror(len) << ", samples / channel: " << samples_per_channel << ", bytes: " << size | |
258 | << '\n'; | |
238 | 259 | } |
239 | 260 | } |
240 | 261 |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #pragma once | |
18 | #ifndef OPUS_ENCODER_HPP | |
19 | #define OPUS_ENCODER_HPP | |
19 | 20 | |
21 | #include "common/resampler.hpp" | |
20 | 22 | #include "encoder.hpp" |
21 | 23 | #include <opus/opus.h> |
22 | 24 | |
30 | 32 | OpusEncoder(const std::string& codecOptions = ""); |
31 | 33 | ~OpusEncoder() override; |
32 | 34 | |
33 | void encode(const msg::PcmChunk* chunk) override; | |
35 | void encode(const msg::PcmChunk& chunk) override; | |
34 | 36 | std::string getAvailableOptions() const override; |
35 | 37 | std::string getDefaultOptions() const override; |
36 | 38 | std::string name() const override; |
42 | 44 | std::vector<unsigned char> encoded_; |
43 | 45 | std::unique_ptr<msg::PcmChunk> remainder_; |
44 | 46 | size_t remainder_max_size_; |
47 | std::unique_ptr<Resampler> resampler_; | |
45 | 48 | }; |
46 | 49 | |
47 | 50 | } // namespace encoder |
51 | ||
52 | #endif |
46 | 46 | } |
47 | 47 | |
48 | 48 | |
49 | void PcmEncoder::encode(const msg::PcmChunk* chunk) | |
49 | void PcmEncoder::encode(const msg::PcmChunk& chunk) | |
50 | 50 | { |
51 | auto pcmChunk = std::make_shared<msg::PcmChunk>(*chunk); | |
52 | listener_->onChunkEncoded(this, pcmChunk, pcmChunk->durationMs()); | |
51 | // copy the chunk into a shared_ptr | |
52 | auto pcmChunk = std::make_shared<msg::PcmChunk>(chunk); | |
53 | encoded_callback_(*this, pcmChunk, pcmChunk->durationMs()); | |
53 | 54 | } |
54 | 55 | |
55 | 56 |
15 | 15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | ***/ |
17 | 17 | |
18 | #ifndef PCM_ENCODER_H | |
19 | #define PCM_ENCODER_H | |
18 | #ifndef PCM_ENCODER_HPP | |
19 | #define PCM_ENCODER_HPP | |
20 | 20 | #include "encoder.hpp" |
21 | 21 | |
22 | 22 | namespace encoder |
26 | 26 | { |
27 | 27 | public: |
28 | 28 | PcmEncoder(const std::string& codecOptions = ""); |
29 | void encode(const msg::PcmChunk* chunk) override; | |
29 | void encode(const msg::PcmChunk& chunk) override; | |
30 | 30 | std::string name() const override; |
31 | 31 | |
32 | 32 | protected: |
0 | #!/bin/sh | |
1 | ||
2 | # PROVIDE: snapserver | |
3 | # REQUIRE: DAEMON | |
4 | # KEYWORD: Snapserver | |
5 | # | |
6 | # Snapserver - Snapcast server | |
7 | # | |
8 | # Add the following line to /etc/rc.conf to enable snapserver: | |
9 | # snapserver_enable=YES | |
10 | # Add snapserver_opts=<options> to configure command line arguments | |
11 | ||
12 | snapserver_opts="-d --logging.sink=system" | |
13 | ||
14 | . /etc/rc.subr | |
15 | ||
16 | name=snapserver | |
17 | rcvar=snapserver_enable | |
18 | desc="Snapserver - Snapcast server" | |
19 | ||
20 | load_rc_config $name | |
21 | ||
22 | : ${snapserver_enable:=NO} | |
23 | ||
24 | command=/usr/local/bin/${name} | |
25 | pidfile="/var/run/${name}/pid" | |
26 | ||
27 | start_cmd=snapserver_start | |
28 | ||
29 | ||
30 | snapserver_start() { | |
31 | checkyesno snapserver_enable && echo "Starting snapserver." && ${command} ${snapserver_opts} | |
32 | } | |
33 | ||
34 | run_rc_command $1 | |
35 |
31 | 31 | # the pid file when running as daemon |
32 | 32 | #pidfile = /var/run/snapserver/pid |
33 | 33 | |
34 | # the user to run as when daemonized | |
35 | #user = snapserver | |
36 | # the group to run as when daemonized | |
37 | #group = snapserver | |
38 | ||
34 | 39 | # directory where persistent data is stored (server.json) |
35 | 40 | # if empty, data dir will be |
36 | 41 | # - "/var/lib/snapserver/" when running as daemon |
58 | 63 | #port = 1780 |
59 | 64 | |
60 | 65 | # serve a website from the doc_root location |
61 | #doc_root = | |
66 | # disabled if commented or empty | |
67 | doc_root = /usr/share/snapserver/snapweb | |
62 | 68 | # |
63 | 69 | ############################################################################### |
64 | 70 | |
95 | 101 | # which port the server should listen to |
96 | 102 | #port = 1704 |
97 | 103 | |
98 | # stream URI of the PCM input stream, can be configured multiple times | |
104 | # source URI of the PCM input stream, can be configured multiple times | |
99 | 105 | # The following notation is used in this paragraph: |
100 | 106 | # <angle brackets>: the whole expression must be replaced with your specific setting |
101 | 107 | # [square brackets]: the whole expression is optional and can be left out |
103 | 109 | # |
104 | 110 | # Format: TYPE://host/path?name=<name>[&codec=<codec>][&sampleformat=<sampleformat>][&chunk_ms=<chunk ms>] |
105 | 111 | # parameters have the form "key=value", they are concatenated with an "&" character |
106 | # parameter "name" is mandatory for all streams, while codec, sampleformat and chunk_ms are optional | |
112 | # parameter "name" is mandatory for all sources, while codec, sampleformat and chunk_ms are optional | |
107 | 113 | # and will override the default codec, sampleformat or chunk_ms settings |
108 | # Non blocking streams support the dryout_ms parameter: when no new data is read from the stream, send silence to the clients | |
114 | # Non blocking sources support the dryout_ms parameter: when no new data is read from the source, send silence to the clients | |
109 | 115 | # Available types are: |
110 | 116 | # pipe: pipe:///<path/to/pipe>?name=<name>[&mode=create][&dryout_ms=2000], mode can be "create" or "read" |
111 | 117 | # librespot: librespot:///<path/to/librespot>?name=<name>[&dryout_ms=2000][&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&nomalize=false][&autoplay=false] |
118 | 124 | # sampleformat will be set to "44100:16:2" |
119 | 125 | # tcp server: tcp://<listen IP, e.g. 127.0.0.1>:<port>?name=<name>[&mode=server] |
120 | 126 | # tcp client: tcp://<server IP, e.g. 127.0.0.1>:<port>?name=<name>&mode=client |
121 | stream = pipe:///tmp/snapfifo?name=default | |
122 | #stream = tcp://127.0.0.1?name=mopidy_tcp | |
127 | # alsa: alsa://?name=<name>&device=<alsa device> | |
128 | # meta: meta:///<name of source#1>/<name of source#2>/.../<name of source#N>?name=<name> | |
129 | source = pipe:///tmp/snapfifo?name=default | |
130 | #source = tcp://127.0.0.1?name=mopidy_tcp | |
123 | 131 | |
124 | 132 | # Default sample format |
125 | 133 | #sampleformat = 48000:16:2 |
129 | 137 | # Type codec:? to get codec specific options |
130 | 138 | #codec = flac |
131 | 139 | |
132 | # Default stream read chunk size [ms] | |
140 | # Default source stream read chunk size [ms] | |
133 | 141 | #chunk_ms = 20 |
134 | 142 | |
135 | 143 | # Buffer [ms] |
145 | 153 | # |
146 | 154 | [logging] |
147 | 155 | |
148 | # enable debug logging | |
149 | #debug = false | |
156 | # log sink [null,system,stdout,stderr,file:<filename>] | |
157 | # when left empty: if running as daemon "system" else "stdout" | |
158 | #sink = | |
150 | 159 | |
151 | # log file name for the debug logs (debug must be enabled) | |
152 | #debug_logfile = | |
160 | # log filter <tag>:<level>[,<tag>:<level>]* | |
161 | # with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal] | |
162 | #filter = *:info | |
153 | 163 | # |
154 | 164 | ############################################################################### |
6 | 6 | <key>ProgramArguments</key> |
7 | 7 | <array> |
8 | 8 | <string>/usr/local/bin/snapserver</string> |
9 | <string>--logging.sink=system</string> | |
9 | 10 | <!-- <string>-d</string> --> |
10 | 11 | </array> |
11 | 12 | <key>RunAtLoad</key> |
Binary diff not shown
0 | <html> | |
1 | ||
2 | <head> | |
3 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
4 | <meta name="theme-color" content="#455A64"> | |
5 | <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> | |
6 | <link rel="manifest" href="manifest.json"> | |
7 | <link rel="stylesheet" href="styles.css"> | |
8 | <link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> | |
9 | <title>Snapweb</title> | |
10 | <script src="3rd-party/libflac.js"></script> | |
11 | <script src="snapstream.js"></script> | |
12 | <script src="snapcontrol.js"></script> | |
13 | </head> | |
14 | ||
15 | <body> | |
16 | <div id="show"></div> | |
17 | </body> | |
18 | ||
19 | </html>⏎ |
Binary diff not shown
0 | { | |
1 | "short_name": "Snapweb", | |
2 | "name": "Snapcast WebApp", | |
3 | "icons": [ | |
4 | { | |
5 | "src": "launcher-icon.png", | |
6 | "sizes": "192x192", | |
7 | "type": "image/png" | |
8 | } | |
9 | ], | |
10 | "start_url": "/index.html", | |
11 | "display": "standalone", | |
12 | "categories": ["music"], | |
13 | "description": "Snapcast web client", | |
14 | "theme_color": "#455A64" | |
15 | } |
Binary diff not shown
Binary diff not shown
0 | "use strict"; | |
1 | class Host { | |
2 | constructor(json) { | |
3 | this.arch = ""; | |
4 | this.ip = ""; | |
5 | this.mac = ""; | |
6 | this.name = ""; | |
7 | this.os = ""; | |
8 | this.fromJson(json); | |
9 | } | |
10 | fromJson(json) { | |
11 | this.arch = json.arch; | |
12 | this.ip = json.ip; | |
13 | this.mac = json.mac; | |
14 | this.name = json.name; | |
15 | this.os = json.os; | |
16 | } | |
17 | } | |
18 | class Client { | |
19 | constructor(json) { | |
20 | this.id = ""; | |
21 | this.connected = false; | |
22 | this.fromJson(json); | |
23 | } | |
24 | fromJson(json) { | |
25 | this.id = json.id; | |
26 | this.host = new Host(json.host); | |
27 | let jsnapclient = json.snapclient; | |
28 | this.snapclient = { name: jsnapclient.name, protocolVersion: jsnapclient.protocolVersion, version: jsnapclient.version }; | |
29 | let jconfig = json.config; | |
30 | this.config = { instance: jconfig.instance, latency: jconfig.latency, name: jconfig.name, volume: { muted: jconfig.volume.muted, percent: jconfig.volume.percent } }; | |
31 | this.lastSeen = { sec: json.lastSeen.sec, usec: json.lastSeen.usec }; | |
32 | this.connected = Boolean(json.connected); | |
33 | } | |
34 | } | |
35 | class Group { | |
36 | constructor(json) { | |
37 | this.name = ""; | |
38 | this.id = ""; | |
39 | this.stream_id = ""; | |
40 | this.muted = false; | |
41 | this.clients = []; | |
42 | this.fromJson(json); | |
43 | } | |
44 | fromJson(json) { | |
45 | this.name = json.name; | |
46 | this.id = json.id; | |
47 | this.stream_id = json.stream_id; | |
48 | this.muted = Boolean(json.muted); | |
49 | for (let client of json.clients) | |
50 | this.clients.push(new Client(client)); | |
51 | } | |
52 | getClient(id) { | |
53 | for (let client of this.clients) { | |
54 | if (client.id == id) | |
55 | return client; | |
56 | } | |
57 | return null; | |
58 | } | |
59 | } | |
60 | class Stream { | |
61 | constructor(json) { | |
62 | this.id = ""; | |
63 | this.status = ""; | |
64 | this.fromJson(json); | |
65 | } | |
66 | fromJson(json) { | |
67 | this.id = json.id; | |
68 | this.status = json.status; | |
69 | let juri = json.uri; | |
70 | this.uri = { raw: juri.raw, scheme: juri.scheme, host: juri.host, path: juri.path, fragment: juri.fragment, query: juri.query }; | |
71 | } | |
72 | } | |
73 | class Server { | |
74 | constructor(json) { | |
75 | this.groups = []; | |
76 | this.streams = []; | |
77 | if (json) | |
78 | this.fromJson(json); | |
79 | } | |
80 | fromJson(json) { | |
81 | this.groups = []; | |
82 | for (let jgroup of json.groups) | |
83 | this.groups.push(new Group(jgroup)); | |
84 | let jsnapserver = json.server.snapserver; | |
85 | this.server = { host: new Host(json.server.host), snapserver: { controlProtocolVersion: jsnapserver.controlProtocolVersion, name: jsnapserver.name, protocolVersion: jsnapserver.protocolVersion, version: jsnapserver.version } }; | |
86 | this.streams = []; | |
87 | for (let jstream of json.streams) { | |
88 | this.streams.push(new Stream(jstream)); | |
89 | } | |
90 | } | |
91 | getClient(id) { | |
92 | for (let group of this.groups) { | |
93 | let client = group.getClient(id); | |
94 | if (client) | |
95 | return client; | |
96 | } | |
97 | return null; | |
98 | } | |
99 | getGroup(id) { | |
100 | for (let group of this.groups) { | |
101 | if (group.id == id) | |
102 | return group; | |
103 | } | |
104 | return null; | |
105 | } | |
106 | getStream(id) { | |
107 | for (let stream of this.streams) { | |
108 | if (stream.id == id) | |
109 | return stream; | |
110 | } | |
111 | return null; | |
112 | } | |
113 | } | |
114 | class SnapControl { | |
115 | constructor(host, port) { | |
116 | this.server = new Server(); | |
117 | this.connection = new WebSocket('ws://' + host + ':' + port + '/jsonrpc'); | |
118 | this.msg_id = 0; | |
119 | this.status_req_id = -1; | |
120 | // console.log(navigator); | |
121 | this.connection.onmessage = (msg) => this.onMessage(msg.data); | |
122 | this.connection.onopen = (ev) => { this.status_req_id = this.sendRequest('Server.GetStatus'); }; | |
123 | this.connection.onerror = (ev) => { alert("error: " + ev.type); }; //this.onError(ev); | |
124 | } | |
125 | action(answer) { | |
126 | switch (answer.method) { | |
127 | case 'Client.OnVolumeChanged': | |
128 | let client = this.getClient(answer.params.id); | |
129 | client.config.volume = answer.params.volume; | |
130 | updateGroupVolume(this.getGroupFromClient(client.id)); | |
131 | break; | |
132 | case 'Client.OnLatencyChanged': | |
133 | this.getClient(answer.params.id).config.latency = answer.params.latency; | |
134 | break; | |
135 | case 'Client.OnNameChanged': | |
136 | this.getClient(answer.params.id).config.name = answer.params.name; | |
137 | break; | |
138 | case 'Client.OnConnect': | |
139 | case 'Client.OnDisconnect': | |
140 | this.getClient(answer.params.client.id).fromJson(answer.params.client); | |
141 | break; | |
142 | case 'Group.OnMute': | |
143 | this.getGroup(answer.params.id).muted = Boolean(answer.params.mute); | |
144 | break; | |
145 | case 'Group.OnStreamChanged': | |
146 | this.getGroup(answer.params.id).stream_id = answer.params.stream_id; | |
147 | break; | |
148 | case 'Stream.OnUpdate': | |
149 | this.getStream(answer.params.id).fromJson(answer.params.stream); | |
150 | break; | |
151 | case 'Server.OnUpdate': | |
152 | this.server.fromJson(answer.params.server); | |
153 | break; | |
154 | default: | |
155 | break; | |
156 | } | |
157 | } | |
158 | getClient(client_id) { | |
159 | return this.server.getClient(client_id); | |
160 | } | |
161 | getGroup(group_id) { | |
162 | return this.server.getGroup(group_id); | |
163 | } | |
164 | getGroupVolume(group, online) { | |
165 | if (group.clients.length == 0) | |
166 | return 0; | |
167 | let group_vol = 0; | |
168 | let client_count = 0; | |
169 | for (let client of group.clients) { | |
170 | if (online && !client.connected) | |
171 | continue; | |
172 | group_vol += client.config.volume.percent; | |
173 | ++client_count; | |
174 | } | |
175 | if (client_count == 0) | |
176 | return 0; | |
177 | return group_vol / client_count; | |
178 | } | |
179 | getGroupFromClient(client_id) { | |
180 | for (let group of this.server.groups) | |
181 | for (let client of group.clients) | |
182 | if (client.id == client_id) | |
183 | return group; | |
184 | return null; | |
185 | } | |
186 | getStream(stream_id) { | |
187 | return this.server.getStream(stream_id); | |
188 | } | |
189 | setVolume(client_id, percent, mute) { | |
190 | percent = Math.max(0, Math.min(100, percent)); | |
191 | let client = this.getClient(client_id); | |
192 | client.config.volume.percent = percent; | |
193 | if (mute != undefined) | |
194 | client.config.volume.muted = mute; | |
195 | this.sendRequest('Client.SetVolume', '{"id":"' + client_id + '","volume":{"muted":' + (client.config.volume.muted ? "true" : "false") + ',"percent":' + client.config.volume.percent + '}}'); | |
196 | } | |
197 | setClientName(client_id, name) { | |
198 | let client = this.getClient(client_id); | |
199 | let current_name = (client.config.name != "") ? client.config.name : client.host.name; | |
200 | if (name != current_name) { | |
201 | this.sendRequest('Client.SetName', '{"id":"' + client_id + '","name":"' + name + '"}'); | |
202 | client.config.name = name; | |
203 | } | |
204 | } | |
205 | setClientLatency(client_id, latency) { | |
206 | let client = this.getClient(client_id); | |
207 | let current_latency = client.config.latency; | |
208 | if (latency != current_latency) { | |
209 | this.sendRequest('Client.SetLatency', '{"id":"' + client_id + '","latency":' + latency + '}'); | |
210 | client.config.latency = latency; | |
211 | } | |
212 | } | |
213 | deleteClient(client_id) { | |
214 | let client = this.getClient(client_id); | |
215 | this.sendRequest('Server.DeleteClient', '{"id": "' + client_id + '"}'); | |
216 | this.server.groups.forEach((g, gi) => { | |
217 | g.clients.forEach((c, ci) => { | |
218 | if (c.id == client_id) { | |
219 | this.server.groups[gi].clients.splice(ci, 1); | |
220 | } | |
221 | }); | |
222 | }); | |
223 | this.server.groups.forEach((g, gi) => { | |
224 | if (g.clients.length == 0) { | |
225 | this.server.groups.splice(gi, 1); | |
226 | } | |
227 | }); | |
228 | show(); | |
229 | } | |
230 | setStream(group_id, stream_id) { | |
231 | this.getGroup(group_id).stream_id = stream_id; | |
232 | this.sendRequest('Group.SetStream', '{"id":"' + group_id + '","stream_id":"' + stream_id + '"}'); | |
233 | } | |
234 | setClients(group_id, clients) { | |
235 | this.status_req_id = this.sendRequest('Group.SetClients', '{"clients":' + JSON.stringify(clients) + ',"id":"' + group_id + '"}'); | |
236 | } | |
237 | muteGroup(group_id, mute) { | |
238 | this.getGroup(group_id).muted = mute; | |
239 | this.sendRequest('Group.SetMute', '{"id":"' + group_id + '","mute":' + (mute ? "true" : "false") + '}'); | |
240 | } | |
241 | sendRequest(method, params) { | |
242 | let msg = '{"id": ' + (++this.msg_id) + ',"jsonrpc":"2.0","method":"' + method + '"'; | |
243 | if (params) | |
244 | msg += ',"params": ' + params; | |
245 | msg += '}'; | |
246 | console.log("Sending: " + msg); | |
247 | this.connection.send(msg); | |
248 | return this.msg_id; | |
249 | } | |
250 | onMessage(msg) { | |
251 | let answer = JSON.parse(msg); | |
252 | let is_response = (answer.id != undefined); | |
253 | console.log("Received " + (is_response ? "response" : "notification") + ", json: " + JSON.stringify(answer)); | |
254 | if (is_response) { | |
255 | if (answer.id == this.status_req_id) { | |
256 | this.server = new Server(answer.result.server); | |
257 | show(); | |
258 | } | |
259 | } | |
260 | else { | |
261 | if (Array.isArray(answer)) { | |
262 | for (let a of answer) { | |
263 | this.action(a); | |
264 | } | |
265 | } | |
266 | else { | |
267 | this.action(answer); | |
268 | } | |
269 | // TODO: don't update everything, but only the changed, | |
270 | // e.g. update the values for the volume sliders | |
271 | show(); | |
272 | } | |
273 | } | |
274 | } | |
275 | let snapcontrol; | |
276 | let snapstream = null; | |
277 | let hide_offline = true; | |
278 | let autoplay_done = false; | |
279 | function autoplayRequested() { | |
280 | return document.location.hash.match(/autoplay/) !== null; | |
281 | } | |
282 | function show() { | |
283 | // Render the page | |
284 | let play_img; | |
285 | if (snapstream) { | |
286 | play_img = 'stop.png'; | |
287 | } | |
288 | else { | |
289 | play_img = 'play.png'; | |
290 | } | |
291 | let content = ""; | |
292 | content += "<div class='navbar'>Snapcast"; | |
293 | let serverVersion = snapcontrol.server.server.snapserver.version.split('.'); | |
294 | if ((serverVersion.length >= 2) && (+serverVersion[1] >= 21)) { | |
295 | content += " <a href=\"javascript:play();\"><img src='" + play_img + "' class='play-button'></a>"; | |
296 | // Stream became ready and was not playing. If autoplay is requested, start playing. | |
297 | if (!snapstream && !autoplay_done && autoplayRequested()) { | |
298 | autoplay_done = true; | |
299 | play(); | |
300 | } | |
301 | } | |
302 | content += "</div>"; | |
303 | content += "<div class='content'>"; | |
304 | let server = snapcontrol.server; | |
305 | for (let group of server.groups) { | |
306 | if (hide_offline) { | |
307 | let groupActive = false; | |
308 | for (let client of group.clients) { | |
309 | if (client.connected) { | |
310 | groupActive = true; | |
311 | break; | |
312 | } | |
313 | } | |
314 | if (!groupActive) | |
315 | continue; | |
316 | } | |
317 | // Set mute variables | |
318 | let classgroup; | |
319 | let muted; | |
320 | let mute_img; | |
321 | if (group.muted == true) { | |
322 | classgroup = 'group muted'; | |
323 | muted = true; | |
324 | mute_img = 'mute_icon.png'; | |
325 | } | |
326 | else { | |
327 | classgroup = 'group'; | |
328 | muted = false; | |
329 | mute_img = 'speaker_icon.png'; | |
330 | } | |
331 | // Start group div | |
332 | content += "<div id='g_" + group.id + "' class='" + classgroup + "'>"; | |
333 | // Create stream selection dropdown | |
334 | let streamselect = "<select id='stream_" + group.id + "' onchange='setStream(\"" + group.id + "\")' class='stream'>"; | |
335 | for (let i_stream = 0; i_stream < server.streams.length; i_stream++) { | |
336 | let streamselected = ""; | |
337 | if (group.stream_id == server.streams[i_stream].id) { | |
338 | streamselected = 'selected'; | |
339 | } | |
340 | streamselect += "<option value='" + server.streams[i_stream].id + "' " + streamselected + ">" + server.streams[i_stream].id + ": " + server.streams[i_stream].status + "</option>"; | |
341 | } | |
342 | streamselect += "</select>"; | |
343 | // Group mute and refresh button | |
344 | content += "<div class='groupheader'>"; | |
345 | content += streamselect; | |
346 | let clientCount = 0; | |
347 | for (let client of group.clients) | |
348 | if (!hide_offline || client.connected) | |
349 | clientCount++; | |
350 | if (clientCount > 1) { | |
351 | let volume = snapcontrol.getGroupVolume(group, hide_offline); | |
352 | content += "<a href=\"javascript:setMuteGroup('" + group.id + "'," + !muted + ");\"><img src='" + mute_img + "' class='mute-button'></a>"; | |
353 | content += "<div class='slidergroupdiv'>"; | |
354 | content += " <input type='range' draggable='false' min=0 max=100 step=1 id='vol_" + group.id + "' oninput='javascript:setGroupVolume(\"" + group.id + "\")' value=" + volume + " class='slider'>"; | |
355 | // content += " <input type='range' min=0 max=100 step=1 id='vol_" + group.id + "' oninput='javascript:setVolume(\"" + client.id + "\"," + client.config.volume.muted + ")' value=" + client.config.volume.percent + " class='" + sliderclass + "'>"; | |
356 | content += "</div>"; | |
357 | } | |
358 | // transparent placeholder edit icon | |
359 | content += "<div class='edit-group-icon'>✎</div>"; | |
360 | content += "</div>"; | |
361 | content += "<hr class='groupheader-separator'>"; | |
362 | // Create clients in group | |
363 | for (let client of group.clients) { | |
364 | if (!client.connected && hide_offline) | |
365 | continue; | |
366 | // Set name and connection state vars, start client div | |
367 | let name; | |
368 | let clas = 'client'; | |
369 | if (client.config.name != "") { | |
370 | name = client.config.name; | |
371 | } | |
372 | else { | |
373 | name = client.host.name; | |
374 | } | |
375 | if (client.connected == false) { | |
376 | clas = 'client disconnected'; | |
377 | } | |
378 | content += "<div id='c_" + client.id + "' class='" + clas + "'>"; | |
379 | // Client mute status vars | |
380 | let muted; | |
381 | let mute_img; | |
382 | let sliderclass; | |
383 | if (client.config.volume.muted == true) { | |
384 | muted = true; | |
385 | sliderclass = 'slider muted'; | |
386 | mute_img = 'mute_icon.png'; | |
387 | } | |
388 | else { | |
389 | sliderclass = 'slider'; | |
390 | muted = false; | |
391 | mute_img = 'speaker_icon.png'; | |
392 | } | |
393 | // Populate client div | |
394 | content += "<a href=\"javascript:setVolume('" + client.id + "'," + !muted + ");\"><img src='" + mute_img + "' class='mute-button'></a>"; | |
395 | content += " <div class='sliderdiv'>"; | |
396 | content += " <input type='range' min=0 max=100 step=1 id='vol_" + client.id + "' oninput='javascript:setVolume(\"" + client.id + "\"," + client.config.volume.muted + ")' value=" + client.config.volume.percent + " class='" + sliderclass + "'>"; | |
397 | content += " </div>"; | |
398 | content += " <span class='edit-icons'>"; | |
399 | content += " <a href=\"javascript:openClientSettings('" + client.id + "');\" class='edit-icon'>✎</a>"; | |
400 | if (client.connected == false) { | |
401 | content += " <a href=\"javascript:deleteClient('" + client.id + "');\" class='delete-icon'>🗑</a>"; | |
402 | content += " </span>"; | |
403 | } | |
404 | else { | |
405 | content += "</span>"; | |
406 | } | |
407 | content += " <div class='name'>" + name + "</div>"; | |
408 | content += "</div>"; | |
409 | } | |
410 | content += "</div>"; | |
411 | } | |
412 | content += "</div>"; // content | |
413 | content += "<div id='client-settings' class='client-settings'>"; | |
414 | content += " <div class='client-setting-content'>"; | |
415 | content += " <form action='javascript:closeClientSettings()'>"; | |
416 | content += " <label for='client-name'>Name</label>"; | |
417 | content += " <input type='text' class='client-input' id='client-name' name='client-name' placeholder='Client name..'>"; | |
418 | content += " <label for='client-latency'>Latency</label>"; | |
419 | content += " <input type='number' class='client-input' min='-10000' max='10000' id='client-latency' name='client-latency' placeholder='Latency in ms..'>"; | |
420 | content += " <label for='client-group'>Group</label>"; | |
421 | content += " <select id='client-group' class='client-input' name='client-group'>"; | |
422 | content += " </select>"; | |
423 | content += " <input type='submit' value='Submit'>"; | |
424 | content += " </form>"; | |
425 | content += " </div>"; | |
426 | content += "</div>"; | |
427 | // Pad then update page | |
428 | content = content + "<br><br>"; | |
429 | document.getElementById('show').innerHTML = content; | |
430 | for (let group of snapcontrol.server.groups) { | |
431 | if (group.clients.length > 1) { | |
432 | let slider = document.getElementById("vol_" + group.id); | |
433 | if (slider == null) | |
434 | continue; | |
435 | slider.addEventListener('pointerdown', function (ev) { | |
436 | groupVolumeEnter(group.id); | |
437 | }); | |
438 | slider.addEventListener('touchstart', function () { | |
439 | groupVolumeEnter(group.id); | |
440 | }); | |
441 | } | |
442 | } | |
443 | } | |
444 | function updateGroupVolume(group) { | |
445 | let group_vol = snapcontrol.getGroupVolume(group, hide_offline); | |
446 | let slider = document.getElementById("vol_" + group.id); | |
447 | if (slider == null) | |
448 | return; | |
449 | console.log("updateGroupVolume group: " + group.id + ", volume: " + group_vol + ", slider: " + (slider != null)); | |
450 | slider.value = String(group_vol); | |
451 | } | |
452 | let client_volumes; | |
453 | let group_volume; | |
454 | function setGroupVolume(group_id) { | |
455 | let group = snapcontrol.getGroup(group_id); | |
456 | let percent = document.getElementById('vol_' + group.id).valueAsNumber; | |
457 | console.log("setGroupVolume id: " + group.id + ", volume: " + percent); | |
458 | // show() | |
459 | let delta = percent - group_volume; | |
460 | let ratio; | |
461 | if (delta < 0) | |
462 | ratio = (group_volume - percent) / group_volume; | |
463 | else | |
464 | ratio = (percent - group_volume) / (100 - group_volume); | |
465 | for (let i = 0; i < group.clients.length; ++i) { | |
466 | let new_volume = client_volumes[i]; | |
467 | if (delta < 0) | |
468 | new_volume -= ratio * client_volumes[i]; | |
469 | else | |
470 | new_volume += ratio * (100 - client_volumes[i]); | |
471 | let client_id = group.clients[i].id; | |
472 | // TODO: use batch request to update all client volumes at once | |
473 | snapcontrol.setVolume(client_id, new_volume); | |
474 | let slider = document.getElementById('vol_' + client_id); | |
475 | if (slider) | |
476 | slider.value = String(new_volume); | |
477 | } | |
478 | } | |
479 | function groupVolumeEnter(group_id) { | |
480 | let group = snapcontrol.getGroup(group_id); | |
481 | let percent = document.getElementById('vol_' + group.id).valueAsNumber; | |
482 | console.log("groupVolumeEnter id: " + group.id + ", volume: " + percent); | |
483 | group_volume = percent; | |
484 | client_volumes = []; | |
485 | for (let i = 0; i < group.clients.length; ++i) { | |
486 | client_volumes.push(group.clients[i].config.volume.percent); | |
487 | } | |
488 | // show() | |
489 | } | |
490 | function setVolume(id, mute) { | |
491 | console.log("setVolume id: " + id + ", mute: " + mute); | |
492 | let percent = document.getElementById('vol_' + id).valueAsNumber; | |
493 | let client = snapcontrol.getClient(id); | |
494 | let needs_update = (mute != client.config.volume.muted); | |
495 | snapcontrol.setVolume(id, percent, mute); | |
496 | let group = snapcontrol.getGroupFromClient(id); | |
497 | updateGroupVolume(group); | |
498 | if (needs_update) | |
499 | show(); | |
500 | } | |
501 | function play() { | |
502 | if (snapstream) { | |
503 | snapstream.stop(); | |
504 | snapstream = null; | |
505 | } | |
506 | else { | |
507 | snapstream = new SnapStream(window.location.hostname, 1780); | |
508 | } | |
509 | show(); | |
510 | } | |
511 | function setMuteGroup(id, mute) { | |
512 | snapcontrol.muteGroup(id, mute); | |
513 | show(); | |
514 | } | |
515 | function setStream(id) { | |
516 | snapcontrol.setStream(id, document.getElementById('stream_' + id).value); | |
517 | show(); | |
518 | } | |
519 | function setGroup(client_id, group_id) { | |
520 | console.log("setGroup id: " + client_id + ", group: " + group_id); | |
521 | let server = snapcontrol.server; | |
522 | // Get client group id | |
523 | let current_group = snapcontrol.getGroupFromClient(client_id); | |
524 | // Get | |
525 | // List of target group's clients | |
526 | // OR | |
527 | // List of current group's other clients | |
528 | let send_clients = []; | |
529 | for (let i_group = 0; i_group < server.groups.length; i_group++) { | |
530 | if (server.groups[i_group].id == group_id || (group_id == "new" && server.groups[i_group].id == current_group.id)) { | |
531 | for (let i_client = 0; i_client < server.groups[i_group].clients.length; i_client++) { | |
532 | if (group_id == "new" && server.groups[i_group].clients[i_client].id == client_id) { } | |
533 | else { | |
534 | send_clients[send_clients.length] = server.groups[i_group].clients[i_client].id; | |
535 | } | |
536 | } | |
537 | } | |
538 | } | |
539 | if (group_id == "new") | |
540 | group_id = current_group.id; | |
541 | else | |
542 | send_clients[send_clients.length] = client_id; | |
543 | snapcontrol.setClients(group_id, send_clients); | |
544 | } | |
545 | function setName(id) { | |
546 | // Get current name and lacency | |
547 | let client = snapcontrol.getClient(id); | |
548 | let current_name = (client.config.name != "") ? client.config.name : client.host.name; | |
549 | let current_latency = client.config.latency; | |
550 | let new_name = window.prompt("New Name", current_name); | |
551 | let new_latency = Number(window.prompt("New Latency", String(current_latency))); | |
552 | if (new_name != null) | |
553 | snapcontrol.setClientName(id, new_name); | |
554 | if (new_latency != null) | |
555 | snapcontrol.setClientLatency(id, new_latency); | |
556 | show(); | |
557 | } | |
558 | function openClientSettings(id) { | |
559 | let modal = document.getElementById("client-settings"); | |
560 | let client = snapcontrol.getClient(id); | |
561 | let current_name = (client.config.name != "") ? client.config.name : client.host.name; | |
562 | let name = document.getElementById("client-name"); | |
563 | name.name = id; | |
564 | name.value = current_name; | |
565 | let latency = document.getElementById("client-latency"); | |
566 | latency.valueAsNumber = client.config.latency; | |
567 | let group = snapcontrol.getGroupFromClient(id); | |
568 | let group_input = document.getElementById("client-group"); | |
569 | while (group_input.length > 0) | |
570 | group_input.remove(0); | |
571 | let group_num = 0; | |
572 | for (let ogroup of snapcontrol.server.groups) { | |
573 | let option = document.createElement('option'); | |
574 | option.value = ogroup.id; | |
575 | option.text = "Group " + (group_num + 1) + " (" + ogroup.clients.length + " Clients)"; | |
576 | group_input.add(option); | |
577 | if (ogroup == group) { | |
578 | console.log("Selected: " + group_num); | |
579 | group_input.selectedIndex = group_num; | |
580 | } | |
581 | ++group_num; | |
582 | } | |
583 | let option = document.createElement('option'); | |
584 | option.value = option.text = "new"; | |
585 | group_input.add(option); | |
586 | modal.style.display = "block"; | |
587 | } | |
588 | function closeClientSettings() { | |
589 | let name = document.getElementById("client-name"); | |
590 | let id = name.name; | |
591 | console.log("onclose " + id + ", value: " + name.value); | |
592 | snapcontrol.setClientName(id, name.value); | |
593 | let latency = document.getElementById("client-latency"); | |
594 | snapcontrol.setClientLatency(id, latency.valueAsNumber); | |
595 | let group_input = document.getElementById("client-group"); | |
596 | let option = group_input.options[group_input.selectedIndex]; | |
597 | setGroup(id, option.value); | |
598 | let modal = document.getElementById("client-settings"); | |
599 | modal.style.display = "none"; | |
600 | show(); | |
601 | } | |
602 | function deleteClient(id) { | |
603 | if (confirm('Are you sure?')) { | |
604 | snapcontrol.deleteClient(id); | |
605 | } | |
606 | } | |
607 | window.onload = function (event) { | |
608 | snapcontrol = new SnapControl(window.location.hostname, 1780); | |
609 | }; | |
610 | // When the user clicks anywhere outside of the modal, close it | |
611 | window.onclick = function (event) { | |
612 | let modal = document.getElementById("client-settings"); | |
613 | if (event.target == modal) { | |
614 | modal.style.display = "none"; | |
615 | } | |
616 | }; | |
617 | //# sourceMappingURL=snapcontrol.js.map⏎ |
0 | "use strict"; | |
1 | function setCookie(key, value, exdays = -1) { | |
2 | let d = new Date(); | |
3 | if (exdays < 0) | |
4 | exdays = 10 * 365; | |
5 | d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); | |
6 | let expires = "expires=" + d.toUTCString(); | |
7 | document.cookie = key + "=" + value + ";" + expires + ";sameSite=Strict;path=/"; | |
8 | } | |
9 | function getPersistentValue(key, defaultValue = "") { | |
10 | if (!!window.localStorage) { | |
11 | const value = window.localStorage.getItem(key); | |
12 | if (value !== null) { | |
13 | return value; | |
14 | } | |
15 | window.localStorage.setItem(key, defaultValue); | |
16 | return defaultValue; | |
17 | } | |
18 | // Fallback to cookies if localStorage is not available. | |
19 | let name = key + "="; | |
20 | let decodedCookie = decodeURIComponent(document.cookie); | |
21 | let ca = decodedCookie.split(';'); | |
22 | for (let c of ca) { | |
23 | c = c.trimLeft(); | |
24 | if (c.indexOf(name) == 0) { | |
25 | return c.substring(name.length, c.length); | |
26 | } | |
27 | } | |
28 | setCookie(key, defaultValue); | |
29 | return defaultValue; | |
30 | } | |
31 | function getChromeVersion() { | |
32 | const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); | |
33 | return raw ? parseInt(raw[2]) : null; | |
34 | } | |
35 | function uuidv4() { | |
36 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
37 | var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); | |
38 | return v.toString(16); | |
39 | }); | |
40 | } | |
41 | class Tv { | |
42 | constructor(sec, usec) { | |
43 | this.sec = 0; | |
44 | this.usec = 0; | |
45 | this.sec = sec; | |
46 | this.usec = usec; | |
47 | } | |
48 | setMilliseconds(ms) { | |
49 | this.sec = Math.floor(ms / 1000); | |
50 | this.usec = Math.floor(ms * 1000) % 1000000; | |
51 | } | |
52 | getMilliseconds() { | |
53 | return this.sec * 1000 + this.usec / 1000; | |
54 | } | |
55 | } | |
56 | class BaseMessage { | |
57 | constructor(buffer) { | |
58 | this.type = 0; | |
59 | this.id = 0; | |
60 | this.refersTo = 0; | |
61 | this.received = new Tv(0, 0); | |
62 | this.sent = new Tv(0, 0); | |
63 | this.size = 0; | |
64 | } | |
65 | deserialize(buffer) { | |
66 | let view = new DataView(buffer); | |
67 | this.type = view.getUint16(0, true); | |
68 | this.id = view.getUint16(2, true); | |
69 | this.refersTo = view.getUint16(4, true); | |
70 | this.received = new Tv(view.getInt32(6, true), view.getInt32(10, true)); | |
71 | this.sent = new Tv(view.getInt32(14, true), view.getInt32(18, true)); | |
72 | this.size = view.getUint32(22, true); | |
73 | } | |
74 | serialize() { | |
75 | this.size = 26 + this.getSize(); | |
76 | let buffer = new ArrayBuffer(this.size); | |
77 | let view = new DataView(buffer); | |
78 | view.setUint16(0, this.type, true); | |
79 | view.setUint16(2, this.id, true); | |
80 | view.setUint16(4, this.refersTo, true); | |
81 | view.setInt32(6, this.sent.sec, true); | |
82 | view.setInt32(10, this.sent.usec, true); | |
83 | view.setInt32(14, this.received.sec, true); | |
84 | view.setInt32(18, this.received.usec, true); | |
85 | view.setUint32(22, this.size, true); | |
86 | return buffer; | |
87 | } | |
88 | getSize() { | |
89 | return 0; | |
90 | } | |
91 | } | |
92 | class CodecMessage extends BaseMessage { | |
93 | constructor(buffer) { | |
94 | super(buffer); | |
95 | this.codec = ""; | |
96 | this.payload = new ArrayBuffer(0); | |
97 | if (buffer) { | |
98 | this.deserialize(buffer); | |
99 | } | |
100 | this.type = 1; | |
101 | } | |
102 | deserialize(buffer) { | |
103 | super.deserialize(buffer); | |
104 | let view = new DataView(buffer); | |
105 | let codecSize = view.getInt32(26, true); | |
106 | let decoder = new TextDecoder("utf-8"); | |
107 | this.codec = decoder.decode(buffer.slice(30, 30 + codecSize)); | |
108 | let payloadSize = view.getInt32(30 + codecSize, true); | |
109 | console.log("payload size: " + payloadSize); | |
110 | this.payload = buffer.slice(34 + codecSize, 34 + codecSize + payloadSize); | |
111 | console.log("payload: " + this.payload); | |
112 | } | |
113 | } | |
114 | class TimeMessage extends BaseMessage { | |
115 | constructor(buffer) { | |
116 | super(buffer); | |
117 | this.latency = new Tv(0, 0); | |
118 | if (buffer) { | |
119 | this.deserialize(buffer); | |
120 | } | |
121 | this.type = 4; | |
122 | } | |
123 | deserialize(buffer) { | |
124 | super.deserialize(buffer); | |
125 | let view = new DataView(buffer); | |
126 | this.latency = new Tv(view.getInt32(26, true), view.getInt32(30, true)); | |
127 | } | |
128 | serialize() { | |
129 | let buffer = super.serialize(); | |
130 | let view = new DataView(buffer); | |
131 | view.setInt32(26, this.latency.sec, true); | |
132 | view.setInt32(30, this.latency.usec, true); | |
133 | return buffer; | |
134 | } | |
135 | getSize() { | |
136 | return 8; | |
137 | } | |
138 | } | |
139 | class JsonMessage extends BaseMessage { | |
140 | constructor(buffer) { | |
141 | super(buffer); | |
142 | if (buffer) { | |
143 | this.deserialize(buffer); | |
144 | } | |
145 | } | |
146 | deserialize(buffer) { | |
147 | super.deserialize(buffer); | |
148 | let view = new DataView(buffer); | |
149 | let size = view.getUint32(26, true); | |
150 | let decoder = new TextDecoder(); | |
151 | this.json = JSON.parse(decoder.decode(buffer.slice(30, 30 + size))); | |
152 | } | |
153 | serialize() { | |
154 | let buffer = super.serialize(); | |
155 | let view = new DataView(buffer); | |
156 | let jsonStr = JSON.stringify(this.json); | |
157 | view.setUint32(26, jsonStr.length, true); | |
158 | let encoder = new TextEncoder(); | |
159 | let encoded = encoder.encode(jsonStr); | |
160 | for (let i = 0; i < encoded.length; ++i) | |
161 | view.setUint8(30 + i, encoded[i]); | |
162 | return buffer; | |
163 | } | |
164 | getSize() { | |
165 | let encoder = new TextEncoder(); | |
166 | let encoded = encoder.encode(JSON.stringify(this.json)); | |
167 | return encoded.length + 4; | |
168 | // return JSON.stringify(this.json).length; | |
169 | } | |
170 | } | |
171 | class HelloMessage extends JsonMessage { | |
172 | constructor(buffer) { | |
173 | super(buffer); | |
174 | this.mac = ""; | |
175 | this.hostname = ""; | |
176 | this.version = "0.1.0"; | |
177 | this.clientName = "Snapweb"; | |
178 | this.os = ""; | |
179 | this.arch = "web"; | |
180 | this.instance = 1; | |
181 | this.uniqueId = ""; | |
182 | this.snapStreamProtocolVersion = 2; | |
183 | if (buffer) { | |
184 | this.deserialize(buffer); | |
185 | } | |
186 | this.type = 5; | |
187 | } | |
188 | deserialize(buffer) { | |
189 | super.deserialize(buffer); | |
190 | this.mac = this.json["MAC"]; | |
191 | this.hostname = this.json["HostName"]; | |
192 | this.version = this.json["Version"]; | |
193 | this.clientName = this.json["ClientName"]; | |
194 | this.os = this.json["OS"]; | |
195 | this.arch = this.json["Arch"]; | |
196 | this.instance = this.json["Instance"]; | |
197 | this.uniqueId = this.json["ID"]; | |
198 | this.snapStreamProtocolVersion = this.json["SnapStreamProtocolVersion"]; | |
199 | } | |
200 | serialize() { | |
201 | this.json = { "MAC": this.mac, "HostName": this.hostname, "Version": this.version, "ClientName": this.clientName, "OS": this.os, "Arch": this.arch, "Instance": this.instance, "ID": this.uniqueId, "SnapStreamProtocolVersion": this.snapStreamProtocolVersion }; | |
202 | return super.serialize(); | |
203 | } | |
204 | } | |
205 | class ServerSettingsMessage extends JsonMessage { | |
206 | constructor(buffer) { | |
207 | super(buffer); | |
208 | this.bufferMs = 0; | |
209 | this.latency = 0; | |
210 | this.volumePercent = 0; | |
211 | this.muted = false; | |
212 | if (buffer) { | |
213 | this.deserialize(buffer); | |
214 | } | |
215 | this.type = 3; | |
216 | } | |
217 | deserialize(buffer) { | |
218 | super.deserialize(buffer); | |
219 | this.bufferMs = this.json["bufferMs"]; | |
220 | this.latency = this.json["latency"]; | |
221 | this.volumePercent = this.json["volume"]; | |
222 | this.muted = this.json["muted"]; | |
223 | } | |
224 | serialize() { | |
225 | this.json = { "bufferMs": this.bufferMs, "latency": this.latency, "volume": this.volumePercent, "muted": this.muted }; | |
226 | return super.serialize(); | |
227 | } | |
228 | } | |
229 | class PcmChunkMessage extends BaseMessage { | |
230 | constructor(buffer, sampleFormat) { | |
231 | super(buffer); | |
232 | this.timestamp = new Tv(0, 0); | |
233 | // payloadSize: number = 0; | |
234 | this.payload = new ArrayBuffer(0); | |
235 | this.idx = 0; | |
236 | this.deserialize(buffer); | |
237 | this.sampleFormat = sampleFormat; | |
238 | this.type = 2; | |
239 | } | |
240 | deserialize(buffer) { | |
241 | super.deserialize(buffer); | |
242 | let view = new DataView(buffer); | |
243 | this.timestamp = new Tv(view.getInt32(26, true), view.getInt32(30, true)); | |
244 | // this.payloadSize = view.getUint32(34, true); | |
245 | this.payload = buffer.slice(38); //, this.payloadSize + 38));// , this.payloadSize); | |
246 | // console.log("ts: " + this.timestamp.sec + " " + this.timestamp.usec + ", payload: " + this.payloadSize + ", len: " + this.payload.byteLength); | |
247 | } | |
248 | readFrames(frames) { | |
249 | let frameCnt = frames; | |
250 | let frameSize = this.sampleFormat.frameSize(); | |
251 | if (this.idx + frames > this.payloadSize() / frameSize) | |
252 | frameCnt = (this.payloadSize() / frameSize) - this.idx; | |
253 | let begin = this.idx * frameSize; | |
254 | this.idx += frameCnt; | |
255 | let end = begin + frameCnt * frameSize; | |
256 | // console.log("readFrames: " + frames + ", result: " + frameCnt + ", begin: " + begin + ", end: " + end + ", payload: " + this.payload.byteLength); | |
257 | return this.payload.slice(begin, end); | |
258 | } | |
259 | getFrameCount() { | |
260 | return (this.payloadSize() / this.sampleFormat.frameSize()); | |
261 | } | |
262 | isEndOfChunk() { | |
263 | return this.idx >= this.getFrameCount(); | |
264 | } | |
265 | startMs() { | |
266 | return this.timestamp.getMilliseconds() + 1000 * (this.idx / this.sampleFormat.rate); | |
267 | } | |
268 | duration() { | |
269 | return 1000 * ((this.getFrameCount() - this.idx) / this.sampleFormat.rate); | |
270 | } | |
271 | payloadSize() { | |
272 | return this.payload.byteLength; | |
273 | } | |
274 | clearPayload() { | |
275 | this.payload = new ArrayBuffer(0); | |
276 | } | |
277 | addPayload(buffer) { | |
278 | let payload = new ArrayBuffer(this.payload.byteLength + buffer.byteLength); | |
279 | let view = new DataView(payload); | |
280 | let viewOld = new DataView(this.payload); | |
281 | let viewNew = new DataView(buffer); | |
282 | for (let i = 0; i < viewOld.byteLength; ++i) { | |
283 | view.setInt8(i, viewOld.getInt8(i)); | |
284 | } | |
285 | for (let i = 0; i < viewNew.byteLength; ++i) { | |
286 | view.setInt8(i + viewOld.byteLength, viewNew.getInt8(i)); | |
287 | } | |
288 | this.payload = payload; | |
289 | } | |
290 | } | |
291 | class AudioStream { | |
292 | constructor(timeProvider, sampleFormat, bufferMs) { | |
293 | this.timeProvider = timeProvider; | |
294 | this.sampleFormat = sampleFormat; | |
295 | this.bufferMs = bufferMs; | |
296 | this.chunks = new Array(); | |
297 | // setRealSampleRate(sampleRate: number) { | |
298 | // if (sampleRate == this.sampleFormat.rate) { | |
299 | // this.correctAfterXFrames = 0; | |
300 | // } | |
301 | // else { | |
302 | // this.correctAfterXFrames = Math.ceil((this.sampleFormat.rate / sampleRate) / (this.sampleFormat.rate / sampleRate - 1.)); | |
303 | // console.debug("setRealSampleRate: " + sampleRate + ", correct after X: " + this.correctAfterXFrames); | |
304 | // } | |
305 | // } | |
306 | this.chunk = undefined; | |
307 | this.volume = 1; | |
308 | this.muted = false; | |
309 | this.lastLog = 0; | |
310 | } | |
311 | setVolume(percent, muted) { | |
312 | let base = 10; | |
313 | this.volume = percent / 100; // (Math.pow(base, percent / 100) - 1) / (base - 1); | |
314 | console.log("setVolume: " + percent + " => " + this.volume + ", muted: " + this.muted); | |
315 | this.muted = muted; | |
316 | } | |
317 | addChunk(chunk) { | |
318 | this.chunks.push(chunk); | |
319 | // let oldest = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds(); | |
320 | // let newest = this.timeProvider.serverNow() - this.chunks[this.chunks.length - 1].timestamp.getMilliseconds(); | |
321 | // console.debug("chunks: " + this.chunks.length + ", oldest: " + oldest.toFixed(2) + ", newest: " + newest.toFixed(2)); | |
322 | while (this.chunks.length > 0) { | |
323 | let age = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds(); | |
324 | // todo: consider buffer ms | |
325 | if (age > 5000 + this.bufferMs) { | |
326 | this.chunks.shift(); | |
327 | console.log("Dropping old chunk: " + age.toFixed(2) + ", left: " + this.chunks.length); | |
328 | } | |
329 | else | |
330 | break; | |
331 | } | |
332 | } | |
333 | getNextBuffer(buffer, playTimeMs) { | |
334 | if (!this.chunk) { | |
335 | this.chunk = this.chunks.shift(); | |
336 | } | |
337 | // let age = this.timeProvider.serverTime(this.playTime * 1000) - startMs; | |
338 | let frames = buffer.length; | |
339 | // console.debug("getNextBuffer: " + frames + ", play time: " + playTimeMs.toFixed(2)); | |
340 | let left = new Float32Array(frames); | |
341 | let right = new Float32Array(frames); | |
342 | let read = 0; | |
343 | let pos = 0; | |
344 | // let volume = this.muted ? 0 : this.volume; | |
345 | let serverPlayTimeMs = this.timeProvider.serverTime(playTimeMs); | |
346 | if (this.chunk) { | |
347 | let age = serverPlayTimeMs - this.chunk.startMs(); // - 500; | |
348 | let reqChunkDuration = frames / this.sampleFormat.msRate(); | |
349 | let secs = Math.floor(Date.now() / 1000); | |
350 | if (this.lastLog != secs) { | |
351 | this.lastLog = secs; | |
352 | console.log("age: " + age.toFixed(2) + ", req: " + reqChunkDuration); | |
353 | } | |
354 | if (age < -reqChunkDuration) { | |
355 | console.log("age: " + age.toFixed(2) + " < req: " + reqChunkDuration * -1 + ", chunk.startMs: " + this.chunk.startMs().toFixed(2) + ", timestamp: " + this.chunk.timestamp.getMilliseconds().toFixed(2)); | |
356 | console.log("Chunk too young, returning silence"); | |
357 | } | |
358 | else { | |
359 | if (Math.abs(age) > 5) { | |
360 | // We are 5ms apart, do a hard sync, i.e. don't play faster/slower, | |
361 | // but seek to the desired position instead | |
362 | while (this.chunk && age > this.chunk.duration()) { | |
363 | console.log("Chunk too old, dropping (age: " + age.toFixed(2) + " > " + this.chunk.duration().toFixed(2) + ")"); | |
364 | this.chunk = this.chunks.shift(); | |
365 | if (!this.chunk) | |
366 | break; | |
367 | age = serverPlayTimeMs - this.chunk.startMs(); | |
368 | } | |
369 | if (this.chunk) { | |
370 | if (age > 0) { | |
371 | console.log("Fast forwarding " + age.toFixed(2) + "ms"); | |
372 | this.chunk.readFrames(Math.floor(age * this.chunk.sampleFormat.msRate())); | |
373 | } | |
374 | else if (age < 0) { | |
375 | console.log("Playing silence " + -age.toFixed(2) + "ms"); | |
376 | let silentFrames = Math.floor(-age * this.chunk.sampleFormat.msRate()); | |
377 | left.fill(0, 0, silentFrames); | |
378 | right.fill(0, 0, silentFrames); | |
379 | read = silentFrames; | |
380 | pos = silentFrames; | |
381 | } | |
382 | age = 0; | |
383 | } | |
384 | } | |
385 | // else if (age > 0.1) { | |
386 | // let rate = age * 0.0005; | |
387 | // rate = 1.0 - Math.min(rate, 0.0005); | |
388 | // console.debug("Age > 0, rate: " + rate); | |
389 | // // we are late (age > 0), this means we are not playing fast enough | |
390 | // // => the real sample rate seems to be lower, we have to drop some frames | |
391 | // this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999); | |
392 | // } | |
393 | // else if (age < -0.1) { | |
394 | // let rate = -age * 0.0005; | |
395 | // rate = 1.0 + Math.min(rate, 0.0005); | |
396 | // console.debug("Age < 0, rate: " + rate); | |
397 | // // we are early (age > 0), this means we are playing too fast | |
398 | // // => the real sample rate seems to be higher, we have to insert some frames | |
399 | // this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999); | |
400 | // } | |
401 | // else { | |
402 | // this.setRealSampleRate(this.sampleFormat.rate); | |
403 | // } | |
404 | let addFrames = 0; | |
405 | let everyN = 0; | |
406 | if (age > 0.1) { | |
407 | addFrames = Math.ceil(age); // / 5); | |
408 | } | |
409 | else if (age < -0.1) { | |
410 | addFrames = Math.floor(age); // / 5); | |
411 | } | |
412 | // addFrames = -2; | |
413 | let readFrames = frames + addFrames - read; | |
414 | if (addFrames != 0) | |
415 | everyN = Math.ceil((frames + addFrames - read) / (Math.abs(addFrames) + 1)); | |
416 | // addFrames = 0; | |
417 | // console.debug("frames: " + frames + ", readFrames: " + readFrames + ", addFrames: " + addFrames + ", everyN: " + everyN); | |
418 | while ((read < readFrames) && this.chunk) { | |
419 | let pcmChunk = this.chunk; | |
420 | let pcmBuffer = pcmChunk.readFrames(readFrames - read); | |
421 | let payload = new Int16Array(pcmBuffer); | |
422 | // console.debug("readFrames: " + (frames - read) + ", read: " + pcmBuffer.byteLength + ", payload: " + payload.length); | |
423 | // read += (pcmBuffer.byteLength / this.sampleFormat.frameSize()); | |
424 | for (let i = 0; i < payload.length; i += 2) { | |
425 | read++; | |
426 | left[pos] = (payload[i] / 32768); // * volume; | |
427 | right[pos] = (payload[i + 1] / 32768); // * volume; | |
428 | if ((everyN != 0) && (read % everyN == 0)) { | |
429 | if (addFrames > 0) { | |
430 | pos--; | |
431 | } | |
432 | else { | |
433 | left[pos + 1] = left[pos]; | |
434 | right[pos + 1] = right[pos]; | |
435 | pos++; | |
436 | // console.log("Add: " + pos); | |
437 | } | |
438 | } | |
439 | pos++; | |
440 | } | |
441 | if (pcmChunk.isEndOfChunk()) { | |
442 | this.chunk = this.chunks.shift(); | |
443 | } | |
444 | } | |
445 | if (addFrames != 0) | |
446 | console.debug("Pos: " + pos + ", frames: " + frames + ", add: " + addFrames + ", everyN: " + everyN); | |
447 | if (read == readFrames) | |
448 | read = frames; | |
449 | } | |
450 | } | |
451 | if (read < frames) { | |
452 | console.log("Failed to get chunk, read: " + read + "/" + frames + ", chunks left: " + this.chunks.length); | |
453 | left.fill(0, pos); | |
454 | right.fill(0, pos); | |
455 | } | |
456 | buffer.copyToChannel(left, 0, 0); | |
457 | buffer.copyToChannel(right, 1, 0); | |
458 | } | |
459 | } | |
460 | class TimeProvider { | |
461 | constructor(ctx = undefined) { | |
462 | this.diffBuffer = new Array(); | |
463 | this.diff = 0; | |
464 | if (ctx) { | |
465 | this.setAudioContext(ctx); | |
466 | } | |
467 | } | |
468 | setAudioContext(ctx) { | |
469 | this.ctx = ctx; | |
470 | this.reset(); | |
471 | } | |
472 | reset() { | |
473 | this.diffBuffer.length = 0; | |
474 | this.diff = 0; | |
475 | } | |
476 | setDiff(c2s, s2c) { | |
477 | if (this.now() == 0) { | |
478 | this.reset(); | |
479 | } | |
480 | else { | |
481 | if (this.diffBuffer.push((c2s - s2c) / 2) > 100) | |
482 | this.diffBuffer.shift(); | |
483 | let sorted = [...this.diffBuffer]; | |
484 | sorted.sort(); | |
485 | this.diff = sorted[Math.floor(sorted.length / 2)]; | |
486 | } | |
487 | // console.debug("c2s: " + c2s.toFixed(2) + ", s2c: " + s2c.toFixed(2) + ", diff: " + this.diff.toFixed(2) + ", now: " + this.now().toFixed(2) + ", server.now: " + this.serverNow().toFixed(2) + ", win.now: " + window.performance.now().toFixed(2)); | |
488 | // console.log("now: " + this.now() + "\t" + this.now() + "\t" + this.now()); | |
489 | } | |
490 | now() { | |
491 | if (!this.ctx) { | |
492 | return window.performance.now(); | |
493 | } | |
494 | else { | |
495 | // Use the more accurate getOutputTimestamp if available, fallback to ctx.currentTime otherwise. | |
496 | const contextTime = !!this.ctx.getOutputTimestamp ? this.ctx.getOutputTimestamp().contextTime : undefined; | |
497 | return (contextTime !== undefined ? contextTime : this.ctx.currentTime) * 1000; | |
498 | } | |
499 | } | |
500 | nowSec() { | |
501 | return this.now() / 1000; | |
502 | } | |
503 | serverNow() { | |
504 | return this.serverTime(this.now()); | |
505 | } | |
506 | serverTime(localTimeMs) { | |
507 | return localTimeMs + this.diff; | |
508 | } | |
509 | } | |
510 | class SampleFormat { | |
511 | constructor() { | |
512 | this.rate = 48000; | |
513 | this.channels = 2; | |
514 | this.bits = 16; | |
515 | } | |
516 | msRate() { | |
517 | return this.rate / 1000; | |
518 | } | |
519 | toString() { | |
520 | return this.rate + ":" + this.bits + ":" + this.channels; | |
521 | } | |
522 | sampleSize() { | |
523 | if (this.bits == 24) { | |
524 | return 4; | |
525 | } | |
526 | return this.bits / 8; | |
527 | } | |
528 | frameSize() { | |
529 | return this.channels * this.sampleSize(); | |
530 | } | |
531 | durationMs(bytes) { | |
532 | return (bytes / this.frameSize()) * this.msRate(); | |
533 | } | |
534 | } | |
535 | class Decoder { | |
536 | setHeader(buffer) { | |
537 | return new SampleFormat(); | |
538 | } | |
539 | decode(chunk) { | |
540 | return null; | |
541 | } | |
542 | } | |
543 | class OpusDecoder extends Decoder { | |
544 | constructor() { | |
545 | super(); | |
546 | } | |
547 | setHeader(buffer) { | |
548 | let view = new DataView(buffer); | |
549 | let ID_OPUS = 0x4F505553; | |
550 | if (buffer.byteLength < 12) { | |
551 | console.error("Opus header too small: " + buffer.byteLength); | |
552 | return null; | |
553 | } | |
554 | else if (view.getUint32(0, true) != ID_OPUS) { | |
555 | console.error("Opus header too small: " + buffer.byteLength); | |
556 | return null; | |
557 | } | |
558 | let format = new SampleFormat(); | |
559 | format.rate = view.getUint32(4, true); | |
560 | format.bits = view.getUint16(8, true); | |
561 | format.channels = view.getUint16(10, true); | |
562 | console.log("Opus samplerate: " + format.toString()); | |
563 | return format; | |
564 | } | |
565 | decode(chunk) { | |
566 | return null; | |
567 | } | |
568 | } | |
569 | class FlacDecoder extends Decoder { | |
570 | constructor() { | |
571 | super(); | |
572 | this.header = null; | |
573 | this.cacheInfo = { isCachedChunk: false, cachedBlocks: 0 }; | |
574 | this.decoder = Flac.create_libflac_decoder(true); | |
575 | if (this.decoder) { | |
576 | let init_status = Flac.init_decoder_stream(this.decoder, this.read_callback_fn.bind(this), this.write_callback_fn.bind(this), this.error_callback_fn.bind(this), this.metadata_callback_fn.bind(this), false); | |
577 | console.log("Flac init: " + init_status); | |
578 | Flac.setOptions(this.decoder, { analyseSubframes: true, analyseResiduals: true }); | |
579 | } | |
580 | this.sampleFormat = new SampleFormat(); | |
581 | this.flacChunk = new ArrayBuffer(0); | |
582 | // this.pcmChunk = new PcmChunkMessage(); | |
583 | // Flac.setOptions(this.decoder, {analyseSubframes: analyse_frames, analyseResiduals: analyse_residuals}); | |
584 | // flac_ok &= init_status == 0; | |
585 | // console.log("flac init : " + flac_ok);//DEBUG | |
586 | } | |
587 | decode(chunk) { | |
588 | // console.log("Flac decode: " + chunk.payload.byteLength); | |
589 | this.flacChunk = chunk.payload.slice(0); | |
590 | this.pcmChunk = chunk; | |
591 | this.pcmChunk.clearPayload(); | |
592 | this.cacheInfo = { cachedBlocks: 0, isCachedChunk: true }; | |
593 | // console.log("Flac len: " + this.flacChunk.byteLength); | |
594 | while (this.flacChunk.byteLength && Flac.FLAC__stream_decoder_process_single(this.decoder)) { | |
595 | let state = Flac.FLAC__stream_decoder_get_state(this.decoder); | |
596 | // console.log("State: " + state); | |
597 | } | |
598 | // console.log("Pcm payload: " + this.pcmChunk!.payloadSize()); | |
599 | if (this.cacheInfo.cachedBlocks > 0) { | |
600 | let diffMs = this.cacheInfo.cachedBlocks / this.sampleFormat.msRate(); | |
601 | // console.log("Cached: " + this.cacheInfo.cachedBlocks + ", " + diffMs + "ms"); | |
602 | this.pcmChunk.timestamp.setMilliseconds(this.pcmChunk.timestamp.getMilliseconds() - diffMs); | |
603 | } | |
604 | return this.pcmChunk; | |
605 | } | |
606 | read_callback_fn(bufferSize) { | |
607 | // console.log(' decode read callback, buffer bytes max=', bufferSize); | |
608 | if (this.header) { | |
609 | console.log(" header: " + this.header.byteLength); | |
610 | let data = new Uint8Array(this.header); | |
611 | this.header = null; | |
612 | return { buffer: data, readDataLength: data.byteLength, error: false }; | |
613 | } | |
614 | else if (this.flacChunk) { | |
615 | // console.log(" flacChunk: " + this.flacChunk.byteLength); | |
616 | // a fresh read => next call to write will not be from cached data | |
617 | this.cacheInfo.isCachedChunk = false; | |
618 | let data = new Uint8Array(this.flacChunk.slice(0, Math.min(bufferSize, this.flacChunk.byteLength))); | |
619 | this.flacChunk = this.flacChunk.slice(data.byteLength); | |
620 | return { buffer: data, readDataLength: data.byteLength, error: false }; | |
621 | } | |
622 | return { buffer: new Uint8Array(0), readDataLength: 0, error: false }; | |
623 | } | |
624 | write_callback_fn(data, frameInfo) { | |
625 | // console.log(" write frame metadata: " + frameInfo + ", len: " + data.length); | |
626 | if (this.cacheInfo.isCachedChunk) { | |
627 | // there was no call to read, so it's some cached data | |
628 | this.cacheInfo.cachedBlocks += frameInfo.blocksize; | |
629 | } | |
630 | let payload = new ArrayBuffer((frameInfo.bitsPerSample / 8) * frameInfo.channels * frameInfo.blocksize); | |
631 | let view = new DataView(payload); | |
632 | for (let channel = 0; channel < frameInfo.channels; ++channel) { | |
633 | let channelData = new DataView(data[channel].buffer, 0, data[channel].buffer.byteLength); | |
634 | // console.log("channelData: " + channelData.byteLength + ", blocksize: " + frameInfo.blocksize); | |
635 | for (let i = 0; i < frameInfo.blocksize; ++i) { | |
636 | view.setInt16(2 * (frameInfo.channels * i + channel), channelData.getInt16(2 * i, true), true); | |
637 | } | |
638 | } | |
639 | this.pcmChunk.addPayload(payload); | |
640 | // console.log("write: " + payload.byteLength + ", len: " + this.pcmChunk!.payloadSize()); | |
641 | } | |
642 | /** @memberOf decode */ | |
643 | metadata_callback_fn(data) { | |
644 | console.info('meta data: ', data); | |
645 | // let view = new DataView(data); | |
646 | this.sampleFormat.rate = data.sampleRate; | |
647 | this.sampleFormat.channels = data.channels; | |
648 | this.sampleFormat.bits = data.bitsPerSample; | |
649 | console.log("metadata_callback_fn, sampleformat: " + this.sampleFormat.toString()); | |
650 | } | |
651 | /** @memberOf decode */ | |
652 | error_callback_fn(err, errMsg) { | |
653 | console.error('decode error callback', err, errMsg); | |
654 | } | |
655 | setHeader(buffer) { | |
656 | this.header = buffer.slice(0); | |
657 | Flac.FLAC__stream_decoder_process_until_end_of_metadata(this.decoder); | |
658 | return this.sampleFormat; | |
659 | } | |
660 | } | |
661 | class PlayBuffer { | |
662 | constructor(buffer, playTime, source, destination) { | |
663 | this.num = 0; | |
664 | this.buffer = buffer; | |
665 | this.playTime = playTime; | |
666 | this.source = source; | |
667 | this.source.buffer = this.buffer; | |
668 | this.source.connect(destination); | |
669 | this.onended = (playBuffer) => { }; | |
670 | } | |
671 | start() { | |
672 | this.source.onended = (ev) => { | |
673 | this.onended(this); | |
674 | }; | |
675 | this.source.start(this.playTime); | |
676 | } | |
677 | } | |
678 | class PcmDecoder extends Decoder { | |
679 | setHeader(buffer) { | |
680 | let sampleFormat = new SampleFormat(); | |
681 | let view = new DataView(buffer); | |
682 | sampleFormat.channels = view.getUint16(22, true); | |
683 | sampleFormat.rate = view.getUint32(24, true); | |
684 | sampleFormat.bits = view.getUint16(34, true); | |
685 | return sampleFormat; | |
686 | } | |
687 | decode(chunk) { | |
688 | return chunk; | |
689 | } | |
690 | } | |
691 | class SnapStream { | |
692 | constructor(host, port) { | |
693 | this.playTime = 0; | |
694 | this.msgId = 0; | |
695 | this.bufferDurationMs = 80; // 0; | |
696 | this.bufferFrameCount = 3844; // 9600; // 2400;//8192; | |
697 | this.syncHandle = -1; | |
698 | // ageBuffer: Array<number>; | |
699 | this.audioBuffers = new Array(); | |
700 | this.freeBuffers = new Array(); | |
701 | // median: number = 0; | |
702 | this.audioBufferCount = 3; | |
703 | this.bufferMs = 1000; | |
704 | this.bufferNum = 0; | |
705 | this.streamsocket = new WebSocket('ws://' + host + ':' + port + '/stream'); | |
706 | this.streamsocket.binaryType = "arraybuffer"; | |
707 | this.streamsocket.onmessage = (msg) => { | |
708 | let view = new DataView(msg.data); | |
709 | let type = view.getUint16(0, true); | |
710 | if (type == 1) { | |
711 | let codec = new CodecMessage(msg.data); | |
712 | console.log("Codec: " + codec.codec); | |
713 | if (codec.codec == "flac") { | |
714 | this.decoder = new FlacDecoder(); | |
715 | } | |
716 | else if (codec.codec == "pcm") { | |
717 | this.decoder = new PcmDecoder(); | |
718 | } | |
719 | else if (codec.codec == "opus") { | |
720 | this.decoder = new OpusDecoder(); | |
721 | alert("Codec not supported: " + codec.codec); | |
722 | } | |
723 | else { | |
724 | alert("Codec not supported: " + codec.codec); | |
725 | } | |
726 | if (this.decoder) { | |
727 | this.sampleFormat = this.decoder.setHeader(codec.payload); | |
728 | console.log("Sampleformat: " + this.sampleFormat.toString()); | |
729 | if ((this.sampleFormat.channels != 2) || (this.sampleFormat.bits != 16)) { | |
730 | alert("Stream must be stereo with 16 bit depth, actual format: " + this.sampleFormat.toString()); | |
731 | } | |
732 | else { | |
733 | if (this.bufferDurationMs != 0) { | |
734 | this.bufferFrameCount = Math.floor(this.bufferDurationMs * this.sampleFormat.msRate()); | |
735 | } | |
736 | this.stopAudio(); | |
737 | let options = { latencyHint: "playback", sampleRate: this.sampleFormat.rate }; | |
738 | const chromeVersion = getChromeVersion(); | |
739 | if (chromeVersion !== null && chromeVersion < 55) { | |
740 | // Some older browsers won't decode the stream if options are provided. | |
741 | options = undefined; | |
742 | } | |
743 | this.ctx = new AudioContext(options); | |
744 | this.timeProvider.setAudioContext(this.ctx); | |
745 | this.gainNode = this.ctx.createGain(); | |
746 | this.gainNode.connect(this.ctx.destination); | |
747 | this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100; | |
748 | // this.timeProvider = new TimeProvider(this.ctx); | |
749 | this.stream = new AudioStream(this.timeProvider, this.sampleFormat, this.bufferMs); | |
750 | console.log("Base latency: " + this.ctx.baseLatency + ", output latency: " + this.ctx.outputLatency); | |
751 | this.play(); | |
752 | } | |
753 | } | |
754 | } | |
755 | else if (type == 2) { | |
756 | let pcmChunk = new PcmChunkMessage(msg.data, this.sampleFormat); | |
757 | if (this.decoder) { | |
758 | let decoded = this.decoder.decode(pcmChunk); | |
759 | if (decoded) { | |
760 | this.stream.addChunk(decoded); | |
761 | } | |
762 | } | |
763 | } | |
764 | else if (type == 3) { | |
765 | this.serverSettings = new ServerSettingsMessage(msg.data); | |
766 | if (this.gainNode) { | |
767 | this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100; | |
768 | } | |
769 | this.bufferMs = this.serverSettings.bufferMs - this.serverSettings.latency; | |
770 | console.log("ServerSettings bufferMs: " + this.serverSettings.bufferMs + ", latency: " + this.serverSettings.latency + ", volume: " + this.serverSettings.volumePercent + ", muted: " + this.serverSettings.muted); | |
771 | } | |
772 | else if (type == 4) { | |
773 | if (this.timeProvider) { | |
774 | let time = new TimeMessage(msg.data); | |
775 | this.timeProvider.setDiff(time.latency.getMilliseconds(), this.timeProvider.now() - time.sent.getMilliseconds()); | |
776 | } | |
777 | // console.log("Time sec: " + time.latency.sec + ", usec: " + time.latency.usec + ", diff: " + this.timeProvider.diff); | |
778 | } | |
779 | else { | |
780 | console.info("Message not handled, type: " + type); | |
781 | } | |
782 | }; | |
783 | this.streamsocket.onopen = (ev) => { | |
784 | console.log("on open"); | |
785 | let hello = new HelloMessage(); | |
786 | hello.mac = "00:00:00:00:00:00"; | |
787 | hello.arch = "web"; | |
788 | hello.os = navigator.platform; | |
789 | hello.hostname = "Snapweb client"; | |
790 | hello.uniqueId = getPersistentValue("uniqueId", uuidv4()); | |
791 | this.sendMessage(hello); | |
792 | this.syncTime(); | |
793 | this.syncHandle = window.setInterval(() => this.syncTime(), 1000); | |
794 | }; | |
795 | this.streamsocket.onerror = (ev) => { alert("error: " + ev.type); }; //this.onError(ev); | |
796 | this.streamsocket.onclose = (ev) => { | |
797 | stop(); | |
798 | }; | |
799 | // this.ageBuffer = new Array<number>(); | |
800 | this.timeProvider = new TimeProvider(); | |
801 | } | |
802 | sendMessage(msg) { | |
803 | msg.sent = new Tv(0, 0); | |
804 | msg.sent.setMilliseconds(this.timeProvider.now()); | |
805 | msg.id = ++this.msgId; | |
806 | if (this.streamsocket.readyState != this.streamsocket.OPEN) { | |
807 | stop(); | |
808 | } | |
809 | else { | |
810 | this.streamsocket.send(msg.serialize()); | |
811 | } | |
812 | } | |
813 | syncTime() { | |
814 | let t = new TimeMessage(); | |
815 | t.latency.setMilliseconds(this.timeProvider.now()); | |
816 | this.sendMessage(t); | |
817 | // console.log("prepareSource median: " + Math.round(this.median * 10) / 10); | |
818 | } | |
819 | stopAudio() { | |
820 | if (this.ctx) { | |
821 | this.ctx.close(); | |
822 | } | |
823 | while (this.audioBuffers.length > 0) { | |
824 | let buffer = this.audioBuffers.pop(); | |
825 | buffer.onended = (playBuffer) => { }; | |
826 | buffer.source.stop(); | |
827 | } | |
828 | while (this.freeBuffers.length > 0) { | |
829 | this.freeBuffers.pop(); | |
830 | } | |
831 | } | |
832 | stop() { | |
833 | window.clearInterval(this.syncHandle); | |
834 | this.stopAudio(); | |
835 | if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(this.streamsocket.readyState)) { | |
836 | this.streamsocket.close(); | |
837 | } | |
838 | } | |
839 | play() { | |
840 | this.playTime = this.timeProvider.nowSec() + 0.1; | |
841 | for (let i = 1; i <= this.audioBufferCount; ++i) { | |
842 | this.playNext(); | |
843 | } | |
844 | } | |
845 | playNext() { | |
846 | let buffer = this.freeBuffers.pop() || this.ctx.createBuffer(this.sampleFormat.channels, this.bufferFrameCount, this.sampleFormat.rate); | |
847 | let playTimeMs = (this.playTime + this.ctx.baseLatency) * 1000 - this.bufferMs; | |
848 | this.stream.getNextBuffer(buffer, playTimeMs); | |
849 | let source = this.ctx.createBufferSource(); | |
850 | let playBuffer = new PlayBuffer(buffer, this.playTime, source, this.gainNode); | |
851 | this.audioBuffers.push(playBuffer); | |
852 | playBuffer.num = ++this.bufferNum; | |
853 | playBuffer.onended = (buffer) => { | |
854 | let diff = this.timeProvider.nowSec() - buffer.playTime; | |
855 | this.freeBuffers.push(this.audioBuffers.splice(this.audioBuffers.indexOf(buffer), 1)[0].buffer); | |
856 | // console.debug("PlayBuffer " + playBuffer.num + " ended after: " + (diff * 1000) + ", in flight: " + this.audioBuffers.length); | |
857 | this.playNext(); | |
858 | }; | |
859 | playBuffer.start(); | |
860 | this.playTime += this.bufferFrameCount / this.sampleFormat.rate; | |
861 | } | |
862 | } | |
863 | //# sourceMappingURL=snapstream.js.map⏎ |
Binary diff not shown
Binary diff not shown
0 | body { | |
1 | background-color: rgb(246, 246, 246); | |
2 | color: rgb(255, 255, 255); | |
3 | font-family: 'Arial', sans-serif; | |
4 | width: 100%; | |
5 | margin: 0; | |
6 | font-size: 20px; | |
7 | overscroll-behavior: contain; | |
8 | } | |
9 | ||
10 | /* width */ | |
11 | ::-webkit-scrollbar { | |
12 | width: 10px; | |
13 | } | |
14 | ||
15 | /* Track */ | |
16 | ::-webkit-scrollbar-track { | |
17 | background: #1f1f1f; | |
18 | } | |
19 | ||
20 | /* Handle */ | |
21 | ::-webkit-scrollbar-thumb { | |
22 | background: #333; | |
23 | } | |
24 | ||
25 | /* Handle on hover */ | |
26 | ::-webkit-scrollbar-thumb:hover { | |
27 | background: #555; | |
28 | } | |
29 | ||
30 | .navbar { | |
31 | overflow: hidden; | |
32 | background-color: #607d8b; | |
33 | z-index: 1; /* Sit on top */ | |
34 | padding: 13px; | |
35 | color: white; | |
36 | position: fixed; /* Set the navbar to fixed position */ | |
37 | top: 0; /* Position the navbar at the top of the page */ | |
38 | width: 100%; /* Full width */ | |
39 | font-size: 21px; | |
40 | font-weight: 500; | |
41 | user-select: none; | |
42 | } | |
43 | ||
44 | .play-button { | |
45 | display: block; | |
46 | position: absolute; | |
47 | right: 34px; | |
48 | top: 5px; | |
49 | height: 40px; | |
50 | width: 40px; | |
51 | } | |
52 | ||
53 | .content { | |
54 | margin-top: 62px | |
55 | } | |
56 | ||
57 | .group { | |
58 | float: none; | |
59 | background-color: white; | |
60 | box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.2); | |
61 | clear: both; | |
62 | padding: 8px; | |
63 | margin: 10px 15px 10px 15px; | |
64 | overflow: auto; | |
65 | width: auto; | |
66 | border-radius: 3px; | |
67 | user-select: none; | |
68 | } | |
69 | ||
70 | .group.muted { | |
71 | opacity: 0.27; | |
72 | } | |
73 | ||
74 | .groupheader { | |
75 | /* margin: 10px; */ | |
76 | width: auto; | |
77 | height: fit-content; | |
78 | /* padding: 10px; */ | |
79 | padding-bottom: 0px; | |
80 | display: grid; | |
81 | grid-template-columns: min-content auto min-content; | |
82 | grid-template-rows: min-content min-content; | |
83 | grid-gap: 0px; | |
84 | } | |
85 | ||
86 | .groupheader-separator { | |
87 | height: 1px; | |
88 | margin: 8px 0px; | |
89 | border-width: 0px; | |
90 | color: lightgray; | |
91 | background-color: lightgray; | |
92 | } | |
93 | ||
94 | .stream { | |
95 | color: #686868; | |
96 | grid-row: 1; | |
97 | grid-column: 1/3; | |
98 | width: fit-content; | |
99 | } | |
100 | ||
101 | select { | |
102 | background-color: transparent; | |
103 | border: 0px; | |
104 | width: 150px; | |
105 | font-size: 20px; | |
106 | color: #e3e3e3; | |
107 | -moz-appearance: none; | |
108 | -webkit-appearance: none; | |
109 | appearance: none; | |
110 | } | |
111 | ||
112 | .slidergroupdiv { | |
113 | /* background: greenyellow; */ | |
114 | display: flex; | |
115 | justify-content: center; | |
116 | align-items: center; | |
117 | grid-row: 2; | |
118 | grid-column: 2; | |
119 | } | |
120 | ||
121 | .client { | |
122 | /* text-align: left; */ | |
123 | /* margin: 10px; */ | |
124 | width: auto; | |
125 | height: fit-content; | |
126 | /* padding: 10px; */ | |
127 | display: grid; | |
128 | grid-template-columns: min-content auto min-content; | |
129 | grid-template-rows: min-content min-content; | |
130 | grid-gap: 0px; | |
131 | } | |
132 | ||
133 | /* .client:hover { | |
134 | box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2); | |
135 | } */ | |
136 | ||
137 | .client.disconnected { | |
138 | opacity: 0.27; | |
139 | } | |
140 | ||
141 | .name { | |
142 | color: #686868; | |
143 | user-select: none; | |
144 | /* background: red; */ | |
145 | padding-top: 5px; | |
146 | grid-row: 1; | |
147 | grid-column: 1/3; | |
148 | text-decoration: none; | |
149 | } | |
150 | ||
151 | .editdiv { | |
152 | background: violet; | |
153 | grid-row: 0/4; | |
154 | grid-column: 3; | |
155 | } | |
156 | ||
157 | .edit-icon { | |
158 | color: #686868; | |
159 | text-decoration: none; | |
160 | } | |
161 | ||
162 | .delete-icon { | |
163 | color: #ff4081; | |
164 | text-decoration: none; | |
165 | } | |
166 | ||
167 | .edit-icons { | |
168 | align-items: center; | |
169 | display: flex; | |
170 | grid-row: 1/3; | |
171 | grid-column: 3; | |
172 | } | |
173 | ||
174 | .edit-group-icon { | |
175 | display: flex; | |
176 | color: transparent; | |
177 | align-items: center; | |
178 | grid-row: 1/3; | |
179 | grid-column: 3; | |
180 | text-decoration: none; | |
181 | } | |
182 | ||
183 | .mute-button { | |
184 | color: #686868; | |
185 | grid-row: 2; | |
186 | grid-column: 1; | |
187 | height: 25px; | |
188 | width: 25px; | |
189 | padding-left: 10px; | |
190 | padding-right: 10px; | |
191 | text-decoration: none; | |
192 | } | |
193 | ||
194 | .sliderdiv { | |
195 | display: flex; | |
196 | justify-content: center; | |
197 | align-items: center; | |
198 | grid-row: 2; | |
199 | grid-column: 2; | |
200 | /* padding-left: 40px; */ | |
201 | /* display: inline-block; | |
202 | text-align: left; | |
203 | width: 250px; */ | |
204 | } | |
205 | ||
206 | .slider { | |
207 | writing-mode: bt-lr; | |
208 | -webkit-appearance: none; | |
209 | background: #dbdbdb; | |
210 | outline: none; | |
211 | -webkit-transition: .2s; | |
212 | transition: opacity .2s; | |
213 | height: 2px; | |
214 | width: 90%; | |
215 | } | |
216 | ||
217 | .slider::-moz-range-track { | |
218 | padding: 6px; | |
219 | background-color: transparent; | |
220 | border: none; | |
221 | } | |
222 | ||
223 | .slider::-webkit-slider-thumb { | |
224 | -webkit-appearance: none; | |
225 | appearance: none; | |
226 | height: 12px; | |
227 | width: 12px; | |
228 | border-radius: 50%; | |
229 | background: #ff4081; | |
230 | cursor: pointer; | |
231 | } | |
232 | ||
233 | .slider::-moz-range-thumb { | |
234 | height: 12px; | |
235 | width: 12px; | |
236 | border-radius: 50%; | |
237 | background: #ff4081; | |
238 | cursor: pointer; | |
239 | } | |
240 | ||
241 | .slider.muted { | |
242 | opacity: 0.27; | |
243 | } | |
244 | ||
245 | .client-settings { | |
246 | display: none; /* Hidden by default */ | |
247 | position: fixed; /* Stay in place */ | |
248 | z-index: 1; /* Sit on top */ | |
249 | left: 0; | |
250 | top: 0; | |
251 | width: 100%; /* Full width */ | |
252 | height: 100%; /* Full height */ | |
253 | overflow: auto; /* Enable scroll if needed */ | |
254 | background-color: rgb(0,0,0); /* Fallback color */ | |
255 | background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ | |
256 | } | |
257 | ||
258 | .client-setting-content { | |
259 | background-color: #fefefe; | |
260 | color: #686868; | |
261 | margin: 15% auto; /* 15% from the top and centered */ | |
262 | padding: 20px; | |
263 | border: 1px solid #888; | |
264 | width: 80%; /* Could be more or less, depending on screen size */ | |
265 | } | |
266 | ||
267 | .client-input { | |
268 | color: #686868; | |
269 | width: 100%; | |
270 | padding: 12px 20px; | |
271 | margin: 8px 0; | |
272 | display: block; | |
273 | border: 1px solid #ccc; | |
274 | border-radius: 4px; | |
275 | box-sizing: border-box; | |
276 | } | |
277 | ||
278 | input[type=submit] { | |
279 | width: 100%; | |
280 | background-color: #4CAF50; | |
281 | color: white; | |
282 | padding: 14px 20px; | |
283 | margin: 8px 0; | |
284 | border: none; | |
285 | border-radius: 4px; | |
286 | cursor: pointer; | |
287 | } | |
288 | ||
289 | input[type=submit]:hover { | |
290 | background-color: #45a049; | |
291 | } | |
292 | ||
293 | div.container { | |
294 | border-radius: 5px; | |
295 | background-color: #f2f2f2; | |
296 | padding: 20px; | |
297 | } | |
298 |
34 | 34 | |
35 | 35 | #if defined(HAS_AVAHI) |
36 | 36 | #include "publish_avahi.hpp" |
37 | typedef PublishAvahi PublishZeroConf; | |
37 | using PublishZeroConf = PublishAvahi; | |
38 | 38 | #elif defined(HAS_BONJOUR) |
39 | 39 | #include "publish_bonjour.hpp" |
40 | typedef PublishBonjour PublishZeroConf; | |
40 | using PublishZeroConf = PublishBonjour; | |
41 | 41 | #endif |
42 | 42 | |
43 | 43 | #endif |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "server.hpp" | |
19 | #include "common/aixlog.hpp" | |
20 | #include "config.hpp" | |
21 | #include "message/client_info.hpp" | |
22 | #include "message/hello.hpp" | |
23 | #include "message/stream_tags.hpp" | |
24 | #include "message/time.hpp" | |
25 | #include "stream_session_tcp.hpp" | |
26 | #include <iostream> | |
27 | ||
28 | using namespace std; | |
29 | using namespace streamreader; | |
30 | ||
31 | using json = nlohmann::json; | |
32 | ||
33 | static constexpr auto LOG_TAG = "Server"; | |
34 | ||
35 | Server::Server(boost::asio::io_context& io_context, const ServerSettings& serverSettings) | |
36 | : io_context_(io_context), config_timer_(io_context), settings_(serverSettings) | |
37 | { | |
38 | } | |
39 | ||
40 | ||
41 | Server::~Server() = default; | |
42 | ||
43 | ||
44 | void Server::onNewSession(const std::shared_ptr<StreamSession>& session) | |
45 | { | |
46 | LOG(INFO, LOG_TAG) << "onNewSession\n"; | |
47 | streamServer_->addSession(session); | |
48 | } | |
49 | ||
50 | ||
51 | void Server::onMetaChanged(const PcmStream* pcmStream) | |
52 | { | |
53 | // clang-format off | |
54 | // Notification: {"jsonrpc":"2.0","method":"Stream.OnMetadata","params":{"id":"stream 1", "meta": {"album": "some album", "artist": "some artist", "track": "some track"...}} | |
55 | // clang-format on | |
56 | ||
57 | const auto meta = pcmStream->getMeta(); | |
58 | LOG(DEBUG, LOG_TAG) << "metadata = " << meta->msg.dump(3) << "\n"; | |
59 | LOG(INFO, LOG_TAG) << "onMetaChanged (" << pcmStream->getName() << ")\n"; | |
60 | ||
61 | streamServer_->onMetaChanged(pcmStream, meta); | |
62 | ||
63 | // Send meta to all connected clients | |
64 | json notification = jsonrpcpp::Notification("Stream.OnMetadata", jsonrpcpp::Parameter("id", pcmStream->getId(), "meta", meta->msg)).to_json(); | |
65 | controlServer_->send(notification.dump(), nullptr); | |
66 | // cout << "Notification: " << notification.dump() << "\n"; | |
67 | } | |
68 | ||
69 | ||
70 | void Server::onStateChanged(const PcmStream* pcmStream, ReaderState state) | |
71 | { | |
72 | // clang-format off | |
73 | // Notification: {"jsonrpc":"2.0","method":"Stream.OnUpdate","params":{"id":"stream 1","stream":{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}}}} | |
74 | // clang-format on | |
75 | LOG(INFO, LOG_TAG) << "onStateChanged (" << pcmStream->getName() << "): " << static_cast<int>(state) << "\n"; | |
76 | // LOG(INFO, LOG_TAG) << pcmStream->toJson().dump(4); | |
77 | json notification = jsonrpcpp::Notification("Stream.OnUpdate", jsonrpcpp::Parameter("id", pcmStream->getId(), "stream", pcmStream->toJson())).to_json(); | |
78 | controlServer_->send(notification.dump(), nullptr); | |
79 | // cout << "Notification: " << notification.dump() << "\n"; | |
80 | } | |
81 | ||
82 | ||
83 | void Server::onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) | |
84 | { | |
85 | std::ignore = pcmStream; | |
86 | std::ignore = chunk; | |
87 | } | |
88 | ||
89 | ||
90 | void Server::onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) | |
91 | { | |
92 | streamServer_->onChunkEncoded(pcmStream, pcmStream == streamManager_->getDefaultStream().get(), chunk, duration); | |
93 | } | |
94 | ||
95 | ||
96 | void Server::onResync(const PcmStream* pcmStream, double ms) | |
97 | { | |
98 | LOG(INFO, LOG_TAG) << "onResync (" << pcmStream->getName() << "): " << ms << " ms\n"; | |
99 | } | |
100 | ||
101 | ||
102 | void Server::onDisconnect(StreamSession* streamSession) | |
103 | { | |
104 | // notify controllers if not yet done | |
105 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(streamSession->clientId); | |
106 | if (!clientInfo || !clientInfo->connected) | |
107 | return; | |
108 | ||
109 | clientInfo->connected = false; | |
110 | chronos::systemtimeofday(&clientInfo->lastSeen); | |
111 | saveConfig(); | |
112 | if (controlServer_ != nullptr) | |
113 | { | |
114 | // Check if there is no session of this client left | |
115 | // Can happen in case of ungraceful disconnect/reconnect or | |
116 | // in case of a duplicate client id | |
117 | if (streamServer_->getStreamSession(clientInfo->id) == nullptr) | |
118 | { | |
119 | // clang-format off | |
120 | // Notification: | |
121 | // {"jsonrpc":"2.0","method":"Client.OnDisconnect","params":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":false,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025523,"usec":814067},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},"id":"00:21:6a:7d:74:fc"}} | |
122 | // clang-format on | |
123 | json notification = | |
124 | jsonrpcpp::Notification("Client.OnDisconnect", jsonrpcpp::Parameter("id", clientInfo->id, "client", clientInfo->toJson())).to_json(); | |
125 | controlServer_->send(notification.dump()); | |
126 | // cout << "Notification: " << notification.dump() << "\n"; | |
127 | } | |
128 | } | |
129 | } | |
130 | ||
131 | ||
132 | void Server::processRequest(const jsonrpcpp::request_ptr request, jsonrpcpp::entity_ptr& response, jsonrpcpp::notification_ptr& notification) const | |
133 | { | |
134 | try | |
135 | { | |
136 | // LOG(INFO, LOG_TAG) << "Server::processRequest method: " << request->method << ", " << "id: " << request->id() << "\n"; | |
137 | Json result; | |
138 | ||
139 | if (request->method().find("Client.") == 0) | |
140 | { | |
141 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(request->params().get<std::string>("id")); | |
142 | if (clientInfo == nullptr) | |
143 | throw jsonrpcpp::InternalErrorException("Client not found", request->id()); | |
144 | ||
145 | if (request->method() == "Client.GetStatus") | |
146 | { | |
147 | // clang-format off | |
148 | // Request: {"id":8,"jsonrpc":"2.0","method":"Client.GetStatus","params":{"id":"00:21:6a:7d:74:fc"}} | |
149 | // Response: {"id":8,"jsonrpc":"2.0","result":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":74}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488026416,"usec":135973},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}}} | |
150 | // clang-format on | |
151 | result["client"] = clientInfo->toJson(); | |
152 | } | |
153 | else if (request->method() == "Client.SetVolume") | |
154 | { | |
155 | // clang-format off | |
156 | // Request: {"id":8,"jsonrpc":"2.0","method":"Client.SetVolume","params":{"id":"00:21:6a:7d:74:fc","volume":{"muted":false,"percent":74}}} | |
157 | // Response: {"id":8,"jsonrpc":"2.0","result":{"volume":{"muted":false,"percent":74}}} | |
158 | // Notification: {"jsonrpc":"2.0","method":"Client.OnVolumeChanged","params":{"id":"00:21:6a:7d:74:fc","volume":{"muted":false,"percent":74}}} | |
159 | // clang-format on | |
160 | ||
161 | std::lock_guard<std::recursive_mutex> lock(clientMutex_); | |
162 | clientInfo->config.volume.fromJson(request->params().get("volume")); | |
163 | result["volume"] = clientInfo->config.volume.toJson(); | |
164 | notification.reset(new jsonrpcpp::Notification("Client.OnVolumeChanged", | |
165 | jsonrpcpp::Parameter("id", clientInfo->id, "volume", clientInfo->config.volume.toJson()))); | |
166 | } | |
167 | else if (request->method() == "Client.SetLatency") | |
168 | { | |
169 | // clang-format off | |
170 | // Request: {"id":7,"jsonrpc":"2.0","method":"Client.SetLatency","params":{"id":"00:21:6a:7d:74:fc#2","latency":10}} | |
171 | // Response: {"id":7,"jsonrpc":"2.0","result":{"latency":10}} | |
172 | // Notification: {"jsonrpc":"2.0","method":"Client.OnLatencyChanged","params":{"id":"00:21:6a:7d:74:fc#2","latency":10}} | |
173 | // clang-format on | |
174 | int latency = request->params().get("latency"); | |
175 | if (latency < -10000) | |
176 | latency = -10000; | |
177 | else if (latency > settings_.stream.bufferMs) | |
178 | latency = settings_.stream.bufferMs; | |
179 | clientInfo->config.latency = latency; //, -10000, settings_.stream.bufferMs); | |
180 | result["latency"] = clientInfo->config.latency; | |
181 | notification.reset( | |
182 | new jsonrpcpp::Notification("Client.OnLatencyChanged", jsonrpcpp::Parameter("id", clientInfo->id, "latency", clientInfo->config.latency))); | |
183 | } | |
184 | else if (request->method() == "Client.SetName") | |
185 | { | |
186 | // clang-format off | |
187 | // Request: {"id":6,"jsonrpc":"2.0","method":"Client.SetName","params":{"id":"00:21:6a:7d:74:fc#2","name":"Laptop"}} | |
188 | // Response: {"id":6,"jsonrpc":"2.0","result":{"name":"Laptop"}} | |
189 | // Notification: {"jsonrpc":"2.0","method":"Client.OnNameChanged","params":{"id":"00:21:6a:7d:74:fc#2","name":"Laptop"}} | |
190 | // clang-format on | |
191 | clientInfo->config.name = request->params().get<std::string>("name"); | |
192 | result["name"] = clientInfo->config.name; | |
193 | notification.reset( | |
194 | new jsonrpcpp::Notification("Client.OnNameChanged", jsonrpcpp::Parameter("id", clientInfo->id, "name", clientInfo->config.name))); | |
195 | } | |
196 | else | |
197 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
198 | ||
199 | ||
200 | if (request->method().find("Client.Set") == 0) | |
201 | { | |
202 | /// Update client | |
203 | session_ptr session = streamServer_->getStreamSession(clientInfo->id); | |
204 | if (session != nullptr) | |
205 | { | |
206 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
207 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
208 | serverSettings->setVolume(clientInfo->config.volume.percent); | |
209 | GroupPtr group = Config::instance().getGroupFromClient(clientInfo); | |
210 | serverSettings->setMuted(clientInfo->config.volume.muted || group->muted); | |
211 | serverSettings->setLatency(clientInfo->config.latency); | |
212 | session->send(serverSettings); | |
213 | } | |
214 | } | |
215 | } | |
216 | else if (request->method().find("Group.") == 0) | |
217 | { | |
218 | GroupPtr group = Config::instance().getGroup(request->params().get<std::string>("id")); | |
219 | if (group == nullptr) | |
220 | throw jsonrpcpp::InternalErrorException("Group not found", request->id()); | |
221 | ||
222 | if (request->method() == "Group.GetStatus") | |
223 | { | |
224 | // clang-format off | |
225 | // Request: {"id":5,"jsonrpc":"2.0","method":"Group.GetStatus","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"}} | |
226 | // Response: {"id":5,"jsonrpc":"2.0","result":{"group":{"clients":[{"config":{"instance":2,"latency":10,"name":"Laptop","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488026485,"usec":644997},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":74}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488026481,"usec":223747},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":true,"name":"","stream_id":"stream 1"}}} | |
227 | // clang-format on | |
228 | result["group"] = group->toJson(); | |
229 | } | |
230 | else if (request->method() == "Group.SetName") | |
231 | { | |
232 | // clang-format off | |
233 | // Request: {"id":6,"jsonrpc":"2.0","method":"Group.SetName","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","name":"Laptop"}} | |
234 | // Response: {"id":6,"jsonrpc":"2.0","result":{"name":"MediaPlayer"}} | |
235 | // Notification: {"jsonrpc":"2.0","method":"Group.OnNameChanged","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","MediaPlayer":"Laptop"}} | |
236 | // clang-format on | |
237 | group->name = request->params().get<std::string>("name"); | |
238 | result["name"] = group->name; | |
239 | notification.reset(new jsonrpcpp::Notification("Group.OnNameChanged", jsonrpcpp::Parameter("id", group->id, "name", group->name))); | |
240 | } | |
241 | else if (request->method() == "Group.SetMute") | |
242 | { | |
243 | // clang-format off | |
244 | // Request: {"id":5,"jsonrpc":"2.0","method":"Group.SetMute","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","mute":true}} | |
245 | // Response: {"id":5,"jsonrpc":"2.0","result":{"mute":true}} | |
246 | // Notification: {"jsonrpc":"2.0","method":"Group.OnMute","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","mute":true}} | |
247 | // clang-format on | |
248 | bool muted = request->params().get<bool>("mute"); | |
249 | group->muted = muted; | |
250 | ||
251 | /// Update clients | |
252 | for (auto client : group->clients) | |
253 | { | |
254 | session_ptr session = streamServer_->getStreamSession(client->id); | |
255 | if (session != nullptr) | |
256 | { | |
257 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
258 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
259 | serverSettings->setVolume(client->config.volume.percent); | |
260 | GroupPtr group = Config::instance().getGroupFromClient(client); | |
261 | serverSettings->setMuted(client->config.volume.muted || group->muted); | |
262 | serverSettings->setLatency(client->config.latency); | |
263 | session->send(serverSettings); | |
264 | } | |
265 | } | |
266 | ||
267 | result["mute"] = group->muted; | |
268 | notification.reset(new jsonrpcpp::Notification("Group.OnMute", jsonrpcpp::Parameter("id", group->id, "mute", group->muted))); | |
269 | } | |
270 | else if (request->method() == "Group.SetStream") | |
271 | { | |
272 | // clang-format off | |
273 | // Request: {"id":4,"jsonrpc":"2.0","method":"Group.SetStream","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","stream_id":"stream 1"}} | |
274 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"stream 1"}} | |
275 | // Notification: {"jsonrpc":"2.0","method":"Group.OnStreamChanged","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","stream_id":"stream 1"}} | |
276 | // clang-format on | |
277 | string streamId = request->params().get<std::string>("stream_id"); | |
278 | PcmStreamPtr stream = streamManager_->getStream(streamId); | |
279 | if (stream == nullptr) | |
280 | throw jsonrpcpp::InternalErrorException("Stream not found", request->id()); | |
281 | ||
282 | group->streamId = streamId; | |
283 | ||
284 | // Update clients | |
285 | for (auto client : group->clients) | |
286 | { | |
287 | session_ptr session = streamServer_->getStreamSession(client->id); | |
288 | if (session && (session->pcmStream() != stream)) | |
289 | { | |
290 | session->send(stream->getMeta()); | |
291 | session->send(stream->getHeader()); | |
292 | session->setPcmStream(stream); | |
293 | } | |
294 | } | |
295 | ||
296 | // Notify others | |
297 | result["stream_id"] = group->streamId; | |
298 | notification.reset(new jsonrpcpp::Notification("Group.OnStreamChanged", jsonrpcpp::Parameter("id", group->id, "stream_id", group->streamId))); | |
299 | } | |
300 | else if (request->method() == "Group.SetClients") | |
301 | { | |
302 | // clang-format off | |
303 | // Request: {"id":3,"jsonrpc":"2.0","method":"Group.SetClients","params":{"clients":["00:21:6a:7d:74:fc#2","00:21:6a:7d:74:fc"],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"}} | |
304 | // Response: {"id":3,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025901,"usec":864472},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025905,"usec":45238},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
305 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025901,"usec":864472},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025905,"usec":45238},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
306 | // clang-format on | |
307 | vector<string> clients = request->params().get("clients"); | |
308 | // Remove clients from group | |
309 | for (auto iter = group->clients.begin(); iter != group->clients.end();) | |
310 | { | |
311 | auto client = *iter; | |
312 | if (find(clients.begin(), clients.end(), client->id) != clients.end()) | |
313 | { | |
314 | ++iter; | |
315 | continue; | |
316 | } | |
317 | iter = group->clients.erase(iter); | |
318 | GroupPtr newGroup = Config::instance().addClientInfo(client); | |
319 | newGroup->streamId = group->streamId; | |
320 | } | |
321 | ||
322 | // Add clients to group | |
323 | PcmStreamPtr stream = streamManager_->getStream(group->streamId); | |
324 | for (const auto& clientId : clients) | |
325 | { | |
326 | ClientInfoPtr client = Config::instance().getClientInfo(clientId); | |
327 | if (!client) | |
328 | continue; | |
329 | GroupPtr oldGroup = Config::instance().getGroupFromClient(client); | |
330 | if (oldGroup && (oldGroup->id == group->id)) | |
331 | continue; | |
332 | ||
333 | if (oldGroup) | |
334 | { | |
335 | oldGroup->removeClient(client); | |
336 | Config::instance().remove(oldGroup); | |
337 | } | |
338 | ||
339 | group->addClient(client); | |
340 | ||
341 | // assign new stream | |
342 | session_ptr session = streamServer_->getStreamSession(client->id); | |
343 | if (session && stream && (session->pcmStream() != stream)) | |
344 | { | |
345 | session->send(stream->getMeta()); | |
346 | session->send(stream->getHeader()); | |
347 | session->setPcmStream(stream); | |
348 | } | |
349 | } | |
350 | ||
351 | if (group->empty()) | |
352 | Config::instance().remove(group); | |
353 | ||
354 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
355 | result["server"] = server; | |
356 | ||
357 | // Notify others: since at least two groups are affected, send a complete server update | |
358 | notification.reset(new jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server))); | |
359 | } | |
360 | else | |
361 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
362 | } | |
363 | else if (request->method().find("Server.") == 0) | |
364 | { | |
365 | if (request->method().find("Server.GetRPCVersion") == 0) | |
366 | { | |
367 | // Request: {"id":8,"jsonrpc":"2.0","method":"Server.GetRPCVersion"} | |
368 | // Response: {"id":8,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
369 | // <major>: backwards incompatible change | |
370 | result["major"] = 2; | |
371 | // <minor>: feature addition to the API | |
372 | result["minor"] = 0; | |
373 | // <patch>: bugfix release | |
374 | result["patch"] = 0; | |
375 | } | |
376 | else if (request->method() == "Server.GetStatus") | |
377 | { | |
378 | // clang-format off | |
379 | // Request: {"id":1,"jsonrpc":"2.0","method":"Server.GetStatus"} | |
380 | // Response: {"id":1,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025696,"usec":578142},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":true,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025696,"usec":611255},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
381 | // clang-format on | |
382 | result["server"] = Config::instance().getServerStatus(streamManager_->toJson()); | |
383 | } | |
384 | else if (request->method() == "Server.DeleteClient") | |
385 | { | |
386 | // clang-format off | |
387 | // Request: {"id":2,"jsonrpc":"2.0","method":"Server.DeleteClient","params":{"id":"00:21:6a:7d:74:fc"}} | |
388 | // Response: {"id":2,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025751,"usec":654777},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
389 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025751,"usec":654777},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
390 | // clang-format on | |
391 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(request->params().get<std::string>("id")); | |
392 | if (clientInfo == nullptr) | |
393 | throw jsonrpcpp::InternalErrorException("Client not found", request->id()); | |
394 | ||
395 | Config::instance().remove(clientInfo); | |
396 | ||
397 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
398 | result["server"] = server; | |
399 | ||
400 | /// Notify others | |
401 | notification.reset(new jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server))); | |
402 | } | |
403 | else | |
404 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
405 | } | |
406 | else if (request->method().find("Stream.") == 0) | |
407 | { | |
408 | if (request->method().find("Stream.SetMeta") == 0) | |
409 | { | |
410 | // clang-format off | |
411 | // Request: {"id":4,"jsonrpc":"2.0","method":"Stream.SetMeta","params":{"id":"Spotify", "meta": {"album": "some album", "artist": "some artist", "track": "some track"...}}} | |
412 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
413 | // Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
414 | // clang-format on | |
415 | ||
416 | LOG(INFO, LOG_TAG) << "Stream.SetMeta(" << request->params().get<std::string>("id") << ")" << request->params().get("meta") << "\n"; | |
417 | ||
418 | // Find stream | |
419 | string streamId = request->params().get<std::string>("id"); | |
420 | PcmStreamPtr stream = streamManager_->getStream(streamId); | |
421 | if (stream == nullptr) | |
422 | throw jsonrpcpp::InternalErrorException("Stream not found", request->id()); | |
423 | ||
424 | // Set metadata from request | |
425 | stream->setMeta(request->params().get("meta")); | |
426 | ||
427 | // Setup response | |
428 | result["id"] = streamId; | |
429 | } | |
430 | else if (request->method() == "Stream.AddStream") | |
431 | { | |
432 | // clang-format off | |
433 | // Request: {"id":4,"jsonrpc":"2.0","method":"Stream.AddStream","params":{"streamUri":"uri"}} | |
434 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
435 | // Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
436 | // clang-format on | |
437 | ||
438 | LOG(INFO, LOG_TAG) << "Stream.AddStream(" << request->params().get("streamUri") << ")" | |
439 | << "\n"; | |
440 | ||
441 | // Find stream | |
442 | string streamUri = request->params().get("streamUri"); | |
443 | PcmStreamPtr stream = streamManager_->addStream(streamUri); | |
444 | if (stream == nullptr) | |
445 | throw jsonrpcpp::InternalErrorException("Stream not created", request->id()); | |
446 | stream->start(); // We start the stream, otherwise it would be silent | |
447 | // Setup response | |
448 | result["id"] = stream->getId(); | |
449 | } | |
450 | else if (request->method() == "Stream.RemoveStream") | |
451 | { | |
452 | // clang-format off | |
453 | // Request: {"id":4,"jsonrpc":"2.0","method":"Stream.RemoveStream","params":{"id":"Spotify"}} | |
454 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
455 | // Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
456 | // clang-format on | |
457 | ||
458 | LOG(INFO, LOG_TAG) << "Stream.RemoveStream(" << request->params().get("id") << ")" | |
459 | << "\n"; | |
460 | ||
461 | // Find stream | |
462 | string streamId = request->params().get("id"); | |
463 | streamManager_->removeStream(streamId); | |
464 | // Setup response | |
465 | result["id"] = streamId; | |
466 | } | |
467 | else | |
468 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
469 | } | |
470 | else | |
471 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
472 | ||
473 | response.reset(new jsonrpcpp::Response(*request, result)); | |
474 | } | |
475 | catch (const jsonrpcpp::RequestException& e) | |
476 | { | |
477 | LOG(ERROR, LOG_TAG) << "Server::onMessageReceived JsonRequestException: " << e.to_json().dump() << ", message: " << request->to_json().dump() << "\n"; | |
478 | response.reset(new jsonrpcpp::RequestException(e)); | |
479 | } | |
480 | catch (const exception& e) | |
481 | { | |
482 | LOG(ERROR, LOG_TAG) << "Server::onMessageReceived exception: " << e.what() << ", message: " << request->to_json().dump() << "\n"; | |
483 | response.reset(new jsonrpcpp::InternalErrorException(e.what(), request->id())); | |
484 | } | |
485 | } | |
486 | ||
487 | ||
488 | std::string Server::onMessageReceived(ControlSession* controlSession, const std::string& message) | |
489 | { | |
490 | // LOG(DEBUG, LOG_TAG) << "onMessageReceived: " << message << "\n"; | |
491 | jsonrpcpp::entity_ptr entity(nullptr); | |
492 | try | |
493 | { | |
494 | entity = jsonrpcpp::Parser::do_parse(message); | |
495 | if (!entity) | |
496 | return ""; | |
497 | } | |
498 | catch (const jsonrpcpp::ParseErrorException& e) | |
499 | { | |
500 | return e.to_json().dump(); | |
501 | } | |
502 | catch (const std::exception& e) | |
503 | { | |
504 | return jsonrpcpp::ParseErrorException(e.what()).to_json().dump(); | |
505 | } | |
506 | ||
507 | jsonrpcpp::entity_ptr response(nullptr); | |
508 | jsonrpcpp::notification_ptr notification(nullptr); | |
509 | if (entity->is_request()) | |
510 | { | |
511 | jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(entity); | |
512 | processRequest(request, response, notification); | |
513 | saveConfig(); | |
514 | ////cout << "Request: " << request->to_json().dump() << "\n"; | |
515 | if (notification) | |
516 | { | |
517 | ////cout << "Notification: " << notification->to_json().dump() << "\n"; | |
518 | controlServer_->send(notification->to_json().dump(), controlSession); | |
519 | } | |
520 | if (response) | |
521 | { | |
522 | ////cout << "Response: " << response->to_json().dump() << "\n"; | |
523 | return response->to_json().dump(); | |
524 | } | |
525 | return ""; | |
526 | } | |
527 | else if (entity->is_batch()) | |
528 | { | |
529 | jsonrpcpp::batch_ptr batch = dynamic_pointer_cast<jsonrpcpp::Batch>(entity); | |
530 | ////cout << "Batch: " << batch->to_json().dump() << "\n"; | |
531 | jsonrpcpp::Batch responseBatch; | |
532 | jsonrpcpp::Batch notificationBatch; | |
533 | for (const auto& batch_entity : batch->entities) | |
534 | { | |
535 | if (batch_entity->is_request()) | |
536 | { | |
537 | jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(batch_entity); | |
538 | processRequest(request, response, notification); | |
539 | if (response != nullptr) | |
540 | responseBatch.add_ptr(response); | |
541 | if (notification != nullptr) | |
542 | notificationBatch.add_ptr(notification); | |
543 | } | |
544 | } | |
545 | saveConfig(); | |
546 | if (!notificationBatch.entities.empty()) | |
547 | controlServer_->send(notificationBatch.to_json().dump(), controlSession); | |
548 | if (!responseBatch.entities.empty()) | |
549 | return responseBatch.to_json().dump(); | |
550 | return ""; | |
551 | } | |
552 | return ""; | |
553 | } | |
554 | ||
555 | ||
556 | ||
557 | void Server::onMessageReceived(StreamSession* streamSession, const msg::BaseMessage& baseMessage, char* buffer) | |
558 | { | |
559 | LOG(DEBUG, LOG_TAG) << "onMessageReceived: " << baseMessage.type << ", size: " << baseMessage.size << ", id: " << baseMessage.id | |
560 | << ", refers: " << baseMessage.refersTo << ", sent: " << baseMessage.sent.sec << "," << baseMessage.sent.usec | |
561 | << ", recv: " << baseMessage.received.sec << "," << baseMessage.received.usec << "\n"; | |
562 | if (baseMessage.type == message_type::kTime) | |
563 | { | |
564 | auto timeMsg = make_shared<msg::Time>(); | |
565 | timeMsg->deserialize(baseMessage, buffer); | |
566 | timeMsg->refersTo = timeMsg->id; | |
567 | timeMsg->latency = timeMsg->received - timeMsg->sent; | |
568 | // LOG(INFO, LOG_TAG) << "Latency sec: " << timeMsg.latency.sec << ", usec: " << timeMsg.latency.usec << ", refers to: " << timeMsg.refersTo << "\n"; | |
569 | streamSession->send(timeMsg); | |
570 | ||
571 | // refresh streamSession state | |
572 | ClientInfoPtr client = Config::instance().getClientInfo(streamSession->clientId); | |
573 | if (client != nullptr) | |
574 | { | |
575 | chronos::systemtimeofday(&client->lastSeen); | |
576 | client->connected = true; | |
577 | } | |
578 | } | |
579 | else if (baseMessage.type == message_type::kClientInfo) | |
580 | { | |
581 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(streamSession->clientId); | |
582 | if (clientInfo == nullptr) | |
583 | { | |
584 | LOG(ERROR, LOG_TAG) << "client not found: " << streamSession->clientId << "\n"; | |
585 | return; | |
586 | } | |
587 | msg::ClientInfo infoMsg; | |
588 | infoMsg.deserialize(baseMessage, buffer); | |
589 | ||
590 | clientInfo->config.volume.percent = infoMsg.getVolume(); | |
591 | clientInfo->config.volume.muted = infoMsg.isMuted(); | |
592 | jsonrpcpp::notification_ptr notification = make_shared<jsonrpcpp::Notification>( | |
593 | "Client.OnVolumeChanged", jsonrpcpp::Parameter("id", streamSession->clientId, "volume", clientInfo->config.volume.toJson())); | |
594 | controlServer_->send(notification->to_json().dump()); | |
595 | } | |
596 | else if (baseMessage.type == message_type::kHello) | |
597 | { | |
598 | msg::Hello helloMsg; | |
599 | helloMsg.deserialize(baseMessage, buffer); | |
600 | streamSession->clientId = helloMsg.getUniqueId(); | |
601 | LOG(INFO, LOG_TAG) << "Hello from " << streamSession->clientId << ", host: " << helloMsg.getHostName() << ", v" << helloMsg.getVersion() | |
602 | << ", ClientName: " << helloMsg.getClientName() << ", OS: " << helloMsg.getOS() << ", Arch: " << helloMsg.getArch() | |
603 | << ", Protocol version: " << helloMsg.getProtocolVersion() << "\n"; | |
604 | ||
605 | bool newGroup(false); | |
606 | GroupPtr group = Config::instance().getGroupFromClient(streamSession->clientId); | |
607 | if (group == nullptr) | |
608 | { | |
609 | group = Config::instance().addClientInfo(streamSession->clientId); | |
610 | newGroup = true; | |
611 | } | |
612 | ||
613 | ClientInfoPtr client = group->getClient(streamSession->clientId); | |
614 | ||
615 | LOG(DEBUG, LOG_TAG) << "Sending ServerSettings to " << streamSession->clientId << "\n"; | |
616 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
617 | serverSettings->setVolume(client->config.volume.percent); | |
618 | serverSettings->setMuted(client->config.volume.muted || group->muted); | |
619 | serverSettings->setLatency(client->config.latency); | |
620 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
621 | serverSettings->refersTo = helloMsg.id; | |
622 | streamSession->send(serverSettings); | |
623 | ||
624 | client->host.mac = helloMsg.getMacAddress(); | |
625 | client->host.ip = streamSession->getIP(); | |
626 | client->host.name = helloMsg.getHostName(); | |
627 | client->host.os = helloMsg.getOS(); | |
628 | client->host.arch = helloMsg.getArch(); | |
629 | client->snapclient.version = helloMsg.getVersion(); | |
630 | client->snapclient.name = helloMsg.getClientName(); | |
631 | client->snapclient.protocolVersion = helloMsg.getProtocolVersion(); | |
632 | client->config.instance = helloMsg.getInstance(); | |
633 | client->connected = true; | |
634 | chronos::systemtimeofday(&client->lastSeen); | |
635 | ||
636 | // Assign and update stream | |
637 | PcmStreamPtr stream = streamManager_->getStream(group->streamId); | |
638 | if (!stream) | |
639 | { | |
640 | stream = streamManager_->getDefaultStream(); | |
641 | group->streamId = stream->getId(); | |
642 | } | |
643 | LOG(DEBUG, LOG_TAG) << "Group: " << group->id << ", stream: " << group->streamId << "\n"; | |
644 | ||
645 | saveConfig(); | |
646 | ||
647 | LOG(DEBUG, LOG_TAG) << "Sending meta data to " << streamSession->clientId << "\n"; | |
648 | streamSession->send(stream->getMeta()); | |
649 | streamSession->setPcmStream(stream); | |
650 | auto headerChunk = stream->getHeader(); | |
651 | LOG(DEBUG, LOG_TAG) << "Sending codec header to " << streamSession->clientId << "\n"; | |
652 | streamSession->send(headerChunk); | |
653 | ||
654 | if (newGroup) | |
655 | { | |
656 | // clang-format off | |
657 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025796,"usec":714671},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"},{"clients":[{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025798,"usec":728305},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"c5da8f7a-f377-1e51-8266-c5cc61099b71","muted":false,"name":"","stream_id":"stream 1"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
658 | // clang-format on | |
659 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
660 | json notification = jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server)).to_json(); | |
661 | controlServer_->send(notification.dump()); | |
662 | } | |
663 | else | |
664 | { | |
665 | // clang-format off | |
666 | // Notification: {"jsonrpc":"2.0","method":"Client.OnConnect","params":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":true,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025524,"usec":876332},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},"id":"00:21:6a:7d:74:fc"}} | |
667 | // clang-format on | |
668 | json notification = jsonrpcpp::Notification("Client.OnConnect", jsonrpcpp::Parameter("id", client->id, "client", client->toJson())).to_json(); | |
669 | controlServer_->send(notification.dump()); | |
670 | // cout << "Notification: " << notification.dump() << "\n"; | |
671 | } | |
672 | // cout << Config::instance().getServerStatus(streamManager_->toJson()).dump(4) << "\n"; | |
673 | // cout << group->toJson().dump(4) << "\n"; | |
674 | } | |
675 | } | |
676 | ||
677 | ||
678 | void Server::saveConfig(const std::chrono::milliseconds& deferred) | |
679 | { | |
680 | config_timer_.cancel(); | |
681 | config_timer_.expires_after(deferred); | |
682 | config_timer_.async_wait([](const boost::system::error_code& ec) { | |
683 | if (!ec) | |
684 | { | |
685 | LOG(DEBUG, LOG_TAG) << "Saving config\n"; | |
686 | Config::instance().save(); | |
687 | } | |
688 | }); | |
689 | } | |
690 | ||
691 | ||
692 | void Server::start() | |
693 | { | |
694 | try | |
695 | { | |
696 | controlServer_ = std::make_unique<ControlServer>(io_context_, settings_.tcp, settings_.http, this); | |
697 | streamServer_ = std::make_unique<StreamServer>(io_context_, settings_, this); | |
698 | streamManager_ = | |
699 | std::make_unique<StreamManager>(this, io_context_, settings_.stream.sampleFormat, settings_.stream.codec, settings_.stream.streamChunkMs); | |
700 | // throw SnapException("xxx"); | |
701 | // Add normal sources first | |
702 | for (const auto& sourceUri : settings_.stream.sources) | |
703 | { | |
704 | StreamUri streamUri(sourceUri); | |
705 | if (streamUri.scheme == "meta") | |
706 | continue; | |
707 | PcmStreamPtr stream = streamManager_->addStream(streamUri); | |
708 | if (stream) | |
709 | LOG(INFO, LOG_TAG) << "Stream: " << stream->getUri().toJson() << "\n"; | |
710 | } | |
711 | // Add meta sources second | |
712 | for (const auto& sourceUri : settings_.stream.sources) | |
713 | { | |
714 | StreamUri streamUri(sourceUri); | |
715 | if (streamUri.scheme != "meta") | |
716 | continue; | |
717 | PcmStreamPtr stream = streamManager_->addStream(streamUri); | |
718 | if (stream) | |
719 | LOG(INFO, LOG_TAG) << "Stream: " << stream->getUri().toJson() << "\n"; | |
720 | } | |
721 | ||
722 | streamManager_->start(); | |
723 | controlServer_->start(); | |
724 | streamServer_->start(); | |
725 | } | |
726 | catch (const std::exception& e) | |
727 | { | |
728 | LOG(NOTICE, LOG_TAG) << "Server::start: " << e.what() << endl; | |
729 | stop(); | |
730 | throw; | |
731 | } | |
732 | } | |
733 | ||
734 | ||
735 | void Server::stop() | |
736 | { | |
737 | if (streamManager_) | |
738 | { | |
739 | streamManager_->stop(); | |
740 | streamManager_ = nullptr; | |
741 | } | |
742 | ||
743 | if (controlServer_) | |
744 | { | |
745 | controlServer_->stop(); | |
746 | controlServer_ = nullptr; | |
747 | } | |
748 | ||
749 | if (streamServer_) | |
750 | { | |
751 | streamServer_->stop(); | |
752 | streamServer_ = nullptr; | |
753 | } | |
754 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef SERVER_HPP | |
19 | #define SERVER_HPP | |
20 | ||
21 | #include <boost/asio.hpp> | |
22 | #include <memory> | |
23 | #include <mutex> | |
24 | #include <set> | |
25 | #include <sstream> | |
26 | #include <vector> | |
27 | ||
28 | #include "common/queue.h" | |
29 | #include "common/sample_format.hpp" | |
30 | #include "control_server.hpp" | |
31 | #include "jsonrpcpp.hpp" | |
32 | #include "message/codec_header.hpp" | |
33 | #include "message/message.hpp" | |
34 | #include "message/server_settings.hpp" | |
35 | #include "server_settings.hpp" | |
36 | #include "stream_server.hpp" | |
37 | #include "stream_session.hpp" | |
38 | #include "streamreader/stream_manager.hpp" | |
39 | ||
40 | using namespace streamreader; | |
41 | ||
42 | using boost::asio::ip::tcp; | |
43 | using acceptor_ptr = std::unique_ptr<tcp::acceptor>; | |
44 | using session_ptr = std::shared_ptr<StreamSession>; | |
45 | ||
46 | ||
47 | /// Forwars PCM data to the connected clients | |
48 | /** | |
49 | * Reads PCM data using PipeStream, implements PcmListener to get the (encoded) PCM stream. | |
50 | * Accepts and holds client connections (StreamSession) | |
51 | * Receives (via the StreamMessageReceiver interface) and answers messages from the clients | |
52 | * Forwards PCM data to the clients | |
53 | */ | |
54 | class Server : public StreamMessageReceiver, public ControlMessageReceiver, public PcmListener | |
55 | { | |
56 | public: | |
57 | Server(boost::asio::io_context& io_context, const ServerSettings& serverSettings); | |
58 | virtual ~Server(); | |
59 | ||
60 | void start(); | |
61 | void stop(); | |
62 | ||
63 | private: | |
64 | /// Implementation of StreamMessageReceiver | |
65 | void onMessageReceived(StreamSession* connection, const msg::BaseMessage& baseMessage, char* buffer) override; | |
66 | void onDisconnect(StreamSession* connection) override; | |
67 | ||
68 | /// Implementation of ControllMessageReceiver | |
69 | std::string onMessageReceived(ControlSession* connection, const std::string& message) override; | |
70 | void onNewSession(const std::shared_ptr<ControlSession>& session) override | |
71 | { | |
72 | std::ignore = session; | |
73 | }; | |
74 | void onNewSession(const std::shared_ptr<StreamSession>& session) override; | |
75 | ||
76 | /// Implementation of PcmListener | |
77 | void onMetaChanged(const PcmStream* pcmStream) override; | |
78 | void onStateChanged(const PcmStream* pcmStream, ReaderState state) override; | |
79 | void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override; | |
80 | void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) override; | |
81 | void onResync(const PcmStream* pcmStream, double ms) override; | |
82 | ||
83 | private: | |
84 | void processRequest(const jsonrpcpp::request_ptr request, jsonrpcpp::entity_ptr& response, jsonrpcpp::notification_ptr& notification) const; | |
85 | /// Save the server state deferred to prevent blocking and lower disk io | |
86 | /// @param deferred the delay after the last call to saveConfig | |
87 | void saveConfig(const std::chrono::milliseconds& deferred = std::chrono::seconds(2)); | |
88 | ||
89 | mutable std::recursive_mutex sessionsMutex_; | |
90 | mutable std::recursive_mutex clientMutex_; | |
91 | boost::asio::io_context& io_context_; | |
92 | boost::asio::steady_timer config_timer_; | |
93 | ||
94 | ServerSettings settings_; | |
95 | Queue<std::shared_ptr<msg::BaseMessage>> messages_; | |
96 | std::unique_ptr<ControlServer> controlServer_; | |
97 | std::unique_ptr<StreamServer> streamServer_; | |
98 | std::unique_ptr<StreamManager> streamManager_; | |
99 | }; | |
100 | ||
101 | ||
102 | ||
103 | #endif |
23 | 23 | |
24 | 24 | struct ServerSettings |
25 | 25 | { |
26 | struct HttpSettings | |
26 | struct Server | |
27 | { | |
28 | int threads{-1}; | |
29 | std::string pid_file{"/var/run/snapserver/pid"}; | |
30 | std::string user{"snapserver"}; | |
31 | std::string group{""}; | |
32 | std::string data_dir{""}; | |
33 | }; | |
34 | ||
35 | struct Http | |
27 | 36 | { |
28 | 37 | bool enabled{true}; |
29 | 38 | size_t port{1780}; |
31 | 40 | std::string doc_root{""}; |
32 | 41 | }; |
33 | 42 | |
34 | struct TcpSettings | |
43 | struct Tcp | |
35 | 44 | { |
36 | 45 | bool enabled{true}; |
37 | 46 | size_t port{1705}; |
38 | 47 | std::vector<std::string> bind_to_address{{"0.0.0.0"}}; |
39 | 48 | }; |
40 | 49 | |
41 | struct StreamSettings | |
50 | struct Stream | |
42 | 51 | { |
43 | 52 | size_t port{1704}; |
44 | std::vector<std::string> pcmStreams; | |
53 | std::vector<std::string> sources; | |
45 | 54 | std::string codec{"flac"}; |
46 | 55 | int32_t bufferMs{1000}; |
47 | 56 | std::string sampleFormat{"48000:16:2"}; |
50 | 59 | std::vector<std::string> bind_to_address{{"0.0.0.0"}}; |
51 | 60 | }; |
52 | 61 | |
53 | struct LoggingSettings | |
62 | struct Logging | |
54 | 63 | { |
55 | bool debug{false}; | |
56 | std::string debug_logfile{""}; | |
64 | std::string sink{""}; | |
65 | std::string filter{"*:info"}; | |
57 | 66 | }; |
58 | 67 | |
59 | HttpSettings http; | |
60 | TcpSettings tcp; | |
61 | StreamSettings stream; | |
62 | LoggingSettings logging; | |
68 | Server server; | |
69 | Http http; | |
70 | Tcp tcp; | |
71 | Stream stream; | |
72 | Logging logging; | |
63 | 73 | }; |
64 | 74 | |
65 | 75 | #endif |
0 | .TH SNAPSERVER 1 "January 2020" | |
0 | .TH SNAPSERVER 1 "June 2020" | |
1 | 1 | .SH NAME |
2 | 2 | snapserver - Snapcast server |
3 | 3 | .SH SYNOPSIS |
24 | 24 | Daemonize |
25 | 25 | optional process priority [-20..19] |
26 | 26 | .TP |
27 | \fB--user arg\fR | |
28 | the user[:group] to run snapserver as when daemonized | |
29 | .TP | |
30 | 27 | \fB-c, --config arg (=/etc/snapserver.conf)\fR |
31 | 28 | path to the configuration file |
32 | 29 | .SH FILES |
29 | 29 | #include "common/utils/string_utils.hpp" |
30 | 30 | #include "encoder/encoder_factory.hpp" |
31 | 31 | #include "message/message.hpp" |
32 | #include "server.hpp" | |
32 | 33 | #include "server_settings.hpp" |
33 | #include "stream_server.hpp" | |
34 | 34 | #if defined(HAS_AVAHI) || defined(HAS_BONJOUR) |
35 | 35 | #include "publishZeroConf/publish_mdns.hpp" |
36 | 36 | #endif |
52 | 52 | try |
53 | 53 | { |
54 | 54 | ServerSettings settings; |
55 | std::string pcmStream = "pipe:///tmp/snapfifo?name=default"; | |
55 | std::string pcmSource = "pipe:///tmp/snapfifo?name=default"; | |
56 | 56 | std::string config_file = "/etc/snapserver.conf"; |
57 | 57 | |
58 | 58 | OptionParser op("Allowed options"); |
62 | 62 | #ifdef HAS_DAEMON |
63 | 63 | int processPriority(0); |
64 | 64 | auto daemonOption = op.add<Implicit<int>>("d", "daemon", "Daemonize\noptional process priority [-20..19]", 0, &processPriority); |
65 | auto userValue = op.add<Value<string>>("", "user", "the user[:group] to run snapserver as when daemonized", ""); | |
66 | #endif | |
67 | ||
65 | #endif | |
68 | 66 | op.add<Value<string>>("c", "config", "path to the configuration file", config_file, &config_file); |
69 | 67 | |
70 | // debug settings | |
71 | OptionParser conf(""); | |
72 | conf.add<Value<bool>>("", "logging.debug", "enable debug logging", settings.logging.debug, &settings.logging.debug); | |
73 | conf.add<Value<string>>("", "logging.debug_logfile", "log file name for the debug logs (debug must be enabled)", settings.logging.debug_logfile, | |
74 | &settings.logging.debug_logfile); | |
68 | OptionParser conf("Overridable config file options"); | |
69 | ||
70 | // server settings | |
71 | conf.add<Value<int>>("", "server.threads", "number of server threads", settings.server.threads, &settings.server.threads); | |
72 | conf.add<Value<string>>("", "server.pidfile", "pid file when running as daemon", settings.server.pid_file, &settings.server.pid_file); | |
73 | conf.add<Value<string>>("", "server.user", "the user to run as when daemonized", settings.server.user, &settings.server.user); | |
74 | conf.add<Implicit<string>>("", "server.group", "the group to run as when daemonized", settings.server.group, &settings.server.group); | |
75 | conf.add<Implicit<string>>("", "server.datadir", "directory where persistent data is stored", settings.server.data_dir, &settings.server.data_dir); | |
76 | ||
77 | // HTTP RPC settings | |
78 | conf.add<Value<bool>>("", "http.enabled", "enable HTTP Json RPC (HTTP POST and websockets)", settings.http.enabled, &settings.http.enabled); | |
79 | conf.add<Value<size_t>>("", "http.port", "which port the server should listen on", settings.http.port, &settings.http.port); | |
80 | auto http_bind_to_address = conf.add<Value<string>>("", "http.bind_to_address", "address for the server to listen on", | |
81 | settings.http.bind_to_address.front(), &settings.http.bind_to_address[0]); | |
82 | conf.add<Implicit<string>>("", "http.doc_root", "serve a website from the doc_root location", settings.http.doc_root, &settings.http.doc_root); | |
83 | ||
84 | // TCP RPC settings | |
85 | conf.add<Value<bool>>("", "tcp.enabled", "enable TCP Json RPC)", settings.tcp.enabled, &settings.tcp.enabled); | |
86 | conf.add<Value<size_t>>("", "tcp.port", "which port the server should listen on", settings.tcp.port, &settings.tcp.port); | |
87 | auto tcp_bind_to_address = conf.add<Value<string>>("", "tcp.bind_to_address", "address for the server to listen on", | |
88 | settings.tcp.bind_to_address.front(), &settings.tcp.bind_to_address[0]); | |
75 | 89 | |
76 | 90 | // stream settings |
77 | conf.add<Value<size_t>>("", "stream.port", "Server port", settings.stream.port, &settings.stream.port); | |
78 | auto streamValue = conf.add<Value<string>>( | |
79 | "", "stream.stream", "URI of the PCM input stream.\nFormat: TYPE://host/path?name=NAME\n[&codec=CODEC]\n[&sampleformat=SAMPLEFORMAT]", pcmStream, | |
80 | &pcmStream); | |
81 | int num_threads = -1; | |
82 | conf.add<Value<int>>("", "server.threads", "number of server threads", num_threads, &num_threads); | |
83 | std::string pid_file = "/var/run/snapserver/pid"; | |
84 | conf.add<Value<string>>("", "server.pidfile", "pid file when running as daemon", pid_file, &pid_file); | |
85 | std::string data_dir; | |
86 | conf.add<Implicit<string>>("", "server.datadir", "directory where persistent data is stored", data_dir, &data_dir); | |
91 | auto stream_bind_to_address = conf.add<Value<string>>("", "stream.bind_to_address", "address for the server to listen on", | |
92 | settings.stream.bind_to_address.front(), &settings.stream.bind_to_address[0]); | |
93 | conf.add<Value<size_t>>("", "stream.port", "which port the server should listen on", settings.stream.port, &settings.stream.port); | |
94 | // deprecated: stream.stream, use stream.source instead | |
95 | auto streamValue = conf.add<Value<string>>("", "stream.stream", "Deprecated: use stream.source", pcmSource, &pcmSource); | |
96 | auto sourceValue = conf.add<Value<string>>( | |
97 | "", "stream.source", "URI of the PCM input stream.\nFormat: TYPE://host/path?name=NAME\n[&codec=CODEC]\n[&sampleformat=SAMPLEFORMAT]", pcmSource, | |
98 | &pcmSource); | |
87 | 99 | |
88 | 100 | conf.add<Value<string>>("", "stream.sampleformat", "Default sample format", settings.stream.sampleFormat, &settings.stream.sampleFormat); |
89 | 101 | conf.add<Value<string>>("", "stream.codec", "Default transport codec\n(flac|ogg|opus|pcm)[:options]\nType codec:? to get codec specific options", |
90 | 102 | settings.stream.codec, &settings.stream.codec); |
91 | 103 | // deprecated: stream_buffer, use chunk_ms instead |
92 | conf.add<Value<size_t>>("", "stream.stream_buffer", "Default stream read chunk size [ms]", settings.stream.streamChunkMs, | |
93 | &settings.stream.streamChunkMs); | |
104 | conf.add<Value<size_t>>("", "stream.stream_buffer", "Default stream read chunk size [ms], deprecated, use stream.chunk_ms instead", | |
105 | settings.stream.streamChunkMs, &settings.stream.streamChunkMs); | |
94 | 106 | conf.add<Value<size_t>>("", "stream.chunk_ms", "Default stream read chunk size [ms]", settings.stream.streamChunkMs, &settings.stream.streamChunkMs); |
95 | 107 | conf.add<Value<int>>("", "stream.buffer", "Buffer [ms]", settings.stream.bufferMs, &settings.stream.bufferMs); |
96 | 108 | conf.add<Value<bool>>("", "stream.send_to_muted", "Send audio to muted clients", settings.stream.sendAudioToMutedClients, |
97 | 109 | &settings.stream.sendAudioToMutedClients); |
98 | auto stream_bind_to_address = conf.add<Value<string>>("", "stream.bind_to_address", "address for the server to listen on", | |
99 | settings.stream.bind_to_address.front(), &settings.stream.bind_to_address[0]); | |
100 | ||
101 | // HTTP RPC settings | |
102 | conf.add<Value<bool>>("", "http.enabled", "enable HTTP Json RPC (HTTP POST and websockets)", settings.http.enabled, &settings.http.enabled); | |
103 | conf.add<Value<size_t>>("", "http.port", "which port the server should listen to", settings.http.port, &settings.http.port); | |
104 | auto http_bind_to_address = conf.add<Value<string>>("", "http.bind_to_address", "address for the server to listen on", | |
105 | settings.http.bind_to_address.front(), &settings.http.bind_to_address[0]); | |
106 | conf.add<Implicit<string>>("", "http.doc_root", "serve a website from the doc_root location", settings.http.doc_root, &settings.http.doc_root); | |
107 | ||
108 | // TCP RPC settings | |
109 | conf.add<Value<bool>>("", "tcp.enabled", "enable TCP Json RPC)", settings.tcp.enabled, &settings.tcp.enabled); | |
110 | conf.add<Value<size_t>>("", "tcp.port", "which port the server should listen to", settings.tcp.port, &settings.tcp.port); | |
111 | auto tcp_bind_to_address = conf.add<Value<string>>("", "tcp.bind_to_address", "address for the server to listen on", | |
112 | settings.tcp.bind_to_address.front(), &settings.tcp.bind_to_address[0]); | |
110 | ||
111 | // logging settings | |
112 | conf.add<Value<string>>("", "logging.sink", "log sink [null,system,stdout,stderr,file:<filename>]", settings.logging.sink, &settings.logging.sink); | |
113 | auto logfilterOption = conf.add<Value<string>>( | |
114 | "", "logging.filter", | |
115 | "log filter <tag>:<level>[,<tag>:<level>]* with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]", | |
116 | settings.logging.filter); | |
113 | 117 | |
114 | 118 | try |
115 | 119 | { |
137 | 141 | } |
138 | 142 | catch (const std::invalid_argument& e) |
139 | 143 | { |
140 | SLOG(ERROR) << "Exception: " << e.what() << std::endl; | |
144 | cerr << "Exception: " << e.what() << std::endl; | |
141 | 145 | cout << "\n" << op << "\n"; |
142 | 146 | exit(EXIT_FAILURE); |
143 | 147 | } |
181 | 185 | exit(EXIT_SUCCESS); |
182 | 186 | } |
183 | 187 | |
184 | AixLog::Log::init<AixLog::SinkNative>("snapserver", AixLog::Severity::trace, AixLog::Type::special); | |
185 | if (settings.logging.debug) | |
186 | { | |
187 | AixLog::Log::instance().add_logsink<AixLog::SinkCout>(AixLog::Severity::trace, AixLog::Type::all, "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"); | |
188 | if (!settings.logging.debug_logfile.empty()) | |
189 | AixLog::Log::instance().add_logsink<AixLog::SinkFile>(AixLog::Severity::trace, AixLog::Type::all, settings.logging.debug_logfile, | |
190 | "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"); | |
191 | } | |
188 | settings.logging.filter = logfilterOption->value(); | |
189 | if (logfilterOption->is_set()) | |
190 | { | |
191 | for (size_t n = 1; n < logfilterOption->count(); ++n) | |
192 | settings.logging.filter += "," + logfilterOption->value(n); | |
193 | } | |
194 | ||
195 | if (settings.logging.sink.empty()) | |
196 | { | |
197 | settings.logging.sink = "stdout"; | |
198 | #ifdef HAS_DAEMON | |
199 | if (daemonOption->is_set()) | |
200 | settings.logging.sink = "system"; | |
201 | #endif | |
202 | } | |
203 | AixLog::Filter logfilter; | |
204 | auto filters = utils::string::split(settings.logging.filter, ','); | |
205 | for (const auto& filter : filters) | |
206 | logfilter.add_filter(filter); | |
207 | ||
208 | string logformat = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"; | |
209 | if (settings.logging.sink.find("file:") != string::npos) | |
210 | { | |
211 | string logfile = settings.logging.sink.substr(settings.logging.sink.find(":") + 1); | |
212 | AixLog::Log::init<AixLog::SinkFile>(logfilter, logfile, logformat); | |
213 | } | |
214 | else if (settings.logging.sink == "stdout") | |
215 | AixLog::Log::init<AixLog::SinkCout>(logfilter, logformat); | |
216 | else if (settings.logging.sink == "stderr") | |
217 | AixLog::Log::init<AixLog::SinkCerr>(logfilter, logformat); | |
218 | else if (settings.logging.sink == "system") | |
219 | AixLog::Log::init<AixLog::SinkNative>("snapserver", logfilter); | |
220 | else if (settings.logging.sink == "null") | |
221 | AixLog::Log::init<AixLog::SinkNull>(); | |
192 | 222 | else |
193 | { | |
194 | AixLog::Log::instance().add_logsink<AixLog::SinkCout>(AixLog::Severity::info, AixLog::Type::all, "%Y-%m-%d %H-%M-%S [#severity] (#tag_func)"); | |
195 | } | |
196 | ||
197 | for (const auto& opt : conf.unknown_options()) | |
198 | LOG(WARNING) << "unknown configuration option: " << opt << "\n"; | |
199 | ||
200 | if (!streamValue->is_set()) | |
201 | settings.stream.pcmStreams.push_back(streamValue->value()); | |
223 | throw SnapException("Invalid log sink: " + settings.logging.sink); | |
224 | ||
225 | if (!streamValue->is_set() && !sourceValue->is_set()) | |
226 | settings.stream.sources.push_back(sourceValue->value()); | |
202 | 227 | |
203 | 228 | for (size_t n = 0; n < streamValue->count(); ++n) |
204 | 229 | { |
205 | 230 | LOG(INFO) << "Adding stream: " << streamValue->value(n) << "\n"; |
206 | settings.stream.pcmStreams.push_back(streamValue->value(n)); | |
231 | settings.stream.sources.push_back(streamValue->value(n)); | |
232 | } | |
233 | for (size_t n = 0; n < sourceValue->count(); ++n) | |
234 | { | |
235 | LOG(INFO) << "Adding source: " << sourceValue->value(n) << "\n"; | |
236 | settings.stream.sources.push_back(sourceValue->value(n)); | |
207 | 237 | } |
208 | 238 | |
209 | 239 | #ifdef HAS_DAEMON |
210 | 240 | std::unique_ptr<Daemon> daemon; |
211 | 241 | if (daemonOption->is_set()) |
212 | 242 | { |
213 | string user = ""; | |
214 | string group = ""; | |
215 | ||
216 | if (userValue->is_set()) | |
217 | { | |
218 | if (userValue->value().empty()) | |
219 | std::invalid_argument("user must not be empty"); | |
220 | ||
221 | vector<string> user_group = utils::string::split(userValue->value(), ':'); | |
222 | user = user_group[0]; | |
223 | if (user_group.size() > 1) | |
224 | group = user_group[1]; | |
225 | } | |
226 | if (data_dir.empty()) | |
227 | data_dir = "/var/lib/snapserver"; | |
228 | Config::instance().init(data_dir, user, group); | |
229 | daemon.reset(new Daemon(user, group, pid_file)); | |
230 | SLOG(NOTICE) << "daemonizing" << std::endl; | |
231 | daemon->daemonize(); | |
232 | if (processPriority < -20) | |
233 | processPriority = -20; | |
234 | else if (processPriority > 19) | |
235 | processPriority = 19; | |
243 | if (settings.server.user.empty()) | |
244 | std::invalid_argument("user must not be empty"); | |
245 | ||
246 | if (settings.server.data_dir.empty()) | |
247 | settings.server.data_dir = "/var/lib/snapserver"; | |
248 | Config::instance().init(settings.server.data_dir, settings.server.user, settings.server.group); | |
249 | ||
250 | daemon = std::make_unique<Daemon>(settings.server.user, settings.server.group, settings.server.pid_file); | |
251 | processPriority = std::min(std::max(-20, processPriority), 19); | |
236 | 252 | if (processPriority != 0) |
237 | 253 | setpriority(PRIO_PROCESS, 0, processPriority); |
238 | SLOG(NOTICE) << "daemon started" << std::endl; | |
254 | LOG(NOTICE) << "daemonizing" << std::endl; | |
255 | daemon->daemonize(); | |
256 | LOG(NOTICE) << "daemon started" << std::endl; | |
239 | 257 | } |
240 | 258 | else |
241 | Config::instance().init(data_dir); | |
259 | Config::instance().init(settings.server.data_dir); | |
242 | 260 | #else |
243 | 261 | Config::instance().init(); |
244 | 262 | #endif |
272 | 290 | settings.stream.bufferMs = 400; |
273 | 291 | } |
274 | 292 | |
275 | auto streamServer = std::make_unique<StreamServer>(io_context, settings); | |
276 | streamServer->start(); | |
277 | ||
278 | if (num_threads < 0) | |
279 | num_threads = std::max(2, std::min(4, static_cast<int>(std::thread::hardware_concurrency()))); | |
280 | LOG(INFO) << "number of threads: " << num_threads << ", hw threads: " << std::thread::hardware_concurrency() << "\n"; | |
293 | auto server = std::make_unique<Server>(io_context, settings); | |
294 | server->start(); | |
295 | ||
296 | if (settings.server.threads < 0) | |
297 | settings.server.threads = std::max(2, std::min(4, static_cast<int>(std::thread::hardware_concurrency()))); | |
298 | LOG(INFO) << "number of threads: " << settings.server.threads << ", hw threads: " << std::thread::hardware_concurrency() << "\n"; | |
281 | 299 | |
282 | 300 | // Construct a signal set registered for process termination. |
283 | 301 | boost::asio::signal_set signals(io_context, SIGHUP, SIGINT, SIGTERM); |
284 | 302 | signals.async_wait([&io_context](const boost::system::error_code& ec, int signal) { |
285 | 303 | if (!ec) |
286 | SLOG(INFO) << "Received signal " << signal << ": " << strsignal(signal) << "\n"; | |
304 | LOG(INFO) << "Received signal " << signal << ": " << strsignal(signal) << "\n"; | |
287 | 305 | else |
288 | SLOG(INFO) << "Failed to wait for signal: " << ec << "\n"; | |
306 | LOG(INFO) << "Failed to wait for signal, error: " << ec.message() << "\n"; | |
289 | 307 | io_context.stop(); |
290 | 308 | }); |
291 | 309 | |
292 | 310 | std::vector<std::thread> threads; |
293 | for (int n = 0; n < num_threads; ++n) | |
311 | for (int n = 0; n < settings.server.threads; ++n) | |
294 | 312 | threads.emplace_back([&] { io_context.run(); }); |
295 | 313 | |
296 | 314 | io_context.run(); |
299 | 317 | t.join(); |
300 | 318 | |
301 | 319 | LOG(INFO) << "Stopping streamServer" << endl; |
302 | streamServer->stop(); | |
320 | server->stop(); | |
303 | 321 | LOG(INFO) << "done" << endl; |
304 | 322 | } |
305 | 323 | catch (const std::exception& e) |
306 | 324 | { |
307 | SLOG(ERROR) << "Exception: " << e.what() << std::endl; | |
325 | LOG(ERROR) << "Exception: " << e.what() << std::endl; | |
308 | 326 | exitcode = EXIT_FAILURE; |
309 | 327 | } |
310 | 328 | Config::instance().save(); |
311 | SLOG(NOTICE) << "daemon terminated." << endl; | |
329 | LOG(NOTICE) << "daemon terminated." << endl; | |
312 | 330 | exit(exitcode); |
313 | 331 | } |
18 | 18 | #include "stream_server.hpp" |
19 | 19 | #include "common/aixlog.hpp" |
20 | 20 | #include "config.hpp" |
21 | #include "message/client_info.hpp" | |
21 | 22 | #include "message/hello.hpp" |
22 | 23 | #include "message/stream_tags.hpp" |
23 | 24 | #include "message/time.hpp" |
25 | #include "stream_session_tcp.hpp" | |
24 | 26 | #include <iostream> |
25 | 27 | |
26 | 28 | using namespace std; |
28 | 30 | |
29 | 31 | using json = nlohmann::json; |
30 | 32 | |
31 | ||
32 | StreamServer::StreamServer(boost::asio::io_context& io_context, const ServerSettings& serverSettings) | |
33 | : io_context_(io_context), config_timer_(io_context), settings_(serverSettings) | |
33 | static constexpr auto LOG_TAG = "StreamServer"; | |
34 | ||
35 | StreamServer::StreamServer(boost::asio::io_context& io_context, const ServerSettings& serverSettings, StreamMessageReceiver* messageReceiver) | |
36 | : io_context_(io_context), config_timer_(io_context), settings_(serverSettings), messageReceiver_(messageReceiver) | |
34 | 37 | { |
35 | 38 | } |
36 | 39 | |
44 | 47 | auto count = distance(new_end, sessions_.end()); |
45 | 48 | if (count > 0) |
46 | 49 | { |
47 | SLOG(ERROR) << "Removing " << count << " inactive session(s), active sessions: " << sessions_.size() - count << "\n"; | |
50 | LOG(ERROR, LOG_TAG) << "Removing " << count << " inactive session(s), active sessions: " << sessions_.size() - count << "\n"; | |
48 | 51 | sessions_.erase(new_end, sessions_.end()); |
49 | 52 | } |
50 | 53 | } |
51 | 54 | |
52 | 55 | |
53 | void StreamServer::onMetaChanged(const PcmStream* pcmStream) | |
56 | void StreamServer::addSession(const std::shared_ptr<StreamSession>& session) | |
57 | { | |
58 | session->setMessageReceiver(this); | |
59 | session->setBufferMs(settings_.stream.bufferMs); | |
60 | session->start(); | |
61 | ||
62 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); | |
63 | sessions_.emplace_back(session); | |
64 | cleanup(); | |
65 | } | |
66 | ||
67 | ||
68 | void StreamServer::onMetaChanged(const PcmStream* pcmStream, std::shared_ptr<msg::StreamTags> meta) | |
54 | 69 | { |
55 | 70 | // clang-format off |
56 | 71 | // Notification: {"jsonrpc":"2.0","method":"Stream.OnMetadata","params":{"id":"stream 1", "meta": {"album": "some album", "artist": "some artist", "track": "some track"...}} |
57 | 72 | // clang-format on |
58 | 73 | |
59 | 74 | // Send meta to all connected clients |
60 | const auto meta = pcmStream->getMeta(); | |
61 | LOG(DEBUG) << "metadata = " << meta->msg.dump(3) << "\n"; | |
62 | 75 | |
63 | 76 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); |
64 | 77 | for (auto s : sessions_) |
66 | 79 | if (auto session = s.lock()) |
67 | 80 | { |
68 | 81 | if (session->pcmStream().get() == pcmStream) |
69 | session->sendAsync(meta); | |
70 | } | |
71 | } | |
72 | ||
73 | LOG(INFO) << "onMetaChanged (" << pcmStream->getName() << ")\n"; | |
74 | json notification = jsonrpcpp::Notification("Stream.OnMetadata", jsonrpcpp::Parameter("id", pcmStream->getId(), "meta", meta->msg)).to_json(); | |
75 | controlServer_->send(notification.dump(), nullptr); | |
76 | // cout << "Notification: " << notification.dump() << "\n"; | |
77 | } | |
78 | ||
79 | void StreamServer::onStateChanged(const PcmStream* pcmStream, const ReaderState& state) | |
80 | { | |
81 | // clang-format off | |
82 | // Notification: {"jsonrpc":"2.0","method":"Stream.OnUpdate","params":{"id":"stream 1","stream":{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}}}} | |
83 | // clang-format on | |
84 | LOG(INFO) << "onStateChanged (" << pcmStream->getName() << "): " << static_cast<int>(state) << "\n"; | |
85 | // LOG(INFO) << pcmStream->toJson().dump(4); | |
86 | json notification = jsonrpcpp::Notification("Stream.OnUpdate", jsonrpcpp::Parameter("id", pcmStream->getId(), "stream", pcmStream->toJson())).to_json(); | |
87 | controlServer_->send(notification.dump(), nullptr); | |
88 | // cout << "Notification: " << notification.dump() << "\n"; | |
89 | } | |
90 | ||
91 | ||
92 | void StreamServer::onChunkRead(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double /*duration*/) | |
93 | { | |
94 | // LOG(INFO) << "onChunkRead (" << pcmStream->getName() << "): " << duration << "ms\n"; | |
95 | bool isDefaultStream(pcmStream == streamManager_->getDefaultStream().get()); | |
96 | ||
82 | session->send(meta); | |
83 | } | |
84 | } | |
85 | } | |
86 | ||
87 | ||
88 | void StreamServer::onChunkEncoded(const PcmStream* pcmStream, bool isDefaultStream, std::shared_ptr<msg::PcmChunk> chunk, double /*duration*/) | |
89 | { | |
90 | // LOG(TRACE, LOG_TAG) << "onChunkRead (" << pcmStream->getName() << "): " << duration << "ms\n"; | |
97 | 91 | shared_const_buffer buffer(*chunk); |
98 | 92 | |
93 | // make a copy of the sessions to avoid that a session get's deleted | |
99 | 94 | std::vector<std::shared_ptr<StreamSession>> sessions; |
100 | 95 | { |
101 | 96 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); |
126 | 121 | } |
127 | 122 | |
128 | 123 | if (!session->pcmStream() && isDefaultStream) //->getName() == "default") |
129 | session->sendAsync(buffer); | |
124 | session->send(buffer); | |
130 | 125 | else if (session->pcmStream().get() == pcmStream) |
131 | session->sendAsync(buffer); | |
132 | } | |
133 | } | |
134 | ||
135 | ||
136 | void StreamServer::onResync(const PcmStream* pcmStream, double ms) | |
137 | { | |
138 | LOG(INFO) << "onResync (" << pcmStream->getName() << "): " << ms << " ms\n"; | |
126 | session->send(buffer); | |
127 | } | |
128 | } | |
129 | ||
130 | ||
131 | void StreamServer::onMessageReceived(StreamSession* streamSession, const msg::BaseMessage& baseMessage, char* buffer) | |
132 | { | |
133 | if (messageReceiver_) | |
134 | messageReceiver_->onMessageReceived(streamSession, baseMessage, buffer); | |
139 | 135 | } |
140 | 136 | |
141 | 137 | |
147 | 143 | if (session == nullptr) |
148 | 144 | return; |
149 | 145 | |
150 | LOG(INFO) << "onDisconnect: " << session->clientId << "\n"; | |
151 | LOG(DEBUG) << "sessions: " << sessions_.size() << "\n"; | |
146 | LOG(INFO, LOG_TAG) << "onDisconnect: " << session->clientId << "\n"; | |
147 | LOG(DEBUG, LOG_TAG) << "sessions: " << sessions_.size() << "\n"; | |
152 | 148 | sessions_.erase(std::remove_if(sessions_.begin(), sessions_.end(), |
153 | 149 | [streamSession](std::weak_ptr<StreamSession> session) { |
154 | 150 | auto s = session.lock(); |
155 | 151 | return s.get() == streamSession; |
156 | 152 | }), |
157 | 153 | sessions_.end()); |
158 | LOG(DEBUG) << "sessions: " << sessions_.size() << "\n"; | |
159 | ||
160 | // notify controllers if not yet done | |
161 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(session->clientId); | |
162 | if (!clientInfo || !clientInfo->connected) | |
163 | return; | |
164 | ||
165 | clientInfo->connected = false; | |
166 | chronos::systemtimeofday(&clientInfo->lastSeen); | |
167 | saveConfig(); | |
168 | if (controlServer_ != nullptr) | |
169 | { | |
170 | // Check if there is no session of this client is left | |
171 | // Can happen in case of ungraceful disconnect/reconnect or | |
172 | // in case of a duplicate client id | |
173 | if (getStreamSession(clientInfo->id) == nullptr) | |
174 | { | |
175 | // clang-format off | |
176 | // Notification: | |
177 | // {"jsonrpc":"2.0","method":"Client.OnDisconnect","params":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":false,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025523,"usec":814067},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},"id":"00:21:6a:7d:74:fc"}} | |
178 | // clang-format on | |
179 | json notification = | |
180 | jsonrpcpp::Notification("Client.OnDisconnect", jsonrpcpp::Parameter("id", clientInfo->id, "client", clientInfo->toJson())).to_json(); | |
181 | controlServer_->send(notification.dump()); | |
182 | // cout << "Notification: " << notification.dump() << "\n"; | |
183 | } | |
184 | } | |
154 | LOG(DEBUG, LOG_TAG) << "sessions: " << sessions_.size() << "\n"; | |
155 | if (messageReceiver_) | |
156 | messageReceiver_->onDisconnect(streamSession); | |
185 | 157 | cleanup(); |
186 | } | |
187 | ||
188 | ||
189 | void StreamServer::ProcessRequest(const jsonrpcpp::request_ptr request, jsonrpcpp::entity_ptr& response, jsonrpcpp::notification_ptr& notification) const | |
190 | { | |
191 | try | |
192 | { | |
193 | // LOG(INFO) << "StreamServer::ProcessRequest method: " << request->method << ", " << "id: " << request->id() << "\n"; | |
194 | Json result; | |
195 | ||
196 | if (request->method().find("Client.") == 0) | |
197 | { | |
198 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(request->params().get<std::string>("id")); | |
199 | if (clientInfo == nullptr) | |
200 | throw jsonrpcpp::InternalErrorException("Client not found", request->id()); | |
201 | ||
202 | if (request->method() == "Client.GetStatus") | |
203 | { | |
204 | // clang-format off | |
205 | // Request: {"id":8,"jsonrpc":"2.0","method":"Client.GetStatus","params":{"id":"00:21:6a:7d:74:fc"}} | |
206 | // Response: {"id":8,"jsonrpc":"2.0","result":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":74}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488026416,"usec":135973},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}}} | |
207 | // clang-format on | |
208 | result["client"] = clientInfo->toJson(); | |
209 | } | |
210 | else if (request->method() == "Client.SetVolume") | |
211 | { | |
212 | // clang-format off | |
213 | // Request: {"id":8,"jsonrpc":"2.0","method":"Client.SetVolume","params":{"id":"00:21:6a:7d:74:fc","volume":{"muted":false,"percent":74}}} | |
214 | // Response: {"id":8,"jsonrpc":"2.0","result":{"volume":{"muted":false,"percent":74}}} | |
215 | // Notification: {"jsonrpc":"2.0","method":"Client.OnVolumeChanged","params":{"id":"00:21:6a:7d:74:fc","volume":{"muted":false,"percent":74}}} | |
216 | // clang-format on | |
217 | ||
218 | std::lock_guard<std::recursive_mutex> lock(clientMutex_); | |
219 | clientInfo->config.volume.fromJson(request->params().get("volume")); | |
220 | result["volume"] = clientInfo->config.volume.toJson(); | |
221 | notification.reset(new jsonrpcpp::Notification("Client.OnVolumeChanged", | |
222 | jsonrpcpp::Parameter("id", clientInfo->id, "volume", clientInfo->config.volume.toJson()))); | |
223 | } | |
224 | else if (request->method() == "Client.SetLatency") | |
225 | { | |
226 | // clang-format off | |
227 | // Request: {"id":7,"jsonrpc":"2.0","method":"Client.SetLatency","params":{"id":"00:21:6a:7d:74:fc#2","latency":10}} | |
228 | // Response: {"id":7,"jsonrpc":"2.0","result":{"latency":10}} | |
229 | // Notification: {"jsonrpc":"2.0","method":"Client.OnLatencyChanged","params":{"id":"00:21:6a:7d:74:fc#2","latency":10}} | |
230 | // clang-format on | |
231 | int latency = request->params().get("latency"); | |
232 | if (latency < -10000) | |
233 | latency = -10000; | |
234 | else if (latency > settings_.stream.bufferMs) | |
235 | latency = settings_.stream.bufferMs; | |
236 | clientInfo->config.latency = latency; //, -10000, settings_.stream.bufferMs); | |
237 | result["latency"] = clientInfo->config.latency; | |
238 | notification.reset( | |
239 | new jsonrpcpp::Notification("Client.OnLatencyChanged", jsonrpcpp::Parameter("id", clientInfo->id, "latency", clientInfo->config.latency))); | |
240 | } | |
241 | else if (request->method() == "Client.SetName") | |
242 | { | |
243 | // clang-format off | |
244 | // Request: {"id":6,"jsonrpc":"2.0","method":"Client.SetName","params":{"id":"00:21:6a:7d:74:fc#2","name":"Laptop"}} | |
245 | // Response: {"id":6,"jsonrpc":"2.0","result":{"name":"Laptop"}} | |
246 | // Notification: {"jsonrpc":"2.0","method":"Client.OnNameChanged","params":{"id":"00:21:6a:7d:74:fc#2","name":"Laptop"}} | |
247 | // clang-format on | |
248 | clientInfo->config.name = request->params().get<std::string>("name"); | |
249 | result["name"] = clientInfo->config.name; | |
250 | notification.reset( | |
251 | new jsonrpcpp::Notification("Client.OnNameChanged", jsonrpcpp::Parameter("id", clientInfo->id, "name", clientInfo->config.name))); | |
252 | } | |
253 | else | |
254 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
255 | ||
256 | ||
257 | if (request->method().find("Client.Set") == 0) | |
258 | { | |
259 | /// Update client | |
260 | session_ptr session = getStreamSession(clientInfo->id); | |
261 | if (session != nullptr) | |
262 | { | |
263 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
264 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
265 | serverSettings->setVolume(clientInfo->config.volume.percent); | |
266 | GroupPtr group = Config::instance().getGroupFromClient(clientInfo); | |
267 | serverSettings->setMuted(clientInfo->config.volume.muted || group->muted); | |
268 | serverSettings->setLatency(clientInfo->config.latency); | |
269 | session->sendAsync(serverSettings); | |
270 | } | |
271 | } | |
272 | } | |
273 | else if (request->method().find("Group.") == 0) | |
274 | { | |
275 | GroupPtr group = Config::instance().getGroup(request->params().get<std::string>("id")); | |
276 | if (group == nullptr) | |
277 | throw jsonrpcpp::InternalErrorException("Group not found", request->id()); | |
278 | ||
279 | if (request->method() == "Group.GetStatus") | |
280 | { | |
281 | // clang-format off | |
282 | // Request: {"id":5,"jsonrpc":"2.0","method":"Group.GetStatus","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"}} | |
283 | // Response: {"id":5,"jsonrpc":"2.0","result":{"group":{"clients":[{"config":{"instance":2,"latency":10,"name":"Laptop","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488026485,"usec":644997},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":74}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488026481,"usec":223747},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":true,"name":"","stream_id":"stream 1"}}} | |
284 | // clang-format on | |
285 | result["group"] = group->toJson(); | |
286 | } | |
287 | else if (request->method() == "Group.SetName") | |
288 | { | |
289 | // clang-format off | |
290 | // Request: {"id":6,"jsonrpc":"2.0","method":"Group.SetName","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","name":"Laptop"}} | |
291 | // Response: {"id":6,"jsonrpc":"2.0","result":{"name":"MediaPlayer"}} | |
292 | // Notification: {"jsonrpc":"2.0","method":"Group.OnNameChanged","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","MediaPlayer":"Laptop"}} | |
293 | // clang-format on | |
294 | group->name = request->params().get<std::string>("name"); | |
295 | result["name"] = group->name; | |
296 | notification.reset(new jsonrpcpp::Notification("Group.OnNameChanged", jsonrpcpp::Parameter("id", group->id, "name", group->name))); | |
297 | } | |
298 | else if (request->method() == "Group.SetMute") | |
299 | { | |
300 | // clang-format off | |
301 | // Request: {"id":5,"jsonrpc":"2.0","method":"Group.SetMute","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","mute":true}} | |
302 | // Response: {"id":5,"jsonrpc":"2.0","result":{"mute":true}} | |
303 | // Notification: {"jsonrpc":"2.0","method":"Group.OnMute","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","mute":true}} | |
304 | // clang-format on | |
305 | bool muted = request->params().get<bool>("mute"); | |
306 | group->muted = muted; | |
307 | ||
308 | /// Update clients | |
309 | for (auto client : group->clients) | |
310 | { | |
311 | session_ptr session = getStreamSession(client->id); | |
312 | if (session != nullptr) | |
313 | { | |
314 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
315 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
316 | serverSettings->setVolume(client->config.volume.percent); | |
317 | GroupPtr group = Config::instance().getGroupFromClient(client); | |
318 | serverSettings->setMuted(client->config.volume.muted || group->muted); | |
319 | serverSettings->setLatency(client->config.latency); | |
320 | session->sendAsync(serverSettings); | |
321 | } | |
322 | } | |
323 | ||
324 | result["mute"] = group->muted; | |
325 | notification.reset(new jsonrpcpp::Notification("Group.OnMute", jsonrpcpp::Parameter("id", group->id, "mute", group->muted))); | |
326 | } | |
327 | else if (request->method() == "Group.SetStream") | |
328 | { | |
329 | // clang-format off | |
330 | // Request: {"id":4,"jsonrpc":"2.0","method":"Group.SetStream","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","stream_id":"stream 1"}} | |
331 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"stream 1"}} | |
332 | // Notification: {"jsonrpc":"2.0","method":"Group.OnStreamChanged","params":{"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","stream_id":"stream 1"}} | |
333 | // clang-format on | |
334 | string streamId = request->params().get<std::string>("stream_id"); | |
335 | PcmStreamPtr stream = streamManager_->getStream(streamId); | |
336 | if (stream == nullptr) | |
337 | throw jsonrpcpp::InternalErrorException("Stream not found", request->id()); | |
338 | ||
339 | group->streamId = streamId; | |
340 | ||
341 | // Update clients | |
342 | for (auto client : group->clients) | |
343 | { | |
344 | session_ptr session = getStreamSession(client->id); | |
345 | if (session && (session->pcmStream() != stream)) | |
346 | { | |
347 | session->sendAsync(stream->getMeta()); | |
348 | session->sendAsync(stream->getHeader()); | |
349 | session->setPcmStream(stream); | |
350 | } | |
351 | } | |
352 | ||
353 | // Notify others | |
354 | result["stream_id"] = group->streamId; | |
355 | notification.reset(new jsonrpcpp::Notification("Group.OnStreamChanged", jsonrpcpp::Parameter("id", group->id, "stream_id", group->streamId))); | |
356 | } | |
357 | else if (request->method() == "Group.SetClients") | |
358 | { | |
359 | // clang-format off | |
360 | // Request: {"id":3,"jsonrpc":"2.0","method":"Group.SetClients","params":{"clients":["00:21:6a:7d:74:fc#2","00:21:6a:7d:74:fc"],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1"}} | |
361 | // Response: {"id":3,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025901,"usec":864472},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025905,"usec":45238},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
362 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025901,"usec":864472},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025905,"usec":45238},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
363 | // clang-format on | |
364 | vector<string> clients = request->params().get("clients"); | |
365 | // Remove clients from group | |
366 | for (auto iter = group->clients.begin(); iter != group->clients.end();) | |
367 | { | |
368 | auto client = *iter; | |
369 | if (find(clients.begin(), clients.end(), client->id) != clients.end()) | |
370 | { | |
371 | ++iter; | |
372 | continue; | |
373 | } | |
374 | iter = group->clients.erase(iter); | |
375 | GroupPtr newGroup = Config::instance().addClientInfo(client); | |
376 | newGroup->streamId = group->streamId; | |
377 | } | |
378 | ||
379 | // Add clients to group | |
380 | PcmStreamPtr stream = streamManager_->getStream(group->streamId); | |
381 | for (const auto& clientId : clients) | |
382 | { | |
383 | ClientInfoPtr client = Config::instance().getClientInfo(clientId); | |
384 | if (!client) | |
385 | continue; | |
386 | GroupPtr oldGroup = Config::instance().getGroupFromClient(client); | |
387 | if (oldGroup && (oldGroup->id == group->id)) | |
388 | continue; | |
389 | ||
390 | if (oldGroup) | |
391 | { | |
392 | oldGroup->removeClient(client); | |
393 | Config::instance().remove(oldGroup); | |
394 | } | |
395 | ||
396 | group->addClient(client); | |
397 | ||
398 | // assign new stream | |
399 | session_ptr session = getStreamSession(client->id); | |
400 | if (session && stream && (session->pcmStream() != stream)) | |
401 | { | |
402 | session->sendAsync(stream->getMeta()); | |
403 | session->sendAsync(stream->getHeader()); | |
404 | session->setPcmStream(stream); | |
405 | } | |
406 | } | |
407 | ||
408 | if (group->empty()) | |
409 | Config::instance().remove(group); | |
410 | ||
411 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
412 | result["server"] = server; | |
413 | ||
414 | // Notify others: since at least two groups are affected, send a complete server update | |
415 | notification.reset(new jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server))); | |
416 | } | |
417 | else | |
418 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
419 | } | |
420 | else if (request->method().find("Server.") == 0) | |
421 | { | |
422 | if (request->method().find("Server.GetRPCVersion") == 0) | |
423 | { | |
424 | // Request: {"id":8,"jsonrpc":"2.0","method":"Server.GetRPCVersion"} | |
425 | // Response: {"id":8,"jsonrpc":"2.0","result":{"major":2,"minor":0,"patch":0}} | |
426 | // <major>: backwards incompatible change | |
427 | result["major"] = 2; | |
428 | // <minor>: feature addition to the API | |
429 | result["minor"] = 0; | |
430 | // <patch>: bugfix release | |
431 | result["patch"] = 0; | |
432 | } | |
433 | else if (request->method() == "Server.GetStatus") | |
434 | { | |
435 | // clang-format off | |
436 | // Request: {"id":1,"jsonrpc":"2.0","method":"Server.GetStatus"} | |
437 | // Response: {"id":1,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025696,"usec":578142},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":true,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025696,"usec":611255},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
438 | // clang-format on | |
439 | result["server"] = Config::instance().getServerStatus(streamManager_->toJson()); | |
440 | } | |
441 | else if (request->method() == "Server.DeleteClient") | |
442 | { | |
443 | // clang-format off | |
444 | // Request: {"id":2,"jsonrpc":"2.0","method":"Server.DeleteClient","params":{"id":"00:21:6a:7d:74:fc"}} | |
445 | // Response: {"id":2,"jsonrpc":"2.0","result":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025751,"usec":654777},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
446 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025751,"usec":654777},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
447 | // clang-format on | |
448 | ClientInfoPtr clientInfo = Config::instance().getClientInfo(request->params().get<std::string>("id")); | |
449 | if (clientInfo == nullptr) | |
450 | throw jsonrpcpp::InternalErrorException("Client not found", request->id()); | |
451 | ||
452 | Config::instance().remove(clientInfo); | |
453 | ||
454 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
455 | result["server"] = server; | |
456 | ||
457 | /// Notify others | |
458 | notification.reset(new jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server))); | |
459 | } | |
460 | else | |
461 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
462 | } | |
463 | else if (request->method().find("Stream.") == 0) | |
464 | { | |
465 | if (request->method().find("Stream.SetMeta") == 0) | |
466 | { | |
467 | /// Request: {"id":4,"jsonrpc":"2.0","method":"Stream.SetMeta","params":{"id":"Spotify", | |
468 | /// "meta": {"album": "some album", "artist": "some artist", "track": "some track"...}}} | |
469 | /// | |
470 | /// Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
471 | /// Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
472 | ||
473 | LOG(INFO) << "Stream.SetMeta(" << request->params().get<std::string>("id") << ")" << request->params().get("meta") << "\n"; | |
474 | ||
475 | // Find stream | |
476 | string streamId = request->params().get<std::string>("id"); | |
477 | PcmStreamPtr stream = streamManager_->getStream(streamId); | |
478 | if (stream == nullptr) | |
479 | throw jsonrpcpp::InternalErrorException("Stream not found", request->id()); | |
480 | ||
481 | // Set metadata from request | |
482 | stream->setMeta(request->params().get("meta")); | |
483 | ||
484 | // Setup response | |
485 | result["id"] = streamId; | |
486 | } | |
487 | else if (request->method() == "Stream.AddStream") | |
488 | { | |
489 | // clang-format off | |
490 | // Request: {"id":4,"jsonrpc":"2.0","method":"Stream.AddStream","params":{"streamUri":"uri"}} | |
491 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
492 | // Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
493 | // clang-format on | |
494 | ||
495 | LOG(INFO) << "Stream.AddStream(" << request->params().get("streamUri") << ")" | |
496 | << "\n"; | |
497 | ||
498 | // Find stream | |
499 | string streamUri = request->params().get("streamUri"); | |
500 | PcmStreamPtr stream = streamManager_->addStream(streamUri); | |
501 | if (stream == nullptr) | |
502 | throw jsonrpcpp::InternalErrorException("Stream not created", request->id()); | |
503 | stream->start(); // We start the stream, otherwise it would be silent | |
504 | // Setup response | |
505 | result["id"] = stream->getId(); | |
506 | } | |
507 | else if (request->method() == "Stream.RemoveStream") | |
508 | { | |
509 | // clang-format off | |
510 | // Request: {"id":4,"jsonrpc":"2.0","method":"Stream.RemoveStream","params":{"id":"Spotify"}} | |
511 | // Response: {"id":4,"jsonrpc":"2.0","result":{"stream_id":"Spotify"}} | |
512 | // Call onMetaChanged(const PcmStream* pcmStream) for updates and notifications | |
513 | // clang-format on | |
514 | ||
515 | LOG(INFO) << "Stream.RemoveStream(" << request->params().get("id") << ")" | |
516 | << "\n"; | |
517 | ||
518 | // Find stream | |
519 | string streamId = request->params().get("id"); | |
520 | streamManager_->removeStream(streamId); | |
521 | // Setup response | |
522 | result["id"] = streamId; | |
523 | } | |
524 | else | |
525 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
526 | } | |
527 | else | |
528 | throw jsonrpcpp::MethodNotFoundException(request->id()); | |
529 | ||
530 | response.reset(new jsonrpcpp::Response(*request, result)); | |
531 | } | |
532 | catch (const jsonrpcpp::RequestException& e) | |
533 | { | |
534 | LOG(ERROR) << "StreamServer::onMessageReceived JsonRequestException: " << e.to_json().dump() << ", message: " << request->to_json().dump() << "\n"; | |
535 | response.reset(new jsonrpcpp::RequestException(e)); | |
536 | } | |
537 | catch (const exception& e) | |
538 | { | |
539 | LOG(ERROR) << "StreamServer::onMessageReceived exception: " << e.what() << ", message: " << request->to_json().dump() << "\n"; | |
540 | response.reset(new jsonrpcpp::InternalErrorException(e.what(), request->id())); | |
541 | } | |
542 | } | |
543 | ||
544 | ||
545 | std::string StreamServer::onMessageReceived(ControlSession* controlSession, const std::string& message) | |
546 | { | |
547 | // LOG(DEBUG) << "onMessageReceived: " << message << "\n"; | |
548 | jsonrpcpp::entity_ptr entity(nullptr); | |
549 | try | |
550 | { | |
551 | entity = jsonrpcpp::Parser::do_parse(message); | |
552 | if (!entity) | |
553 | return ""; | |
554 | } | |
555 | catch (const jsonrpcpp::ParseErrorException& e) | |
556 | { | |
557 | return e.to_json().dump(); | |
558 | } | |
559 | catch (const std::exception& e) | |
560 | { | |
561 | return jsonrpcpp::ParseErrorException(e.what()).to_json().dump(); | |
562 | } | |
563 | ||
564 | jsonrpcpp::entity_ptr response(nullptr); | |
565 | jsonrpcpp::notification_ptr notification(nullptr); | |
566 | if (entity->is_request()) | |
567 | { | |
568 | jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(entity); | |
569 | ProcessRequest(request, response, notification); | |
570 | saveConfig(); | |
571 | ////cout << "Request: " << request->to_json().dump() << "\n"; | |
572 | if (notification) | |
573 | { | |
574 | ////cout << "Notification: " << notification->to_json().dump() << "\n"; | |
575 | controlServer_->send(notification->to_json().dump(), controlSession); | |
576 | } | |
577 | if (response) | |
578 | { | |
579 | ////cout << "Response: " << response->to_json().dump() << "\n"; | |
580 | return response->to_json().dump(); | |
581 | } | |
582 | return ""; | |
583 | } | |
584 | else if (entity->is_batch()) | |
585 | { | |
586 | jsonrpcpp::batch_ptr batch = dynamic_pointer_cast<jsonrpcpp::Batch>(entity); | |
587 | ////cout << "Batch: " << batch->to_json().dump() << "\n"; | |
588 | jsonrpcpp::Batch responseBatch; | |
589 | jsonrpcpp::Batch notificationBatch; | |
590 | for (const auto& batch_entity : batch->entities) | |
591 | { | |
592 | if (batch_entity->is_request()) | |
593 | { | |
594 | jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(batch_entity); | |
595 | ProcessRequest(request, response, notification); | |
596 | if (response != nullptr) | |
597 | responseBatch.add_ptr(response); | |
598 | if (notification != nullptr) | |
599 | notificationBatch.add_ptr(notification); | |
600 | } | |
601 | } | |
602 | saveConfig(); | |
603 | if (!notificationBatch.entities.empty()) | |
604 | controlServer_->send(notificationBatch.to_json().dump(), controlSession); | |
605 | if (!responseBatch.entities.empty()) | |
606 | return responseBatch.to_json().dump(); | |
607 | return ""; | |
608 | } | |
609 | return ""; | |
610 | } | |
611 | ||
612 | ||
613 | ||
614 | void StreamServer::onMessageReceived(StreamSession* streamSession, const msg::BaseMessage& baseMessage, char* buffer) | |
615 | { | |
616 | // LOG(DEBUG) << "onMessageReceived: " << baseMessage.type << ", size: " << baseMessage.size << ", id: " << baseMessage.id << ", refers: " << | |
617 | // baseMessage.refersTo << ", sent: " << baseMessage.sent.sec << "," << baseMessage.sent.usec << ", recv: " << baseMessage.received.sec << "," << | |
618 | // baseMessage.received.usec << "\n"; | |
619 | if (baseMessage.type == message_type::kTime) | |
620 | { | |
621 | auto timeMsg = make_shared<msg::Time>(); | |
622 | timeMsg->deserialize(baseMessage, buffer); | |
623 | timeMsg->refersTo = timeMsg->id; | |
624 | timeMsg->latency = timeMsg->received - timeMsg->sent; | |
625 | // LOG(INFO) << "Latency sec: " << timeMsg.latency.sec << ", usec: " << timeMsg.latency.usec << ", refers to: " << timeMsg.refersTo << "\n"; | |
626 | streamSession->sendAsync(timeMsg); | |
627 | ||
628 | // refresh streamSession state | |
629 | ClientInfoPtr client = Config::instance().getClientInfo(streamSession->clientId); | |
630 | if (client != nullptr) | |
631 | { | |
632 | chronos::systemtimeofday(&client->lastSeen); | |
633 | client->connected = true; | |
634 | } | |
635 | } | |
636 | else if (baseMessage.type == message_type::kHello) | |
637 | { | |
638 | msg::Hello helloMsg; | |
639 | helloMsg.deserialize(baseMessage, buffer); | |
640 | streamSession->clientId = helloMsg.getUniqueId(); | |
641 | LOG(INFO) << "Hello from " << streamSession->clientId << ", host: " << helloMsg.getHostName() << ", v" << helloMsg.getVersion() | |
642 | << ", ClientName: " << helloMsg.getClientName() << ", OS: " << helloMsg.getOS() << ", Arch: " << helloMsg.getArch() | |
643 | << ", Protocol version: " << helloMsg.getProtocolVersion() << "\n"; | |
644 | ||
645 | LOG(DEBUG) << "request kServerSettings: " << streamSession->clientId << "\n"; | |
646 | // std::lock_guard<std::mutex> mlock(mutex_); | |
647 | bool newGroup(false); | |
648 | GroupPtr group = Config::instance().getGroupFromClient(streamSession->clientId); | |
649 | if (group == nullptr) | |
650 | { | |
651 | group = Config::instance().addClientInfo(streamSession->clientId); | |
652 | newGroup = true; | |
653 | } | |
654 | ||
655 | ClientInfoPtr client = group->getClient(streamSession->clientId); | |
656 | ||
657 | LOG(DEBUG) << "request kServerSettings\n"; | |
658 | auto serverSettings = make_shared<msg::ServerSettings>(); | |
659 | serverSettings->setVolume(client->config.volume.percent); | |
660 | serverSettings->setMuted(client->config.volume.muted || group->muted); | |
661 | serverSettings->setLatency(client->config.latency); | |
662 | serverSettings->setBufferMs(settings_.stream.bufferMs); | |
663 | serverSettings->refersTo = helloMsg.id; | |
664 | streamSession->sendAsync(serverSettings); | |
665 | ||
666 | client->host.mac = helloMsg.getMacAddress(); | |
667 | client->host.ip = streamSession->getIP(); | |
668 | client->host.name = helloMsg.getHostName(); | |
669 | client->host.os = helloMsg.getOS(); | |
670 | client->host.arch = helloMsg.getArch(); | |
671 | client->snapclient.version = helloMsg.getVersion(); | |
672 | client->snapclient.name = helloMsg.getClientName(); | |
673 | client->snapclient.protocolVersion = helloMsg.getProtocolVersion(); | |
674 | client->config.instance = helloMsg.getInstance(); | |
675 | client->connected = true; | |
676 | chronos::systemtimeofday(&client->lastSeen); | |
677 | ||
678 | // Assign and update stream | |
679 | PcmStreamPtr stream = streamManager_->getStream(group->streamId); | |
680 | if (!stream) | |
681 | { | |
682 | stream = streamManager_->getDefaultStream(); | |
683 | group->streamId = stream->getId(); | |
684 | } | |
685 | LOG(DEBUG) << "Group: " << group->id << ", stream: " << group->streamId << "\n"; | |
686 | ||
687 | saveConfig(); | |
688 | ||
689 | streamSession->sendAsync(stream->getMeta()); | |
690 | streamSession->setPcmStream(stream); | |
691 | auto headerChunk = stream->getHeader(); | |
692 | streamSession->sendAsync(headerChunk); | |
693 | ||
694 | if (newGroup) | |
695 | { | |
696 | // clang-format off | |
697 | // Notification: {"jsonrpc":"2.0","method":"Server.OnUpdate","params":{"server":{"groups":[{"clients":[{"config":{"instance":2,"latency":6,"name":"123 456","volume":{"muted":false,"percent":48}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc#2","lastSeen":{"sec":1488025796,"usec":714671},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"4dcc4e3b-c699-a04b-7f0c-8260d23c43e1","muted":false,"name":"","stream_id":"stream 2"},{"clients":[{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":100}},"connected":true,"host":{"arch":"x86_64","ip":"127.0.0.1","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025798,"usec":728305},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}}],"id":"c5da8f7a-f377-1e51-8266-c5cc61099b71","muted":false,"name":"","stream_id":"stream 1"}],"server":{"host":{"arch":"x86_64","ip":"","mac":"","name":"T400","os":"Linux Mint 17.3 Rosa"},"snapserver":{"controlProtocolVersion":1,"name":"Snapserver","protocolVersion":1,"version":"0.10.0"}},"streams":[{"id":"stream 1","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 1","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 1","scheme":"pipe"}},{"id":"stream 2","status":"idle","uri":{"fragment":"","host":"","path":"/tmp/snapfifo","query":{"chunk_ms":"20","codec":"flac","name":"stream 2","sampleformat":"48000:16:2"},"raw":"pipe:///tmp/snapfifo?name=stream 2","scheme":"pipe"}}]}}} | |
698 | // clang-format on | |
699 | json server = Config::instance().getServerStatus(streamManager_->toJson()); | |
700 | json notification = jsonrpcpp::Notification("Server.OnUpdate", jsonrpcpp::Parameter("server", server)).to_json(); | |
701 | controlServer_->send(notification.dump()); | |
702 | } | |
703 | else | |
704 | { | |
705 | // clang-format off | |
706 | // Notification: {"jsonrpc":"2.0","method":"Client.OnConnect","params":{"client":{"config":{"instance":1,"latency":0,"name":"","volume":{"muted":false,"percent":81}},"connected":true,"host":{"arch":"x86_64","ip":"192.168.0.54","mac":"00:21:6a:7d:74:fc","name":"T400","os":"Linux Mint 17.3 Rosa"},"id":"00:21:6a:7d:74:fc","lastSeen":{"sec":1488025524,"usec":876332},"snapclient":{"name":"Snapclient","protocolVersion":2,"version":"0.10.0"}},"id":"00:21:6a:7d:74:fc"}} | |
707 | // clang-format on | |
708 | json notification = jsonrpcpp::Notification("Client.OnConnect", jsonrpcpp::Parameter("id", client->id, "client", client->toJson())).to_json(); | |
709 | controlServer_->send(notification.dump()); | |
710 | // cout << "Notification: " << notification.dump() << "\n"; | |
711 | } | |
712 | // cout << Config::instance().getServerStatus(streamManager_->toJson()).dump(4) << "\n"; | |
713 | // cout << group->toJson().dump(4) << "\n"; | |
714 | } | |
715 | } | |
716 | ||
717 | ||
718 | void StreamServer::saveConfig(const std::chrono::milliseconds& deferred) | |
719 | { | |
720 | config_timer_.cancel(); | |
721 | config_timer_.expires_after(deferred); | |
722 | config_timer_.async_wait([](const boost::system::error_code& ec) { | |
723 | if (!ec) | |
724 | { | |
725 | LOG(DEBUG) << "Saving config\n"; | |
726 | Config::instance().save(); | |
727 | } | |
728 | }); | |
729 | 158 | } |
730 | 159 | |
731 | 160 | |
745 | 174 | |
746 | 175 | session_ptr StreamServer::getStreamSession(const std::string& clientId) const |
747 | 176 | { |
748 | // LOG(INFO) << "getStreamSession: " << mac << "\n"; | |
177 | // LOG(INFO, LOG_TAG) << "getStreamSession: " << mac << "\n"; | |
749 | 178 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); |
750 | 179 | for (auto session : sessions_) |
751 | 180 | { |
763 | 192 | if (!ec) |
764 | 193 | handleAccept(std::move(socket)); |
765 | 194 | else |
766 | LOG(ERROR) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
195 | LOG(ERROR, LOG_TAG) << "Error while accepting socket connection: " << ec.message() << "\n"; | |
767 | 196 | }; |
768 | 197 | |
769 | 198 | for (auto& acceptor : acceptor_) |
784 | 213 | /// experimental: turn on tcp::no_delay |
785 | 214 | socket.set_option(tcp::no_delay(true)); |
786 | 215 | |
787 | SLOG(NOTICE) << "StreamServer::NewConnection: " << socket.remote_endpoint().address().to_string() << endl; | |
788 | shared_ptr<StreamSession> session = make_shared<StreamSession>(io_context_, this, std::move(socket)); | |
789 | ||
790 | session->setBufferMs(settings_.stream.bufferMs); | |
791 | session->start(); | |
792 | ||
793 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); | |
794 | sessions_.emplace_back(session); | |
795 | cleanup(); | |
216 | LOG(NOTICE, LOG_TAG) << "StreamServer::NewConnection: " << socket.remote_endpoint().address().to_string() << endl; | |
217 | shared_ptr<StreamSession> session = make_shared<StreamSessionTcp>(io_context_, this, std::move(socket)); | |
218 | addSession(session); | |
796 | 219 | } |
797 | 220 | catch (const std::exception& e) |
798 | 221 | { |
799 | SLOG(ERROR) << "Exception in StreamServer::handleAccept: " << e.what() << endl; | |
222 | LOG(ERROR, LOG_TAG) << "Exception in StreamServer::handleAccept: " << e.what() << endl; | |
800 | 223 | } |
801 | 224 | startAccept(); |
802 | 225 | } |
804 | 227 | |
805 | 228 | void StreamServer::start() |
806 | 229 | { |
807 | try | |
808 | { | |
809 | controlServer_ = std::make_unique<ControlServer>(io_context_, settings_.tcp, settings_.http, this); | |
810 | controlServer_->start(); | |
811 | ||
812 | streamManager_ = | |
813 | std::make_unique<StreamManager>(this, io_context_, settings_.stream.sampleFormat, settings_.stream.codec, settings_.stream.streamChunkMs); | |
814 | // throw SnapException("xxx"); | |
815 | for (const auto& streamUri : settings_.stream.pcmStreams) | |
816 | { | |
817 | PcmStreamPtr stream = streamManager_->addStream(streamUri); | |
818 | if (stream) | |
819 | LOG(INFO) << "Stream: " << stream->getUri().toJson() << "\n"; | |
820 | } | |
821 | streamManager_->start(); | |
822 | ||
823 | for (const auto& address : settings_.stream.bind_to_address) | |
824 | { | |
825 | try | |
826 | { | |
827 | LOG(INFO) << "Creating stream acceptor for address: " << address << ", port: " << settings_.stream.port << "\n"; | |
828 | acceptor_.emplace_back( | |
829 | make_unique<tcp::acceptor>(io_context_, tcp::endpoint(boost::asio::ip::address::from_string(address), settings_.stream.port))); | |
830 | } | |
831 | catch (const boost::system::system_error& e) | |
832 | { | |
833 | LOG(ERROR) << "error creating TCP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
834 | } | |
835 | } | |
836 | ||
837 | startAccept(); | |
838 | } | |
839 | catch (const std::exception& e) | |
840 | { | |
841 | SLOG(NOTICE) << "StreamServer::start: " << e.what() << endl; | |
842 | stop(); | |
843 | throw; | |
844 | } | |
230 | for (const auto& address : settings_.stream.bind_to_address) | |
231 | { | |
232 | try | |
233 | { | |
234 | LOG(INFO, LOG_TAG) << "Creating stream acceptor for address: " << address << ", port: " << settings_.stream.port << "\n"; | |
235 | acceptor_.emplace_back( | |
236 | make_unique<tcp::acceptor>(io_context_, tcp::endpoint(boost::asio::ip::address::from_string(address), settings_.stream.port))); | |
237 | } | |
238 | catch (const boost::system::system_error& e) | |
239 | { | |
240 | LOG(ERROR, LOG_TAG) << "error creating TCP acceptor: " << e.what() << ", code: " << e.code() << "\n"; | |
241 | } | |
242 | } | |
243 | ||
244 | startAccept(); | |
845 | 245 | } |
846 | 246 | |
847 | 247 | |
851 | 251 | acceptor->cancel(); |
852 | 252 | acceptor_.clear(); |
853 | 253 | |
854 | if (streamManager_) | |
855 | { | |
856 | streamManager_->stop(); | |
857 | streamManager_ = nullptr; | |
858 | } | |
859 | ||
860 | if (controlServer_) | |
861 | { | |
862 | controlServer_->stop(); | |
863 | controlServer_ = nullptr; | |
864 | } | |
865 | ||
866 | 254 | std::lock_guard<std::recursive_mutex> mlock(sessionsMutex_); |
867 | 255 | cleanup(); |
868 | 256 | for (auto s : sessions_) |
47 | 47 | /** |
48 | 48 | * Reads PCM data using PipeStream, implements PcmListener to get the (encoded) PCM stream. |
49 | 49 | * Accepts and holds client connections (StreamSession) |
50 | * Receives (via the MessageReceiver interface) and answers messages from the clients | |
50 | * Receives (via the StreamMessageReceiver interface) and answers messages from the clients | |
51 | 51 | * Forwards PCM data to the clients |
52 | 52 | */ |
53 | class StreamServer : public MessageReceiver, public ControlMessageReceiver, public PcmListener | |
53 | class StreamServer : public StreamMessageReceiver | |
54 | 54 | { |
55 | 55 | public: |
56 | StreamServer(boost::asio::io_context& io_context, const ServerSettings& serverSettings); | |
56 | StreamServer(boost::asio::io_context& io_context, const ServerSettings& serverSettings, StreamMessageReceiver* messageReceiver = nullptr); | |
57 | 57 | virtual ~StreamServer(); |
58 | 58 | |
59 | 59 | void start(); |
62 | 62 | /// Send a message to all connceted clients |
63 | 63 | // void send(const msg::BaseMessage* message); |
64 | 64 | |
65 | /// Clients call this when they receive a message. Implementation of MessageReceiver::onMessageReceived | |
66 | void onMessageReceived(StreamSession* connection, const msg::BaseMessage& baseMessage, char* buffer) override; | |
67 | void onDisconnect(StreamSession* connection) override; | |
65 | void addSession(const std::shared_ptr<StreamSession>& session); | |
66 | void onMetaChanged(const PcmStream* pcmStream, std::shared_ptr<msg::StreamTags> meta); | |
67 | void onChunkEncoded(const PcmStream* pcmStream, bool isDefaultStream, std::shared_ptr<msg::PcmChunk> chunk, double duration); | |
68 | 68 | |
69 | /// Implementation of ControllMessageReceiver::onMessageReceived, called by ControlServer::onMessageReceived | |
70 | std::string onMessageReceived(ControlSession* connection, const std::string& message) override; | |
71 | ||
72 | /// Implementation of PcmListener | |
73 | void onMetaChanged(const PcmStream* pcmStream) override; | |
74 | void onStateChanged(const PcmStream* pcmStream, const ReaderState& state) override; | |
75 | void onChunkRead(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) override; | |
76 | void onResync(const PcmStream* pcmStream, double ms) override; | |
69 | session_ptr getStreamSession(const std::string& mac) const; | |
70 | session_ptr getStreamSession(StreamSession* session) const; | |
77 | 71 | |
78 | 72 | private: |
79 | 73 | void startAccept(); |
80 | 74 | void handleAccept(tcp::socket socket); |
81 | session_ptr getStreamSession(const std::string& mac) const; | |
82 | session_ptr getStreamSession(StreamSession* session) const; | |
83 | void ProcessRequest(const jsonrpcpp::request_ptr request, jsonrpcpp::entity_ptr& response, jsonrpcpp::notification_ptr& notification) const; | |
84 | 75 | void cleanup(); |
85 | /// Save the server state deferred to prevent blocking and lower disk io | |
86 | /// @param deferred the delay after the last call to saveConfig | |
87 | void saveConfig(const std::chrono::milliseconds& deferred = std::chrono::seconds(2)); | |
76 | ||
77 | /// Implementation of StreamMessageReceiver | |
78 | void onMessageReceived(StreamSession* connection, const msg::BaseMessage& baseMessage, char* buffer) override; | |
79 | void onDisconnect(StreamSession* connection) override; | |
88 | 80 | |
89 | 81 | mutable std::recursive_mutex sessionsMutex_; |
90 | 82 | mutable std::recursive_mutex clientMutex_; |
95 | 87 | |
96 | 88 | ServerSettings settings_; |
97 | 89 | Queue<std::shared_ptr<msg::BaseMessage>> messages_; |
98 | std::unique_ptr<ControlServer> controlServer_; | |
99 | std::unique_ptr<StreamManager> streamManager_; | |
90 | StreamMessageReceiver* messageReceiver_; | |
100 | 91 | }; |
101 | 92 | |
102 | 93 |
28 | 28 | static constexpr auto LOG_TAG = "StreamSession"; |
29 | 29 | |
30 | 30 | |
31 | StreamSession::StreamSession(boost::asio::io_context& ioc, MessageReceiver* receiver, tcp::socket&& socket) | |
32 | : socket_(std::move(socket)), messageReceiver_(receiver), pcmStream_(nullptr), strand_(ioc) | |
31 | StreamSession::StreamSession(boost::asio::io_context& ioc, StreamMessageReceiver* receiver) : messageReceiver_(receiver), pcmStream_(nullptr), strand_(ioc) | |
33 | 32 | { |
34 | 33 | base_msg_size_ = baseMessage_.getSize(); |
35 | 34 | buffer_.resize(base_msg_size_); |
36 | } | |
37 | ||
38 | ||
39 | StreamSession::~StreamSession() | |
40 | { | |
41 | stop(); | |
42 | } | |
43 | ||
44 | ||
45 | void StreamSession::read_next() | |
46 | { | |
47 | boost::asio::async_read(socket_, boost::asio::buffer(buffer_, base_msg_size_), | |
48 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](boost::system::error_code ec, std::size_t length) mutable { | |
49 | if (ec) | |
50 | { | |
51 | LOG(ERROR, LOG_TAG) << "Error reading message header of length " << length << ": " << ec.message() << "\n"; | |
52 | messageReceiver_->onDisconnect(this); | |
53 | return; | |
54 | } | |
55 | ||
56 | baseMessage_.deserialize(buffer_.data()); | |
57 | LOG(DEBUG, LOG_TAG) << "getNextMessage: " << baseMessage_.type << ", size: " << baseMessage_.size << ", id: " << baseMessage_.id | |
58 | << ", refers: " << baseMessage_.refersTo << "\n"; | |
59 | if (baseMessage_.type > message_type::kLast) | |
60 | { | |
61 | LOG(ERROR, LOG_TAG) << "unknown message type received: " << baseMessage_.type << ", size: " << baseMessage_.size << "\n"; | |
62 | messageReceiver_->onDisconnect(this); | |
63 | return; | |
64 | } | |
65 | else if (baseMessage_.size > msg::max_size) | |
66 | { | |
67 | LOG(ERROR, LOG_TAG) << "received message of type " << baseMessage_.type << " to large: " << baseMessage_.size << "\n"; | |
68 | messageReceiver_->onDisconnect(this); | |
69 | return; | |
70 | } | |
71 | ||
72 | if (baseMessage_.size > buffer_.size()) | |
73 | buffer_.resize(baseMessage_.size); | |
74 | ||
75 | boost::asio::async_read( | |
76 | socket_, boost::asio::buffer(buffer_, baseMessage_.size), | |
77 | boost::asio::bind_executor(strand_, [this, self](boost::system::error_code ec, std::size_t length) mutable { | |
78 | if (ec) | |
79 | { | |
80 | LOG(ERROR, LOG_TAG) << "Error reading message body of length " << length << ": " << ec.message() << "\n"; | |
81 | messageReceiver_->onDisconnect(this); | |
82 | return; | |
83 | } | |
84 | ||
85 | tv t; | |
86 | baseMessage_.received = t; | |
87 | if (messageReceiver_ != nullptr) | |
88 | messageReceiver_->onMessageReceived(this, baseMessage_, buffer_.data()); | |
89 | read_next(); | |
90 | })); | |
91 | })); | |
92 | 35 | } |
93 | 36 | |
94 | 37 | |
104 | 47 | } |
105 | 48 | |
106 | 49 | |
107 | void StreamSession::start() | |
108 | { | |
109 | read_next(); | |
110 | // strand_.post([this]() { read_next(); }); | |
111 | } | |
112 | ||
113 | ||
114 | void StreamSession::stop() | |
115 | { | |
116 | LOG(DEBUG, LOG_TAG) << "StreamSession::stop\n"; | |
117 | boost::system::error_code ec; | |
118 | socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); | |
119 | if (ec) | |
120 | LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << "\n"; | |
121 | socket_.close(ec); | |
122 | if (ec) | |
123 | LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; | |
124 | LOG(DEBUG, LOG_TAG) << "StreamSession stopped\n"; | |
125 | } | |
126 | ||
127 | ||
128 | 50 | void StreamSession::send_next() |
129 | 51 | { |
130 | 52 | auto& buffer = messages_.front(); |
131 | 53 | buffer.on_air = true; |
132 | boost::asio::async_write(socket_, buffer, | |
133 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this(), buffer ](boost::system::error_code ec, std::size_t length) { | |
134 | messages_.pop_front(); | |
135 | if (ec) | |
136 | { | |
137 | LOG(ERROR, LOG_TAG) << "StreamSession write error (msg length: " << length << "): " << ec.message() << "\n"; | |
138 | messageReceiver_->onDisconnect(this); | |
139 | return; | |
140 | } | |
141 | if (!messages_.empty()) | |
142 | send_next(); | |
143 | })); | |
54 | strand_.post([ this, self = shared_from_this(), buffer ]() { | |
55 | sendAsync(buffer, [this](boost::system::error_code ec, std::size_t length) { | |
56 | messages_.pop_front(); | |
57 | if (ec) | |
58 | { | |
59 | LOG(ERROR, LOG_TAG) << "StreamSession write error (msg length: " << length << "): " << ec.message() << "\n"; | |
60 | messageReceiver_->onDisconnect(this); | |
61 | return; | |
62 | } | |
63 | if (!messages_.empty()) | |
64 | send_next(); | |
65 | }); | |
66 | }); | |
144 | 67 | } |
145 | 68 | |
146 | 69 | |
147 | void StreamSession::sendAsync(shared_const_buffer const_buf, bool send_now) | |
70 | void StreamSession::send(shared_const_buffer const_buf) | |
148 | 71 | { |
149 | strand_.post([ this, self = shared_from_this(), const_buf, send_now ]() { | |
72 | strand_.post([ this, self = shared_from_this(), const_buf ]() { | |
150 | 73 | // delete PCM chunks that are older than the overall buffer duration |
151 | 74 | messages_.erase(std::remove_if(messages_.begin(), messages_.end(), |
152 | 75 | [this](const shared_const_buffer& buffer) { |
158 | 81 | }), |
159 | 82 | messages_.end()); |
160 | 83 | |
161 | if (send_now) | |
162 | messages_.push_front(const_buf); | |
163 | else | |
164 | messages_.push_back(const_buf); | |
84 | messages_.push_back(const_buf); | |
165 | 85 | |
166 | 86 | if (messages_.size() > 1) |
167 | 87 | { |
168 | LOG(DEBUG, LOG_TAG) << "outstanding async_write\n"; | |
88 | LOG(TRACE, LOG_TAG) << "outstanding async_write\n"; | |
169 | 89 | return; |
170 | 90 | } |
171 | 91 | send_next(); |
173 | 93 | } |
174 | 94 | |
175 | 95 | |
176 | void StreamSession::sendAsync(msg::message_ptr message, bool send_now) | |
96 | void StreamSession::send(msg::message_ptr message) | |
177 | 97 | { |
178 | 98 | if (!message) |
179 | 99 | return; |
180 | 100 | |
181 | 101 | // TODO: better set the timestamp in send_next for more accurate time sync |
182 | sendAsync(shared_const_buffer(*message), send_now); | |
102 | send(shared_const_buffer(*message)); | |
183 | 103 | } |
184 | 104 | |
185 | 105 |
39 | 39 | |
40 | 40 | |
41 | 41 | /// Interface: callback for a received message. |
42 | class MessageReceiver | |
42 | class StreamMessageReceiver | |
43 | 43 | { |
44 | 44 | public: |
45 | 45 | virtual void onMessageReceived(StreamSession* connection, const msg::BaseMessage& baseMessage, char* buffer) = 0; |
54 | 54 | { |
55 | 55 | std::vector<char> data; |
56 | 56 | bool is_pcm_chunk; |
57 | uint16_t type; | |
57 | 58 | chronos::time_point_clk rec_time; |
58 | 59 | }; |
59 | 60 | |
64 | 65 | message.sent = t; |
65 | 66 | const msg::PcmChunk* pcm_chunk = dynamic_cast<const msg::PcmChunk*>(&message); |
66 | 67 | message_ = std::make_shared<Message>(); |
68 | message_->type = message.type; | |
67 | 69 | message_->is_pcm_chunk = (pcm_chunk != nullptr); |
68 | 70 | if (message_->is_pcm_chunk) |
69 | 71 | message_->rec_time = pcm_chunk->start(); |
76 | 78 | } |
77 | 79 | |
78 | 80 | // Implement the ConstBufferSequence requirements. |
79 | typedef boost::asio::const_buffer value_type; | |
80 | typedef const boost::asio::const_buffer* const_iterator; | |
81 | using value_type = boost::asio::const_buffer; | |
82 | using const_iterator = const boost::asio::const_buffer*; | |
81 | 83 | const boost::asio::const_buffer* begin() const |
82 | 84 | { |
83 | 85 | return &buffer_; |
101 | 103 | }; |
102 | 104 | |
103 | 105 | |
106 | using WriteHandler = std::function<void(boost::system::error_code ec, std::size_t length)>; | |
107 | ||
104 | 108 | /// Endpoint for a connected client. |
105 | 109 | /** |
106 | 110 | * Endpoint for a connected client. |
107 | 111 | * Messages are sent to the client with the "send" method. |
108 | * Received messages from the client are passed to the MessageReceiver callback | |
112 | * Received messages from the client are passed to the StreamMessageReceiver callback | |
109 | 113 | */ |
110 | 114 | class StreamSession : public std::enable_shared_from_this<StreamSession> |
111 | 115 | { |
112 | 116 | public: |
113 | /// ctor. Received message from the client are passed to MessageReceiver | |
114 | StreamSession(boost::asio::io_context& ioc, MessageReceiver* receiver, tcp::socket&& socket); | |
115 | ~StreamSession(); | |
116 | void start(); | |
117 | void stop(); | |
117 | /// ctor. Received message from the client are passed to StreamMessageReceiver | |
118 | StreamSession(boost::asio::io_context& ioc, StreamMessageReceiver* receiver); | |
119 | virtual ~StreamSession() = default; | |
120 | ||
121 | virtual std::string getIP() = 0; | |
122 | ||
123 | virtual void start() = 0; | |
124 | virtual void stop() = 0; | |
125 | ||
126 | void setMessageReceiver(StreamMessageReceiver* receiver) | |
127 | { | |
128 | messageReceiver_ = receiver; | |
129 | } | |
130 | ||
131 | protected: | |
132 | virtual void sendAsync(const shared_const_buffer& buffer, const WriteHandler& handler) = 0; | |
133 | ||
134 | public: | |
135 | /// Sends a message to the client (asynchronous) | |
136 | void send(msg::message_ptr message); | |
118 | 137 | |
119 | 138 | /// Sends a message to the client (asynchronous) |
120 | void sendAsync(msg::message_ptr message, bool send_now = false); | |
121 | ||
122 | /// Sends a message to the client (asynchronous) | |
123 | void sendAsync(shared_const_buffer const_buf, bool send_now = false); | |
139 | void send(shared_const_buffer const_buf); | |
124 | 140 | |
125 | 141 | /// Max playout latency. No need to send PCM data that is older than bufferMs |
126 | 142 | void setBufferMs(size_t bufferMs); |
127 | 143 | |
128 | 144 | std::string clientId; |
129 | 145 | |
130 | std::string getIP() | |
131 | { | |
132 | return socket_.remote_endpoint().address().to_string(); | |
133 | } | |
134 | ||
135 | 146 | void setPcmStream(streamreader::PcmStreamPtr pcmStream); |
136 | 147 | const streamreader::PcmStreamPtr pcmStream() const; |
137 | 148 | |
138 | 149 | protected: |
139 | void read_next(); | |
140 | 150 | void send_next(); |
141 | 151 | |
142 | 152 | msg::BaseMessage baseMessage_; |
143 | 153 | std::vector<char> buffer_; |
144 | 154 | size_t base_msg_size_; |
145 | tcp::socket socket_; | |
146 | MessageReceiver* messageReceiver_; | |
155 | StreamMessageReceiver* messageReceiver_; | |
147 | 156 | size_t bufferMs_; |
148 | 157 | streamreader::PcmStreamPtr pcmStream_; |
149 | 158 | boost::asio::io_context::strand strand_; |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "stream_session_tcp.hpp" | |
19 | ||
20 | #include "common/aixlog.hpp" | |
21 | #include "message/pcm_chunk.hpp" | |
22 | #include <iostream> | |
23 | ||
24 | using namespace std; | |
25 | using namespace streamreader; | |
26 | ||
27 | ||
28 | static constexpr auto LOG_TAG = "StreamSessionTCP"; | |
29 | ||
30 | ||
31 | StreamSessionTcp::StreamSessionTcp(boost::asio::io_context& ioc, StreamMessageReceiver* receiver, tcp::socket&& socket) | |
32 | : StreamSession(ioc, receiver), socket_(std::move(socket)) | |
33 | { | |
34 | } | |
35 | ||
36 | ||
37 | StreamSessionTcp::~StreamSessionTcp() | |
38 | { | |
39 | LOG(DEBUG, LOG_TAG) << "~StreamSessionTcp\n"; | |
40 | stop(); | |
41 | } | |
42 | ||
43 | ||
44 | void StreamSessionTcp::start() | |
45 | { | |
46 | read_next(); | |
47 | } | |
48 | ||
49 | ||
50 | void StreamSessionTcp::stop() | |
51 | { | |
52 | LOG(DEBUG, LOG_TAG) << "stop\n"; | |
53 | if (socket_.is_open()) | |
54 | { | |
55 | boost::system::error_code ec; | |
56 | socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); | |
57 | if (ec) | |
58 | LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << "\n"; | |
59 | socket_.close(ec); | |
60 | if (ec) | |
61 | LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; | |
62 | LOG(DEBUG, LOG_TAG) << "stopped\n"; | |
63 | } | |
64 | } | |
65 | ||
66 | ||
67 | std::string StreamSessionTcp::getIP() | |
68 | { | |
69 | try | |
70 | { | |
71 | return socket_.remote_endpoint().address().to_string(); | |
72 | } | |
73 | catch (...) | |
74 | { | |
75 | return "0.0.0.0"; | |
76 | } | |
77 | } | |
78 | ||
79 | ||
80 | void StreamSessionTcp::read_next() | |
81 | { | |
82 | boost::asio::async_read(socket_, boost::asio::buffer(buffer_, base_msg_size_), | |
83 | boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](boost::system::error_code ec, std::size_t length) mutable { | |
84 | if (ec) | |
85 | { | |
86 | LOG(ERROR, LOG_TAG) << "Error reading message header of length " << length << ": " << ec.message() << "\n"; | |
87 | messageReceiver_->onDisconnect(this); | |
88 | return; | |
89 | } | |
90 | ||
91 | baseMessage_.deserialize(buffer_.data()); | |
92 | LOG(DEBUG, LOG_TAG) << "getNextMessage: " << baseMessage_.type << ", size: " << baseMessage_.size << ", id: " << baseMessage_.id | |
93 | << ", refers: " << baseMessage_.refersTo << "\n"; | |
94 | if (baseMessage_.type > message_type::kLast) | |
95 | { | |
96 | LOG(ERROR, LOG_TAG) << "unknown message type received: " << baseMessage_.type << ", size: " << baseMessage_.size << "\n"; | |
97 | messageReceiver_->onDisconnect(this); | |
98 | return; | |
99 | } | |
100 | else if (baseMessage_.size > msg::max_size) | |
101 | { | |
102 | LOG(ERROR, LOG_TAG) << "received message of type " << baseMessage_.type << " to large: " << baseMessage_.size << "\n"; | |
103 | messageReceiver_->onDisconnect(this); | |
104 | return; | |
105 | } | |
106 | ||
107 | if (baseMessage_.size > buffer_.size()) | |
108 | buffer_.resize(baseMessage_.size); | |
109 | ||
110 | boost::asio::async_read( | |
111 | socket_, boost::asio::buffer(buffer_, baseMessage_.size), | |
112 | boost::asio::bind_executor(strand_, [this, self](boost::system::error_code ec, std::size_t length) mutable { | |
113 | if (ec) | |
114 | { | |
115 | LOG(ERROR, LOG_TAG) << "Error reading message body of length " << length << ": " << ec.message() << "\n"; | |
116 | messageReceiver_->onDisconnect(this); | |
117 | return; | |
118 | } | |
119 | ||
120 | tv t; | |
121 | baseMessage_.received = t; | |
122 | if (messageReceiver_ != nullptr) | |
123 | messageReceiver_->onMessageReceived(this, baseMessage_, buffer_.data()); | |
124 | read_next(); | |
125 | })); | |
126 | })); | |
127 | } | |
128 | ||
129 | ||
130 | void StreamSessionTcp::sendAsync(const shared_const_buffer& buffer, const WriteHandler& handler) | |
131 | { | |
132 | boost::asio::async_write(socket_, buffer, | |
133 | boost::asio::bind_executor(strand_, [ self = shared_from_this(), buffer, handler ](boost::system::error_code ec, | |
134 | std::size_t length) { handler(ec, length); })); | |
135 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef STREAM_SESSION_TCP_HPP | |
19 | #define STREAM_SESSION_TCP_HPP | |
20 | ||
21 | #include "stream_session.hpp" | |
22 | ||
23 | using boost::asio::ip::tcp; | |
24 | ||
25 | ||
26 | /// Endpoint for a connected client. | |
27 | /** | |
28 | * Endpoint for a connected client. | |
29 | * Messages are sent to the client with the "send" method. | |
30 | * Received messages from the client are passed to the StreamMessageReceiver callback | |
31 | */ | |
32 | class StreamSessionTcp : public StreamSession | |
33 | { | |
34 | public: | |
35 | /// ctor. Received message from the client are passed to StreamMessageReceiver | |
36 | StreamSessionTcp(boost::asio::io_context& ioc, StreamMessageReceiver* receiver, tcp::socket&& socket); | |
37 | ~StreamSessionTcp() override; | |
38 | void start() override; | |
39 | void stop() override; | |
40 | std::string getIP() override; | |
41 | ||
42 | protected: | |
43 | void read_next(); | |
44 | void sendAsync(const shared_const_buffer& buffer, const WriteHandler& handler) override; | |
45 | ||
46 | private: | |
47 | tcp::socket socket_; | |
48 | }; | |
49 | ||
50 | ||
51 | ||
52 | #endif |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "stream_session_ws.hpp" | |
19 | #include "common/aixlog.hpp" | |
20 | #include "message/pcm_chunk.hpp" | |
21 | #include <iostream> | |
22 | ||
23 | using namespace std; | |
24 | ||
25 | static constexpr auto LOG_TAG = "StreamSessionWS"; | |
26 | ||
27 | ||
28 | StreamSessionWebsocket::StreamSessionWebsocket(boost::asio::io_context& ioc, StreamMessageReceiver* receiver, websocket::stream<beast::tcp_stream>&& socket) | |
29 | : StreamSession(ioc, receiver), ws_(std::move(socket)) | |
30 | { | |
31 | LOG(DEBUG, LOG_TAG) << "StreamSessionWS\n"; | |
32 | } | |
33 | ||
34 | ||
35 | StreamSessionWebsocket::~StreamSessionWebsocket() | |
36 | { | |
37 | LOG(DEBUG, LOG_TAG) << "~StreamSessionWS\n"; | |
38 | stop(); | |
39 | } | |
40 | ||
41 | ||
42 | void StreamSessionWebsocket::start() | |
43 | { | |
44 | // Read a message | |
45 | LOG(DEBUG, LOG_TAG) << "start\n"; | |
46 | ws_.binary(true); | |
47 | do_read_ws(); | |
48 | } | |
49 | ||
50 | ||
51 | void StreamSessionWebsocket::stop() | |
52 | { | |
53 | if (ws_.is_open()) | |
54 | { | |
55 | boost::beast::error_code ec; | |
56 | ws_.close(beast::websocket::close_code::normal, ec); | |
57 | if (ec) | |
58 | LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; | |
59 | } | |
60 | } | |
61 | ||
62 | ||
63 | std::string StreamSessionWebsocket::getIP() | |
64 | { | |
65 | try | |
66 | { | |
67 | return ws_.next_layer().socket().remote_endpoint().address().to_string(); | |
68 | } | |
69 | catch (...) | |
70 | { | |
71 | return "0.0.0.0"; | |
72 | } | |
73 | } | |
74 | ||
75 | ||
76 | void StreamSessionWebsocket::sendAsync(const shared_const_buffer& buffer, const WriteHandler& handler) | |
77 | { | |
78 | LOG(TRACE, LOG_TAG) << "sendAsync: " << buffer.message().type << "\n"; | |
79 | ws_.async_write(buffer, boost::asio::bind_executor(strand_, [ self = shared_from_this(), buffer, handler ](boost::system::error_code ec, | |
80 | std::size_t length) { handler(ec, length); })); | |
81 | } | |
82 | ||
83 | ||
84 | void StreamSessionWebsocket::do_read_ws() | |
85 | { | |
86 | // Read a message into our buffer | |
87 | ws_.async_read(buffer_, boost::asio::bind_executor(strand_, [ this, self = shared_from_this() ](beast::error_code ec, std::size_t bytes_transferred) { | |
88 | on_read_ws(ec, bytes_transferred); | |
89 | })); | |
90 | } | |
91 | ||
92 | ||
93 | void StreamSessionWebsocket::on_read_ws(beast::error_code ec, std::size_t bytes_transferred) | |
94 | { | |
95 | LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; | |
96 | boost::ignore_unused(bytes_transferred); | |
97 | ||
98 | // This indicates that the session was closed | |
99 | if (ec == websocket::error::closed) | |
100 | { | |
101 | messageReceiver_->onDisconnect(this); | |
102 | return; | |
103 | } | |
104 | ||
105 | if (ec) | |
106 | { | |
107 | LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n"; | |
108 | messageReceiver_->onDisconnect(this); | |
109 | return; | |
110 | } | |
111 | ||
112 | auto data = boost::asio::buffer_cast<char*>(buffer_.data()); | |
113 | baseMessage_.deserialize(data); | |
114 | LOG(DEBUG, LOG_TAG) << "getNextMessage: " << baseMessage_.type << ", size: " << baseMessage_.size << ", id: " << baseMessage_.id | |
115 | << ", refers: " << baseMessage_.refersTo << "\n"; | |
116 | ||
117 | tv t; | |
118 | baseMessage_.received = t; | |
119 | if (messageReceiver_ != nullptr) | |
120 | messageReceiver_->onMessageReceived(this, baseMessage_, data + base_msg_size_); | |
121 | ||
122 | buffer_.consume(bytes_transferred); | |
123 | do_read_ws(); | |
124 | } |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef STREAM_SESSION_WS_HPP | |
19 | #define STREAM_SESSION_WS_HPP | |
20 | ||
21 | #include "stream_session.hpp" | |
22 | #include <boost/beast/core.hpp> | |
23 | #include <boost/beast/websocket.hpp> | |
24 | #include <deque> | |
25 | ||
26 | namespace beast = boost::beast; // from <boost/beast.hpp> | |
27 | namespace http = beast::http; // from <boost/beast/http.hpp> | |
28 | namespace websocket = beast::websocket; // from <boost/beast/websocket.hpp> | |
29 | namespace net = boost::asio; // from <boost/asio.hpp> | |
30 | ||
31 | ||
32 | /// Endpoint for a connected control client. | |
33 | /** | |
34 | * Endpoint for a connected control client. | |
35 | * Messages are sent to the client with the "send" method. | |
36 | * Received messages from the client are passed to the ControlMessageReceiver callback | |
37 | */ | |
38 | class StreamSessionWebsocket : public StreamSession | |
39 | { | |
40 | public: | |
41 | /// ctor. Received message from the client are passed to StreamMessageReceiver | |
42 | StreamSessionWebsocket(boost::asio::io_context& ioc, StreamMessageReceiver* receiver, websocket::stream<beast::tcp_stream>&& socket); | |
43 | ~StreamSessionWebsocket() override; | |
44 | void start() override; | |
45 | void stop() override; | |
46 | std::string getIP() override; | |
47 | ||
48 | protected: | |
49 | // Websocket methods | |
50 | void sendAsync(const shared_const_buffer& buffer, const WriteHandler& handler) override; | |
51 | void on_read_ws(beast::error_code ec, std::size_t bytes_transferred); | |
52 | void do_read_ws(); | |
53 | ||
54 | websocket::stream<beast::tcp_stream> ws_; | |
55 | ||
56 | protected: | |
57 | beast::flat_buffer buffer_; | |
58 | }; | |
59 | ||
60 | ||
61 | ||
62 | #endif |
33 | 33 | { |
34 | 34 | string hex2str(string input) |
35 | 35 | { |
36 | typedef unsigned char byte; | |
36 | using byte = unsigned char; | |
37 | 37 | unsigned long x = strtoul(input.c_str(), nullptr, 16); |
38 | 38 | byte a[] = {byte(x >> 24), byte(x >> 16), byte(x >> 8), byte(x), 0}; |
39 | 39 | return string((char*)a); |
231 | 231 | boost::asio::async_read_until(*pipe_fd_, streambuf_pipe_, delimiter, [this, delimiter](const std::error_code& ec, std::size_t bytes_transferred) { |
232 | 232 | if (ec) |
233 | 233 | { |
234 | if (ec.value() == boost::asio::error::eof) | |
234 | if ((ec.value() == boost::asio::error::eof) || (ec.value() == boost::asio::error::bad_descriptor)) | |
235 | 235 | { |
236 | 236 | // For some reason, EOF is returned until the first metadata is written to the pipe. |
237 | // Is this a boost bug? | |
237 | // If shairport-sync has not finished setting up the pipe, bad file descriptor is returned. | |
238 | 238 | LOG(INFO, LOG_TAG) << "Waiting for metadata, retrying in 2500ms" |
239 | 239 | << "\n"; |
240 | 240 | wait(pipe_open_timer_, 2500ms, [this] { pipeReadLine(); }); |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include <cerrno> | |
19 | #include <fcntl.h> | |
20 | #include <memory> | |
21 | #include <sys/stat.h> | |
22 | #include <unistd.h> | |
23 | ||
24 | #include "common/aixlog.hpp" | |
25 | #include "common/snap_exception.hpp" | |
26 | #include "common/str_compat.hpp" | |
27 | ||
28 | #include "alsa_stream.hpp" | |
29 | ||
30 | ||
31 | using namespace std; | |
32 | using namespace std::chrono_literals; | |
33 | ||
34 | namespace streamreader | |
35 | { | |
36 | ||
37 | static constexpr auto LOG_TAG = "AlsaStream"; | |
38 | static constexpr auto kResyncTolerance = 50ms; | |
39 | ||
40 | // https://superuser.com/questions/597227/linux-arecord-capture-sound-card-output-rather-than-microphone-input | |
41 | // https://wiki.ubuntuusers.de/.asoundrc/ | |
42 | // https://alsa.opensrc.org/Dsnoop#The_dsnoop_howto | |
43 | // https://linuxconfig.org/how-to-test-microphone-with-audio-linux-sound-architecture-alsa | |
44 | // https://www.alsa-project.org/alsa-doc/alsa-lib/_2test_2latency_8c-example.html#a30 | |
45 | ||
46 | ||
47 | namespace | |
48 | { | |
49 | template <typename Rep, typename Period> | |
50 | void wait(boost::asio::steady_timer& timer, const std::chrono::duration<Rep, Period>& duration, std::function<void()> handler) | |
51 | { | |
52 | timer.expires_after(duration); | |
53 | timer.async_wait([handler = std::move(handler)](const boost::system::error_code& ec) { | |
54 | if (ec) | |
55 | { | |
56 | LOG(ERROR, LOG_TAG) << "Error during async wait: " << ec.message() << "\n"; | |
57 | } | |
58 | else | |
59 | { | |
60 | handler(); | |
61 | } | |
62 | }); | |
63 | } | |
64 | } // namespace | |
65 | ||
66 | ||
67 | AlsaStream::AlsaStream(PcmListener* pcmListener, boost::asio::io_context& ioc, const StreamUri& uri) | |
68 | : PcmStream(pcmListener, ioc, uri), handle_(nullptr), read_timer_(ioc), silence_(0ms) | |
69 | { | |
70 | device_ = uri_.getQuery("device", "hw:0"); | |
71 | LOG(DEBUG, LOG_TAG) << "Device: " << device_ << "\n"; | |
72 | } | |
73 | ||
74 | ||
75 | void AlsaStream::start() | |
76 | { | |
77 | LOG(DEBUG, LOG_TAG) << "Start, sampleformat: " << sampleFormat_.toString() << "\n"; | |
78 | ||
79 | // idle_bytes_ = 0; | |
80 | // max_idle_bytes_ = sampleFormat_.rate() * sampleFormat_.frameSize() * dryout_ms_ / 1000; | |
81 | ||
82 | chunk_ = std::make_unique<msg::PcmChunk>(sampleFormat_, chunk_ms_); | |
83 | silent_chunk_ = std::vector<char>(chunk_->payloadSize, 0); | |
84 | LOG(DEBUG, LOG_TAG) << "Chunk duration: " << chunk_->durationMs() << " ms, frames: " << chunk_->getFrameCount() << ", size: " << chunk_->payloadSize | |
85 | << "\n"; | |
86 | first_ = true; | |
87 | tvEncodedChunk_ = std::chrono::steady_clock::now(); | |
88 | initAlsa(); | |
89 | PcmStream::start(); | |
90 | // wait(read_timer_, std::chrono::milliseconds(chunk_ms_), [this] { do_read(); }); | |
91 | do_read(); | |
92 | } | |
93 | ||
94 | ||
95 | void AlsaStream::stop() | |
96 | { | |
97 | PcmStream::stop(); | |
98 | uninitAlsa(); | |
99 | } | |
100 | ||
101 | ||
102 | void AlsaStream::initAlsa() | |
103 | { | |
104 | int err; | |
105 | unsigned int rate = sampleFormat_.rate(); | |
106 | snd_pcm_format_t snd_pcm_format; | |
107 | if (sampleFormat_.bits() == 8) | |
108 | snd_pcm_format = SND_PCM_FORMAT_S8; | |
109 | else if (sampleFormat_.bits() == 16) | |
110 | snd_pcm_format = SND_PCM_FORMAT_S16_LE; | |
111 | else if ((sampleFormat_.bits() == 24) && (sampleFormat_.sampleSize() == 4)) | |
112 | snd_pcm_format = SND_PCM_FORMAT_S24_LE; | |
113 | else if (sampleFormat_.bits() == 32) | |
114 | snd_pcm_format = SND_PCM_FORMAT_S32_LE; | |
115 | else | |
116 | throw SnapException("Unsupported sample format: " + cpt::to_string(sampleFormat_.bits())); | |
117 | ||
118 | if ((err = snd_pcm_open(&handle_, device_.c_str(), SND_PCM_STREAM_CAPTURE, SND_PCM_NONBLOCK)) < 0) // SND_PCM_NONBLOCK | |
119 | throw SnapException("Can't open device '" + device_ + "', error: " + snd_strerror(err)); | |
120 | ||
121 | snd_pcm_hw_params_t* hw_params; | |
122 | if ((err = snd_pcm_hw_params_malloc(&hw_params)) < 0) | |
123 | throw SnapException("Can't allocate hardware parameter structure: " + string(snd_strerror(err))); | |
124 | ||
125 | if ((err = snd_pcm_hw_params_any(handle_, hw_params)) < 0) | |
126 | throw SnapException("Can't fill params: " + string(snd_strerror(err))); | |
127 | ||
128 | if ((err = snd_pcm_hw_params_set_access(handle_, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED)) < 0) | |
129 | throw SnapException("Can't set interleaved mode: " + string(snd_strerror(err))); | |
130 | ||
131 | if ((err = snd_pcm_hw_params_set_format(handle_, hw_params, snd_pcm_format)) < 0) | |
132 | throw SnapException("Can't set sample format: " + string(snd_strerror(err))); | |
133 | ||
134 | if ((err = snd_pcm_hw_params_set_rate_near(handle_, hw_params, &rate, 0)) < 0) | |
135 | throw SnapException("Can't set rate: " + string(snd_strerror(err))); | |
136 | ||
137 | if ((err = snd_pcm_hw_params_set_channels(handle_, hw_params, sampleFormat_.channels())) < 0) | |
138 | throw SnapException("Can't set channel count: " + string(snd_strerror(err))); | |
139 | ||
140 | if ((err = snd_pcm_hw_params(handle_, hw_params)) < 0) | |
141 | throw SnapException("Can't set hardware parameters: " + string(snd_strerror(err))); | |
142 | ||
143 | snd_pcm_hw_params_free(hw_params); | |
144 | ||
145 | if ((err = snd_pcm_prepare(handle_)) < 0) | |
146 | throw SnapException("Can't prepare audio interface for use: " + string(snd_strerror(err))); | |
147 | } | |
148 | ||
149 | ||
150 | void AlsaStream::uninitAlsa() | |
151 | { | |
152 | if (handle_) | |
153 | { | |
154 | snd_pcm_close(handle_); | |
155 | handle_ = nullptr; | |
156 | } | |
157 | } | |
158 | ||
159 | ||
160 | void AlsaStream::do_read() | |
161 | { | |
162 | try | |
163 | { | |
164 | if (first_) | |
165 | { | |
166 | LOG(TRACE, LOG_TAG) << "First read, initializing nextTick to now\n"; | |
167 | nextTick_ = std::chrono::steady_clock::now(); | |
168 | } | |
169 | ||
170 | int toRead = chunk_->payloadSize; | |
171 | auto duration = chunk_->duration<std::chrono::nanoseconds>(); | |
172 | int len = 0; | |
173 | do | |
174 | { | |
175 | int count = snd_pcm_readi(handle_, chunk_->payload + len, (toRead - len) / chunk_->format.frameSize()); | |
176 | if (count == -EAGAIN) | |
177 | { | |
178 | LOG(INFO, LOG_TAG) << "No data availabale, playing silence.\n"; | |
179 | // no data available, fill with silence | |
180 | memset(chunk_->payload + len, 0, toRead - len); | |
181 | // idle_bytes_ += toRead - len; | |
182 | break; | |
183 | } | |
184 | else if (count == 0) | |
185 | { | |
186 | throw SnapException("end of file"); | |
187 | } | |
188 | else if (count < 0) | |
189 | { | |
190 | // ESTRPIPE | |
191 | LOG(ERROR, LOG_TAG) << "Error reading PCM data: " << snd_strerror(count) << " (code: " << count << ")\n"; | |
192 | first_ = true; | |
193 | uninitAlsa(); | |
194 | initAlsa(); | |
195 | continue; | |
196 | } | |
197 | else | |
198 | { | |
199 | // LOG(TRACE, LOG_TAG) << "count: " << count << ", len: " << len << ", toRead: " << toRead << "\n"; | |
200 | len += count * chunk_->format.frameSize(); | |
201 | } | |
202 | } while (len < toRead); | |
203 | ||
204 | if (std::memcmp(chunk_->payload, silent_chunk_.data(), silent_chunk_.size()) == 0) | |
205 | { | |
206 | silence_ += chunk_->duration<std::chrono::microseconds>(); | |
207 | if (silence_ > 100ms) | |
208 | { | |
209 | setState(ReaderState::kIdle); | |
210 | } | |
211 | } | |
212 | else | |
213 | { | |
214 | silence_ = 0ms; | |
215 | setState(ReaderState::kPlaying); | |
216 | } | |
217 | ||
218 | // LOG(DEBUG, LOG_TAG) << "Received " << len << "/" << toRead << " bytes\n"; | |
219 | if (first_) | |
220 | { | |
221 | first_ = false; | |
222 | // initialize the stream's base timestamp to now minus the chunk's duration | |
223 | tvEncodedChunk_ = std::chrono::steady_clock::now() - duration; | |
224 | } | |
225 | ||
226 | chunkRead(*chunk_); | |
227 | ||
228 | nextTick_ += duration; | |
229 | auto currentTick = std::chrono::steady_clock::now(); | |
230 | auto next_read = nextTick_ - currentTick; | |
231 | if (next_read >= 0ms) | |
232 | { | |
233 | // LOG(DEBUG, LOG_TAG) << "Next read: " << std::chrono::duration_cast<std::chrono::milliseconds>(next_read).count() << "\n"; | |
234 | // synchronize reads to an interval of chunk_ms_ | |
235 | wait(read_timer_, nextTick_ - currentTick, [this] { do_read(); }); | |
236 | return; | |
237 | } | |
238 | else if (next_read >= -kResyncTolerance) | |
239 | { | |
240 | LOG(INFO, LOG_TAG) << "next read < 0 (" << getName() << "): " << std::chrono::duration_cast<std::chrono::microseconds>(next_read).count() / 1000. | |
241 | << " ms\n "; | |
242 | do_read(); | |
243 | } | |
244 | else | |
245 | { | |
246 | // reading chunk_ms_ took longer than chunk_ms_ | |
247 | resync(-next_read); | |
248 | first_ = true; | |
249 | wait(read_timer_, nextTick_ - currentTick, [this] { do_read(); }); | |
250 | } | |
251 | ||
252 | lastException_ = ""; | |
253 | } | |
254 | catch (const std::exception& e) | |
255 | { | |
256 | if (lastException_ != e.what()) | |
257 | { | |
258 | LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << std::endl; | |
259 | lastException_ = e.what(); | |
260 | } | |
261 | first_ = true; | |
262 | uninitAlsa(); | |
263 | initAlsa(); | |
264 | wait(read_timer_, 100ms, [this] { do_read(); }); | |
265 | } | |
266 | } | |
267 | ||
268 | } // namespace streamreader |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef ALSA_STREAM_HPP | |
19 | #define ALSA_STREAM_HPP | |
20 | ||
21 | #include "pcm_stream.hpp" | |
22 | #include <alsa/asoundlib.h> | |
23 | #include <boost/asio.hpp> | |
24 | ||
25 | namespace streamreader | |
26 | { | |
27 | ||
28 | ||
29 | /// Reads and decodes PCM data from an alsa audio device device | |
30 | /** | |
31 | * Reads PCM from an alsa audio device device and passes the data to an encoder. | |
32 | * Implements EncoderListener to get the encoded data. | |
33 | * Data is passed to the PcmListener | |
34 | */ | |
35 | class AlsaStream : public PcmStream | |
36 | { | |
37 | public: | |
38 | /// ctor. Encoded PCM data is passed to the PipeListener | |
39 | AlsaStream(PcmListener* pcmListener, boost::asio::io_context& ioc, const StreamUri& uri); | |
40 | ||
41 | void start() override; | |
42 | void stop() override; | |
43 | ||
44 | protected: | |
45 | void do_read(); | |
46 | void initAlsa(); | |
47 | void uninitAlsa(); | |
48 | ||
49 | snd_pcm_t* handle_; | |
50 | std::unique_ptr<msg::PcmChunk> chunk_; | |
51 | bool first_; | |
52 | std::chrono::time_point<std::chrono::steady_clock> nextTick_; | |
53 | boost::asio::steady_timer read_timer_; | |
54 | std::string device_; | |
55 | std::vector<char> silent_chunk_; | |
56 | std::chrono::microseconds silence_; | |
57 | std::string lastException_; | |
58 | }; | |
59 | ||
60 | } // namespace streamreader | |
61 | ||
62 | #endif |
85 | 85 | : PcmStream(pcmListener, ioc, uri), read_timer_(ioc), state_timer_(ioc) |
86 | 86 | { |
87 | 87 | chunk_ = std::make_unique<msg::PcmChunk>(sampleFormat_, chunk_ms_); |
88 | LOG(DEBUG) << "Chunk duration: " << chunk_->durationMs() << " ms, frames: " << chunk_->getFrameCount() << ", size: " << chunk_->payloadSize << "\n"; | |
88 | LOG(DEBUG, "AsioStream") << "Chunk duration: " << chunk_->durationMs() << " ms, frames: " << chunk_->getFrameCount() << ", size: " << chunk_->payloadSize | |
89 | << "\n"; | |
89 | 90 | |
90 | 91 | bytes_read_ = 0; |
91 | 92 | buffer_ms_ = 50; |
118 | 119 | template <typename ReadStream> |
119 | 120 | void AsioStream<ReadStream>::start() |
120 | 121 | { |
121 | encoder_->init(this, sampleFormat_); | |
122 | active_ = true; | |
122 | PcmStream::start(); | |
123 | 123 | check_state(); |
124 | 124 | connect(); |
125 | 125 | } |
196 | 196 | nextTick_ = std::chrono::steady_clock::now(); |
197 | 197 | } |
198 | 198 | |
199 | encoder_->encode(chunk_.get()); | |
199 | chunkRead(*chunk_); | |
200 | 200 | nextTick_ += chunk_->duration<std::chrono::nanoseconds>(); |
201 | 201 | auto currentTick = std::chrono::steady_clock::now(); |
202 | 202 | |
219 | 219 | // Read took longer, wait for the buffer to fill up |
220 | 220 | else |
221 | 221 | { |
222 | pcmListener_->onResync(this, std::chrono::duration_cast<std::chrono::milliseconds>(currentTick - nextTick_).count()); | |
222 | resync(std::chrono::duration_cast<std::chrono::nanoseconds>(currentTick - nextTick_)); | |
223 | 223 | nextTick_ = currentTick + std::chrono::milliseconds(buffer_ms_); |
224 | 224 | first_ = true; |
225 | 225 | do_read(); |
38 | 38 | string username = uri_.getQuery("username", ""); |
39 | 39 | string password = uri_.getQuery("password", ""); |
40 | 40 | string cache = uri_.getQuery("cache", ""); |
41 | bool disable_audio_cache = (uri_.getQuery("disable_audio_cache", "false") == "true"); | |
41 | 42 | string volume = uri_.getQuery("volume", "100"); |
42 | 43 | string bitrate = uri_.getQuery("bitrate", "320"); |
43 | 44 | string devicename = uri_.getQuery("devicename", "Snapcast"); |
49 | 50 | if (username.empty() != password.empty()) |
50 | 51 | throw SnapException("missing parameter \"username\" or \"password\" (must provide both, or neither)"); |
51 | 52 | |
52 | params_ = "--name \"" + devicename + "\""; | |
53 | if (!params_.empty()) | |
54 | params_ += " "; | |
55 | params_ += "--name \"" + devicename + "\""; | |
53 | 56 | if (!username.empty() && !password.empty()) |
54 | 57 | params_ += " --username \"" + username + "\" --password \"" + password + "\""; |
55 | 58 | params_ += " --bitrate " + bitrate + " --backend pipe"; |
56 | 59 | if (!cache.empty()) |
57 | 60 | params_ += " --cache \"" + cache + "\""; |
61 | if (disable_audio_cache) | |
62 | params_ += " --disable-audio-cache"; | |
58 | 63 | if (!volume.empty()) |
59 | 64 | params_ += " --initial-volume " + volume; |
60 | 65 | if (!onevent.empty()) |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #include "meta_stream.hpp" | |
19 | #include "common/aixlog.hpp" | |
20 | #include "common/snap_exception.hpp" | |
21 | #include "common/utils/string_utils.hpp" | |
22 | #include "encoder/encoder_factory.hpp" | |
23 | ||
24 | ||
25 | using namespace std; | |
26 | ||
27 | namespace streamreader | |
28 | { | |
29 | ||
30 | static constexpr auto LOG_TAG = "MetaStream"; | |
31 | // static constexpr auto kResyncTolerance = 50ms; | |
32 | ||
33 | ||
34 | MetaStream::MetaStream(PcmListener* pcmListener, std::vector<std::shared_ptr<PcmStream>> streams, boost::asio::io_context& ioc, const StreamUri& uri) | |
35 | : PcmStream(pcmListener, ioc, uri), first_read_(true) | |
36 | { | |
37 | auto path_components = utils::string::split(uri.path, '/'); | |
38 | for (const auto& component : path_components) | |
39 | { | |
40 | if (component.empty()) | |
41 | continue; | |
42 | ||
43 | bool found = false; | |
44 | for (const auto stream : streams) | |
45 | { | |
46 | if (stream->getName() == component) | |
47 | { | |
48 | streams_.push_back(stream); | |
49 | stream->addListener(this); | |
50 | found = true; | |
51 | break; | |
52 | } | |
53 | } | |
54 | if (!found) | |
55 | throw SnapException("Unknown stream: \"" + component + "\""); | |
56 | } | |
57 | ||
58 | if (!streams_.empty()) | |
59 | { | |
60 | active_stream_ = streams_.front(); | |
61 | resampler_ = make_unique<Resampler>(active_stream_->getSampleFormat(), sampleFormat_); | |
62 | } | |
63 | } | |
64 | ||
65 | ||
66 | MetaStream::~MetaStream() | |
67 | { | |
68 | stop(); | |
69 | } | |
70 | ||
71 | ||
72 | void MetaStream::start() | |
73 | { | |
74 | LOG(DEBUG, LOG_TAG) << "Start, sampleformat: " << sampleFormat_.toString() << "\n"; | |
75 | PcmStream::start(); | |
76 | } | |
77 | ||
78 | ||
79 | void MetaStream::stop() | |
80 | { | |
81 | active_ = false; | |
82 | } | |
83 | ||
84 | ||
85 | void MetaStream::onMetaChanged(const PcmStream* pcmStream) | |
86 | { | |
87 | LOG(DEBUG, LOG_TAG) << "onMetaChanged: " << pcmStream->getName() << "\n"; | |
88 | std::lock_guard<std::mutex> lock(mutex_); | |
89 | if (pcmStream != active_stream_.get()) | |
90 | return; | |
91 | } | |
92 | ||
93 | ||
94 | void MetaStream::onStateChanged(const PcmStream* pcmStream, ReaderState state) | |
95 | { | |
96 | LOG(DEBUG, LOG_TAG) << "onStateChanged: " << pcmStream->getName() << ", state: " << static_cast<int>(state) << "\n"; | |
97 | std::lock_guard<std::mutex> lock(mutex_); | |
98 | for (const auto stream : streams_) | |
99 | { | |
100 | if (stream->getState() == ReaderState::kPlaying) | |
101 | { | |
102 | if (state_ != ReaderState::kPlaying) // || (active_stream_ != stream)) | |
103 | first_read_ = true; | |
104 | ||
105 | if (active_stream_ != stream) | |
106 | { | |
107 | LOG(INFO, LOG_TAG) << "Stream: " << name_ << ", switching active stream: " << (active_stream_ ? active_stream_->getName() : "<null>") << " => " | |
108 | << stream->getName() << "\n"; | |
109 | active_stream_ = stream; | |
110 | resampler_ = make_unique<Resampler>(active_stream_->getSampleFormat(), sampleFormat_); | |
111 | } | |
112 | ||
113 | setState(ReaderState::kPlaying); | |
114 | return; | |
115 | } | |
116 | } | |
117 | active_stream_ = nullptr; | |
118 | setState(ReaderState::kIdle); | |
119 | } | |
120 | ||
121 | ||
122 | void MetaStream::onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) | |
123 | { | |
124 | // LOG(TRACE, LOG_TAG) << "onChunkRead: " << pcmStream->getName() << ", duration: " << chunk.durationMs() << "\n"; | |
125 | std::lock_guard<std::mutex> lock(mutex_); | |
126 | if (pcmStream != active_stream_.get()) | |
127 | return; | |
128 | // active_stream_->sampleFormat_ | |
129 | // sampleFormat_ | |
130 | ||
131 | if (first_read_) | |
132 | { | |
133 | first_read_ = false; | |
134 | LOG(INFO, LOG_TAG) << "first read, updating timestamp\n"; | |
135 | tvEncodedChunk_ = std::chrono::steady_clock::now() - chunk.duration<std::chrono::nanoseconds>(); | |
136 | next_tick_ = std::chrono::steady_clock::now(); | |
137 | } | |
138 | ||
139 | ||
140 | next_tick_ += chunk.duration<std::chrono::nanoseconds>(); | |
141 | auto currentTick = std::chrono::steady_clock::now(); | |
142 | auto next_read = next_tick_ - currentTick; | |
143 | ||
144 | // Read took longer, wait for the buffer to fill up | |
145 | if (next_read < 0ms) | |
146 | { | |
147 | // if (next_read >= -kResyncTolerance) | |
148 | // { | |
149 | // LOG(INFO, LOG_TAG) << "next read < 0 (" << getName() << "): " << std::chrono::duration_cast<std::chrono::microseconds>(next_read).count() / 1000. | |
150 | // << " ms\n"; | |
151 | // } | |
152 | // else | |
153 | // { | |
154 | resync(-next_read); | |
155 | first_read_ = true; | |
156 | // } | |
157 | } | |
158 | ||
159 | if (resampler_ && resampler_->resamplingNeeded()) | |
160 | { | |
161 | auto resampled_chunk = resampler_->resample(chunk); | |
162 | if (resampled_chunk) | |
163 | chunkRead(*resampled_chunk); | |
164 | } | |
165 | else | |
166 | chunkRead(chunk); | |
167 | } | |
168 | ||
169 | ||
170 | void MetaStream::onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) | |
171 | { | |
172 | std::ignore = pcmStream; | |
173 | std::ignore = chunk; | |
174 | std::ignore = duration; | |
175 | // LOG(TRACE, LOG_TAG) << "onChunkEncoded: " << pcmStream->getName() << ", duration: " << duration << "\n"; | |
176 | // chunkEncoded(*encoder_, chunk, duration); | |
177 | } | |
178 | ||
179 | ||
180 | void MetaStream::onResync(const PcmStream* pcmStream, double ms) | |
181 | { | |
182 | LOG(DEBUG, LOG_TAG) << "onResync: " << pcmStream->getName() << ", duration: " << ms << " ms\n"; | |
183 | std::lock_guard<std::mutex> lock(mutex_); | |
184 | if (pcmStream != active_stream_.get()) | |
185 | return; | |
186 | resync(std::chrono::nanoseconds(static_cast<int64_t>(ms * 1000000))); | |
187 | } | |
188 | ||
189 | ||
190 | } // namespace streamreader |
0 | /*** | |
1 | This file is part of snapcast | |
2 | Copyright (C) 2014-2020 Johannes Pohl | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | ***/ | |
17 | ||
18 | #ifndef META_STREAM_HPP | |
19 | #define META_STREAM_HPP | |
20 | ||
21 | #include "posix_stream.hpp" | |
22 | #include "resampler.hpp" | |
23 | #include <memory> | |
24 | ||
25 | namespace streamreader | |
26 | { | |
27 | ||
28 | ||
29 | /// Reads and decodes PCM data | |
30 | /** | |
31 | * Reads PCM and passes the data to an encoder. | |
32 | * Implements EncoderListener to get the encoded data. | |
33 | * Data is passed to the PcmListener | |
34 | */ | |
35 | class MetaStream : public PcmStream, public PcmListener | |
36 | { | |
37 | public: | |
38 | /// ctor. Encoded PCM data is passed to the PcmListener | |
39 | MetaStream(PcmListener* pcmListener, std::vector<std::shared_ptr<PcmStream>> streams, boost::asio::io_context& ioc, const StreamUri& uri); | |
40 | virtual ~MetaStream(); | |
41 | ||
42 | void start() override; | |
43 | void stop() override; | |
44 | ||
45 | protected: | |
46 | /// Implementation of PcmListener | |
47 | void onMetaChanged(const PcmStream* pcmStream) override; | |
48 | void onStateChanged(const PcmStream* pcmStream, ReaderState state) override; | |
49 | void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override; | |
50 | void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) override; | |
51 | void onResync(const PcmStream* pcmStream, double ms) override; | |
52 | ||
53 | protected: | |
54 | std::vector<std::shared_ptr<PcmStream>> streams_; | |
55 | std::shared_ptr<PcmStream> active_stream_; | |
56 | std::mutex mutex_; | |
57 | std::unique_ptr<Resampler> resampler_; | |
58 | bool first_read_; | |
59 | std::chrono::time_point<std::chrono::steady_clock> next_tick_; | |
60 | }; | |
61 | ||
62 | } // namespace streamreader | |
63 | ||
64 | #endif |
35 | 35 | |
36 | 36 | |
37 | 37 | PcmStream::PcmStream(PcmListener* pcmListener, boost::asio::io_context& ioc, const StreamUri& uri) |
38 | : active_(false), pcmListener_(pcmListener), uri_(uri), chunk_ms_(20), state_(ReaderState::kIdle), ioc_(ioc) | |
38 | : active_(false), pcmListeners_{pcmListener}, uri_(uri), chunk_ms_(20), state_(ReaderState::kIdle), ioc_(ioc) | |
39 | 39 | { |
40 | 40 | encoder::EncoderFactory encoderFactory; |
41 | 41 | if (uri_.query.find(kUriCodec) == uri_.query.end()) |
49 | 49 | if (uri_.query.find(kUriSampleFormat) == uri_.query.end()) |
50 | 50 | throw SnapException("Stream URI must have a sampleformat"); |
51 | 51 | sampleFormat_ = SampleFormat(uri_.query[kUriSampleFormat]); |
52 | LOG(INFO, LOG_TAG) << "PcmStream sampleFormat: " << sampleFormat_.getFormat() << "\n"; | |
52 | LOG(INFO, LOG_TAG) << "PcmStream: " << name_ << ", sampleFormat: " << sampleFormat_.toString() << "\n"; | |
53 | 53 | |
54 | 54 | if (uri_.query.find(kUriChunkMs) != uri_.query.end()) |
55 | 55 | chunk_ms_ = cpt::stoul(uri_.query[kUriChunkMs]); |
94 | 94 | } |
95 | 95 | |
96 | 96 | |
97 | std::string PcmStream::getCodec() const | |
98 | { | |
99 | return encoder_->name(); | |
100 | } | |
101 | ||
102 | ||
97 | 103 | void PcmStream::start() |
98 | 104 | { |
99 | LOG(DEBUG, LOG_TAG) << "Start, sampleformat: " << sampleFormat_.getFormat() << "\n"; | |
100 | encoder_->init(this, sampleFormat_); | |
105 | LOG(DEBUG, LOG_TAG) << "Start: " << name_ << ", sampleformat: " << sampleFormat_.toString() << "\n"; | |
106 | encoder_->init([this](const encoder::Encoder& encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration) { chunkEncoded(encoder, chunk, duration); }, | |
107 | sampleFormat_); | |
101 | 108 | active_ = true; |
102 | 109 | } |
103 | 110 | |
114 | 121 | } |
115 | 122 | |
116 | 123 | |
117 | void PcmStream::setState(const ReaderState& newState) | |
124 | void PcmStream::setState(ReaderState newState) | |
118 | 125 | { |
119 | 126 | if (newState != state_) |
120 | 127 | { |
121 | LOG(DEBUG, LOG_TAG) << "State changed: " << static_cast<int>(state_) << " => " << static_cast<int>(newState) << "\n"; | |
128 | LOG(INFO, LOG_TAG) << "State changed: " << name_ << ", state: " << static_cast<int>(state_) << " => " << static_cast<int>(newState) << "\n"; | |
122 | 129 | state_ = newState; |
123 | if (pcmListener_) | |
124 | pcmListener_->onStateChanged(this, newState); | |
125 | } | |
126 | } | |
127 | ||
128 | ||
129 | void PcmStream::onChunkEncoded(const encoder::Encoder* /*encoder*/, std::shared_ptr<msg::PcmChunk> chunk, double duration) | |
130 | { | |
131 | // LOG(TRACE, LOG_TAG) << "onChunkEncoded: " << duration << " ms, compression ratio: " << 100 - ceil(100 * (chunk->durationMs() / duration)) << "%\n"; | |
130 | for (auto* listener : pcmListeners_) | |
131 | { | |
132 | if (listener) | |
133 | listener->onStateChanged(this, newState); | |
134 | } | |
135 | } | |
136 | } | |
137 | ||
138 | ||
139 | void PcmStream::chunkEncoded(const encoder::Encoder& encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration) | |
140 | { | |
141 | std::ignore = encoder; | |
142 | // LOG(TRACE, LOG_TAG) << "onChunkEncoded: " << getName() << ", duration: " << duration << " ms, compression ratio: " << 100 - ceil(100 * | |
143 | // (chunk->durationMs() / duration)) << "%\n"; | |
132 | 144 | if (duration <= 0) |
133 | 145 | return; |
134 | 146 | |
147 | // absolute start timestamp is the tvEncodedChunk_ | |
135 | 148 | auto microsecs = std::chrono::duration_cast<std::chrono::microseconds>(tvEncodedChunk_.time_since_epoch()).count(); |
136 | 149 | chunk->timestamp.sec = microsecs / 1000000; |
137 | 150 | chunk->timestamp.usec = microsecs % 1000000; |
138 | 151 | |
152 | // update tvEncodedChunk_ to the next chunk start by adding the current chunk duration | |
139 | 153 | tvEncodedChunk_ += std::chrono::nanoseconds(static_cast<std::chrono::nanoseconds::rep>(duration * 1000000)); |
140 | if (pcmListener_) | |
141 | pcmListener_->onChunkRead(this, chunk, duration); | |
154 | for (auto* listener : pcmListeners_) | |
155 | { | |
156 | if (listener) | |
157 | listener->onChunkEncoded(this, chunk, duration); | |
158 | } | |
159 | } | |
160 | ||
161 | ||
162 | void PcmStream::chunkRead(const msg::PcmChunk& chunk) | |
163 | { | |
164 | for (auto* listener : pcmListeners_) | |
165 | { | |
166 | if (listener) | |
167 | listener->onChunkRead(this, chunk); | |
168 | } | |
169 | encoder_->encode(chunk); | |
170 | } | |
171 | ||
172 | ||
173 | void PcmStream::resync(const std::chrono::nanoseconds& duration) | |
174 | { | |
175 | for (auto* listener : pcmListeners_) | |
176 | { | |
177 | if (listener) | |
178 | listener->onResync(this, duration.count() / 1000000.); | |
179 | } | |
142 | 180 | } |
143 | 181 | |
144 | 182 | |
162 | 200 | return j; |
163 | 201 | } |
164 | 202 | |
203 | ||
204 | void PcmStream::addListener(PcmListener* pcmListener) | |
205 | { | |
206 | pcmListeners_.push_back(pcmListener); | |
207 | } | |
208 | ||
209 | ||
165 | 210 | std::shared_ptr<msg::StreamTags> PcmStream::getMeta() const |
166 | 211 | { |
167 | 212 | return meta_; |
168 | 213 | } |
214 | ||
169 | 215 | |
170 | 216 | void PcmStream::setMeta(const json& jtag) |
171 | 217 | { |
172 | 218 | meta_.reset(new msg::StreamTags(jtag)); |
173 | 219 | meta_->msg["STREAM"] = name_; |
174 | LOG(INFO, LOG_TAG) << "metadata=" << meta_->msg.dump(4) << "\n"; | |
220 | LOG(INFO, LOG_TAG) << "Stream: " << name_ << ", metadata=" << meta_->msg.dump(4) << "\n"; | |
175 | 221 | |
176 | 222 | // Trigger a stream update |
177 | if (pcmListener_) | |
178 | pcmListener_->onMetaChanged(this); | |
223 | for (auto* listener : pcmListeners_) | |
224 | { | |
225 | if (listener) | |
226 | listener->onMetaChanged(this); | |
227 | } | |
179 | 228 | } |
180 | 229 | |
181 | 230 | } // namespace streamreader |
29 | 29 | #include <condition_variable> |
30 | 30 | #include <map> |
31 | 31 | #include <string> |
32 | #include <vector> | |
32 | 33 | |
33 | 34 | |
34 | 35 | namespace streamreader |
59 | 60 | { |
60 | 61 | public: |
61 | 62 | virtual void onMetaChanged(const PcmStream* pcmStream) = 0; |
62 | virtual void onStateChanged(const PcmStream* pcmStream, const ReaderState& state) = 0; | |
63 | virtual void onChunkRead(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) = 0; | |
63 | virtual void onStateChanged(const PcmStream* pcmStream, ReaderState state) = 0; | |
64 | virtual void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) = 0; | |
65 | virtual void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) = 0; | |
64 | 66 | virtual void onResync(const PcmStream* pcmStream, double ms) = 0; |
65 | 67 | }; |
66 | 68 | |
71 | 73 | * Implements EncoderListener to get the encoded data. |
72 | 74 | * Data is passed to the PcmListener |
73 | 75 | */ |
74 | class PcmStream : public encoder::EncoderListener | |
76 | class PcmStream | |
75 | 77 | { |
76 | 78 | public: |
77 | 79 | /// ctor. Encoded PCM data is passed to the PcmListener |
81 | 83 | virtual void start(); |
82 | 84 | virtual void stop(); |
83 | 85 | |
84 | /// Implementation of EncoderListener::onChunkEncoded | |
85 | void onChunkEncoded(const encoder::Encoder* encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration) override; | |
86 | 86 | virtual std::shared_ptr<msg::CodecHeader> getHeader(); |
87 | 87 | |
88 | 88 | virtual const StreamUri& getUri() const; |
89 | 89 | virtual const std::string& getName() const; |
90 | 90 | virtual const std::string& getId() const; |
91 | 91 | virtual const SampleFormat& getSampleFormat() const; |
92 | virtual std::string getCodec() const; | |
92 | 93 | |
93 | 94 | std::shared_ptr<msg::StreamTags> getMeta() const; |
94 | 95 | void setMeta(const json& j); |
96 | 97 | virtual ReaderState getState() const; |
97 | 98 | virtual json toJson() const; |
98 | 99 | |
100 | void addListener(PcmListener* pcmListener); | |
99 | 101 | |
100 | 102 | protected: |
101 | 103 | std::atomic<bool> active_; |
102 | 104 | |
103 | void setState(const ReaderState& newState); | |
105 | void setState(ReaderState newState); | |
106 | void chunkRead(const msg::PcmChunk& chunk); | |
107 | void resync(const std::chrono::nanoseconds& duration); | |
108 | void chunkEncoded(const encoder::Encoder& encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration); | |
104 | 109 | |
105 | 110 | std::chrono::time_point<std::chrono::steady_clock> tvEncodedChunk_; |
106 | PcmListener* pcmListener_; | |
111 | std::vector<PcmListener*> pcmListeners_; | |
107 | 112 | StreamUri uri_; |
108 | 113 | SampleFormat sampleFormat_; |
109 | 114 | size_t chunk_ms_; |
54 | 54 | |
55 | 55 | void PipeStream::do_connect() |
56 | 56 | { |
57 | LOG(DEBUG, LOG_TAG) << "connect\n"; | |
58 | 57 | int fd = open(uri_.path.c_str(), O_RDONLY | O_NONBLOCK); |
58 | int pipe_size = -1; | |
59 | #if !defined(MACOS) | |
60 | pipe_size = fcntl(fd, F_GETPIPE_SZ); | |
61 | #endif | |
62 | LOG(TRACE, LOG_TAG) << "Stream: " << name_ << ", connect to pipe: " << uri_.path << ", fd: " << fd << ", pipe size: " << pipe_size << "\n"; | |
59 | 63 | stream_ = std::make_unique<boost::asio::posix::stream_descriptor>(ioc_, fd); |
60 | 64 | on_connect(); |
61 | 65 | } |
62 | } | |
66 | ||
67 | } // namespace streamreader |
81 | 81 | |
82 | 82 | if (first_) |
83 | 83 | { |
84 | LOG(DEBUG, LOG_TAG) << "First read, initializing nextTick to now\n"; | |
84 | LOG(TRACE, LOG_TAG) << "First read, initializing nextTick to now\n"; | |
85 | 85 | nextTick_ = std::chrono::steady_clock::now(); |
86 | 86 | } |
87 | 87 | |
104 | 104 | } |
105 | 105 | else |
106 | 106 | { |
107 | // LOG(DEBUG) << "count: " << count << "\n"; | |
107 | // LOG(DEBUG, LOG_TAG) << "count: " << count << "\n"; | |
108 | 108 | len += count; |
109 | 109 | bytes_read_ += len; |
110 | 110 | idle_bytes_ = 0; |
122 | 122 | if ((idle_bytes_ == 0) || (idle_bytes_ <= max_idle_bytes_)) |
123 | 123 | { |
124 | 124 | // the encoder will update the tvEncodedChunk when a chunk is encoded |
125 | encoder_->encode(chunk_.get()); | |
125 | chunkRead(*chunk_); | |
126 | 126 | } |
127 | 127 | else |
128 | 128 | { |
142 | 142 | } |
143 | 143 | else if (next_read >= -kResyncTolerance) |
144 | 144 | { |
145 | LOG(INFO) << "next read < 0 (" << getName() << "): " << std::chrono::duration_cast<std::chrono::microseconds>(next_read).count() / 1000. << " ms\n"; | |
145 | LOG(INFO, LOG_TAG) << "next read < 0 (" << getName() << "): " << std::chrono::duration_cast<std::chrono::microseconds>(next_read).count() / 1000. | |
146 | << " ms\n"; | |
146 | 147 | do_read(); |
147 | 148 | } |
148 | 149 | else |
149 | 150 | { |
150 | 151 | // reading chunk_ms_ took longer than chunk_ms_ |
151 | pcmListener_->onResync(this, std::chrono::duration_cast<std::chrono::milliseconds>(-next_read).count()); | |
152 | resync(-next_read); | |
152 | 153 | first_ = true; |
153 | 154 | wait(read_timer_, duration + kResyncTolerance, [this] { do_read(); }); |
154 | 155 | } |
43 | 43 | } |
44 | 44 | |
45 | 45 | |
46 | bool ProcessStream::fileExists(const std::string& filename) | |
46 | bool ProcessStream::fileExists(const std::string& filename) const | |
47 | 47 | { |
48 | 48 | struct stat buffer; |
49 | 49 | return (stat(filename.c_str(), &buffer) == 0); |
50 | 50 | } |
51 | 51 | |
52 | 52 | |
53 | std::string ProcessStream::findExe(const std::string& filename) | |
53 | std::string ProcessStream::findExe(const std::string& filename) const | |
54 | 54 | { |
55 | 55 | /// check if filename exists |
56 | 56 | if (fileExists(filename)) |
18 | 18 | #ifndef PROCESS_STREAM_HPP |
19 | 19 | #define PROCESS_STREAM_HPP |
20 | 20 | |
21 | #pragma GCC diagnostic push | |
22 | #pragma GCC diagnostic ignored "-Wunused-result" | |
23 | #pragma GCC diagnostic ignored "-Wunused-parameter" | |
24 | #pragma GCC diagnostic ignored "-Wmissing-braces" | |
21 | 25 | #include <boost/process.hpp> |
26 | #pragma GCC diagnostic pop | |
22 | 27 | #include <memory> |
23 | 28 | #include <string> |
29 | #include <vector> | |
24 | 30 | |
25 | 31 | #include "posix_stream.hpp" |
26 | 32 | #include "watchdog.hpp" |
65 | 71 | virtual void onStderrMsg(const std::string& line); |
66 | 72 | virtual void initExeAndPath(const std::string& filename); |
67 | 73 | |
68 | bool fileExists(const std::string& filename); | |
69 | std::string findExe(const std::string& filename); | |
74 | bool fileExists(const std::string& filename) const; | |
75 | std::string findExe(const std::string& filename) const; | |
70 | 76 | |
71 | 77 | size_t wd_timeout_sec_; |
72 | 78 | std::unique_ptr<Watchdog> watchdog_; |
17 | 17 | |
18 | 18 | #include "stream_manager.hpp" |
19 | 19 | #include "airplay_stream.hpp" |
20 | #ifdef HAS_ALSA | |
21 | #include "alsa_stream.hpp" | |
22 | #endif | |
20 | 23 | #include "common/aixlog.hpp" |
21 | 24 | #include "common/snap_exception.hpp" |
22 | 25 | #include "common/str_compat.hpp" |
23 | 26 | #include "common/utils.hpp" |
24 | 27 | #include "file_stream.hpp" |
25 | 28 | #include "librespot_stream.hpp" |
29 | #include "meta_stream.hpp" | |
26 | 30 | #include "pipe_stream.hpp" |
27 | 31 | #include "process_stream.hpp" |
28 | 32 | #include "tcp_stream.hpp" |
43 | 47 | PcmStreamPtr StreamManager::addStream(const std::string& uri) |
44 | 48 | { |
45 | 49 | StreamUri streamUri(uri); |
46 | ||
50 | return addStream(streamUri); | |
51 | } | |
52 | ||
53 | ||
54 | PcmStreamPtr StreamManager::addStream(StreamUri& streamUri) | |
55 | { | |
47 | 56 | if (streamUri.query.find(kUriSampleFormat) == streamUri.query.end()) |
48 | 57 | streamUri.query[kUriSampleFormat] = sampleFormat_; |
49 | 58 | |
72 | 81 | { |
73 | 82 | stream = make_shared<ProcessStream>(pcmListener_, ioc_, streamUri); |
74 | 83 | } |
84 | #ifdef HAS_ALSA | |
85 | else if (streamUri.scheme == "alsa") | |
86 | { | |
87 | stream = make_shared<AlsaStream>(pcmListener_, ioc_, streamUri); | |
88 | } | |
89 | #endif | |
75 | 90 | else if ((streamUri.scheme == "spotify") || (streamUri.scheme == "librespot")) |
76 | 91 | { |
77 | 92 | // Overwrite sample format here instead of inside the constructor, to make sure |
91 | 106 | else if (streamUri.scheme == "tcp") |
92 | 107 | { |
93 | 108 | stream = make_shared<TcpStream>(pcmListener_, ioc_, streamUri); |
109 | } | |
110 | else if (streamUri.scheme == "meta") | |
111 | { | |
112 | stream = make_shared<MetaStream>(pcmListener_, streams_, ioc_, streamUri); | |
94 | 113 | } |
95 | 114 | else |
96 | 115 | { |
133 | 152 | if (streams_.empty()) |
134 | 153 | return nullptr; |
135 | 154 | |
136 | return streams_.front(); | |
155 | for (const auto stream : streams_) | |
156 | { | |
157 | if (stream->getCodec() != "null") | |
158 | return stream; | |
159 | } | |
160 | return nullptr; | |
137 | 161 | } |
138 | 162 | |
139 | 163 | |
150 | 174 | |
151 | 175 | void StreamManager::start() |
152 | 176 | { |
153 | for (const auto& stream : streams_) | |
154 | stream->start(); | |
177 | // Start meta streams first | |
178 | for (const auto& stream : streams_) | |
179 | if (stream->getUri().scheme == "meta") | |
180 | stream->start(); | |
181 | // Start normal streams second | |
182 | for (const auto& stream : streams_) | |
183 | if (stream->getUri().scheme != "meta") | |
184 | stream->start(); | |
155 | 185 | } |
156 | 186 | |
157 | 187 | |
158 | 188 | void StreamManager::stop() |
159 | 189 | { |
160 | for (const auto& stream : streams_) | |
161 | if (stream) | |
190 | // Stop normal streams first | |
191 | for (const auto& stream : streams_) | |
192 | if (stream && (stream->getUri().scheme != "meta")) | |
193 | stream->stop(); | |
194 | // Stop meta streams second | |
195 | for (const auto& stream : streams_) | |
196 | if (stream && (stream->getUri().scheme == "meta")) | |
162 | 197 | stream->stop(); |
163 | 198 | } |
164 | 199 | |
167 | 202 | { |
168 | 203 | json result = json::array(); |
169 | 204 | for (auto stream : streams_) |
170 | result.push_back(stream->toJson()); | |
205 | if (stream->getCodec() != "null") | |
206 | result.push_back(stream->toJson()); | |
171 | 207 | return result; |
172 | 208 | } |
173 | 209 |
27 | 27 | namespace streamreader |
28 | 28 | { |
29 | 29 | |
30 | typedef std::shared_ptr<PcmStream> PcmStreamPtr; | |
30 | using PcmStreamPtr = std::shared_ptr<PcmStream>; | |
31 | 31 | |
32 | 32 | class StreamManager |
33 | 33 | { |
36 | 36 | size_t defaultChunkBufferMs = 20); |
37 | 37 | |
38 | 38 | PcmStreamPtr addStream(const std::string& uri); |
39 | PcmStreamPtr addStream(StreamUri& streamUri); | |
39 | 40 | void removeStream(const std::string& name); |
40 | 41 | void start(); |
41 | 42 | void stop(); |
34 | 34 | namespace streamreader |
35 | 35 | { |
36 | 36 | |
37 | static constexpr auto LOG_TAG = "TcpStream"; | |
38 | ||
37 | 39 | TcpStream::TcpStream(PcmListener* pcmListener, boost::asio::io_context& ioc, const StreamUri& uri) |
38 | 40 | : AsioStream<tcp::socket>(pcmListener, ioc, uri), reconnect_timer_(ioc) |
39 | 41 | { |
56 | 58 | |
57 | 59 | port_ = cpt::stoi(uri_.getQuery("port", cpt::to_string(port_)), port_); |
58 | 60 | |
59 | LOG(INFO) << "TcpStream host: " << host_ << ", port: " << port_ << ", is server: " << is_server_ << "\n"; | |
61 | LOG(INFO, LOG_TAG) << "TcpStream host: " << host_ << ", port: " << port_ << ", is server: " << is_server_ << "\n"; | |
60 | 62 | if (is_server_) |
61 | 63 | acceptor_ = make_unique<tcp::acceptor>(ioc_, tcp::endpoint(boost::asio::ip::address::from_string(host_), port_)); |
62 | 64 | } |
72 | 74 | acceptor_->async_accept([this](boost::system::error_code ec, tcp::socket socket) { |
73 | 75 | if (!ec) |
74 | 76 | { |
75 | LOG(DEBUG) << "New client connection\n"; | |
77 | LOG(DEBUG, LOG_TAG) << "New client connection\n"; | |
76 | 78 | stream_ = make_unique<tcp::socket>(move(socket)); |
77 | 79 | on_connect(); |
78 | 80 | } |
79 | 81 | else |
80 | 82 | { |
81 | LOG(ERROR) << "Accept failed: " << ec.message() << "\n"; | |
83 | LOG(ERROR, LOG_TAG) << "Accept failed: " << ec.message() << "\n"; | |
82 | 84 | } |
83 | 85 | }); |
84 | 86 | } |
89 | 91 | stream_->async_connect(endpoint, [this](const boost::system::error_code& ec) { |
90 | 92 | if (!ec) |
91 | 93 | { |
92 | LOG(DEBUG) << "Connected\n"; | |
94 | LOG(DEBUG, LOG_TAG) << "Connected\n"; | |
93 | 95 | on_connect(); |
94 | 96 | } |
95 | 97 | else |
96 | 98 | { |
97 | LOG(DEBUG) << "Connect failed: " << ec.message() << "\n"; | |
99 | LOG(DEBUG, LOG_TAG) << "Connect failed: " << ec.message() << "\n"; | |
98 | 100 | wait(reconnect_timer_, 1s, [this] { connect(); }); |
99 | 101 | } |
100 | 102 | }); |