mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce199ee4b | |||
| cb86bd7602 | |||
| 77598e10e9 | |||
| e7bfe276c3 | |||
| f510223d2d | |||
| 2d3be10c8f | |||
| 459c9f688d | |||
| 6d3c50264a | |||
| 9f21edee80 | |||
| a7eb0eda72 | |||
| 9687f90b54 | |||
| ccf6e6c886 | |||
| 5aac4d4ed0 | |||
| ccff7be499 | |||
| 99fc62e505 | |||
| dc819f3a2c | |||
| 69dd374e80 | |||
| 36caf3a14c | |||
| d145a7758e | |||
| fc0b8fbcab | |||
| 5a51c2c713 | |||
| fab1dbf863 | |||
| 8a0810ffb4 | |||
| 6e34dc8160 | |||
| 2aa88d26d4 | |||
|
22fe0fff74
|
|||
| 12bebdde06 | |||
| 50c886fd6f | |||
|
65413c954e
|
|||
|
059900e85e
|
|||
| dea9d9b5a4 | |||
| d191404f2d | |||
| ffffe268ab | |||
| 81ed6db9d1 | |||
| 6878867e64 | |||
| f53f724e1c | |||
| 9bcef81b22 | |||
| 693766134b | |||
| 71fbc717f6 | |||
| d33c7042b6 | |||
| 61c17d2e24 | |||
| e4abaf8814 | |||
| c17fc0f6ac | |||
| 16491f6321 | |||
| d8244e0139 | |||
| 60da17f7cc | |||
| 8cc115cade | |||
| e07ef0ebf1 | |||
| 6456f13bde | |||
| a48159d302 | |||
| 2871db9a96 | |||
| 42ad24cce1 | |||
| eb747ba1ae | |||
| 6bd2bfc01c | |||
| b5751c2918 | |||
| 8c050ad297 | |||
| e2100aedc4 | |||
| aa69f360eb | |||
| ee932db8a9 | |||
| 461a759f0f | |||
| 193a37cf0c | |||
| 65a46bdd0f | |||
| 0afcc4ab68 | |||
| ab35ef5eb4 | |||
| 4ff4ea0df0 | |||
| cef2db92cb | |||
| 72e193cf2c | |||
| 6b3e809d12 | |||
| 2e5a34178b | |||
| 25537f2743 | |||
| 8b7bce5b85 | |||
| 89e8f43add | |||
| da38b411b0 | |||
| 4f2c313bb7 | |||
| c96eb0a805 | |||
| 66c0ecbc1f | |||
| 225609e93c | |||
| 4773f1ca8c | |||
| 129cd3ea23 | |||
| 98352bb656 | |||
| a2f880da0d | |||
| 95f714654d | |||
| e201bc630c | |||
| 36f0ef79fe | |||
| 939ed3d412 | |||
| bddfd45086 | |||
| 48c10e5619 | |||
| 216e6c957f | |||
| 425addffa0 | |||
| 43df830960 | |||
| 6ed41070a5 | |||
| 061c96a89f | |||
| 31d6e5bd77 | |||
| df91396ba1 | |||
| fb927c5a2e | |||
| 7ee03e917b | |||
| 21f65915db | |||
| 33ba1dfa28 | |||
| f60dabbd31 | |||
| f8bc39036a | |||
| f9f39ccfc2 | |||
| a8317f5877 | |||
| 46bbae873e | |||
| ba43ed867a | |||
| 832bacb569 | |||
| b9093883a4 | |||
| 77edfaa867 | |||
| bac0417e00 | |||
| f30920bfeb | |||
| 8e72e7084b | |||
| dcd356aee7 | |||
|
4f188be792
|
|||
|
cd1417b604
|
|||
| ee437fca5a | |||
| e102320e32 | |||
| d79de7904d | |||
| bf93ee17da | |||
| 02c7a3fe22 | |||
| 34b4445c13 | |||
| e1b807d0ef | |||
| d18646de23 | |||
| ccdb899833 | |||
| b197694804 | |||
| b45bb95522 | |||
| cd1c785e43 | |||
| 1e54946939 | |||
| 34e374cf03 | |||
| 9387ceca83 | |||
| c165669db5 | |||
| c50addaacf | |||
| 7e64ef8f2a | |||
| 8c87ba95ff | |||
| 54962a15c1 | |||
| 689d2dc019 | |||
| f3fb730fbc | |||
| 5576fb0d13 | |||
| 99c6720b86 | |||
| 7b20965c1b | |||
| 28844e8456 | |||
| 572eb81927 | |||
| b6812de26b | |||
| 2f1d52e100 | |||
| 06902acd4b | |||
| 2deaed50b8 | |||
| 3ac82595f9 | |||
| 7db42d74ef | |||
| 58851da817 | |||
| 3262458cd3 | |||
| bf3d896016 | |||
| 442ed9d191 | |||
| e23280c013 | |||
| 189bb519eb | |||
| c573784fbb | |||
| 03b56afc92 | |||
| f437017359 | |||
| 34acc46c12 | |||
| d248a78acc | |||
| a4d8dd41fd | |||
| b7a2e91a62 | |||
| d519042917 | |||
| ccbf3605a0 | |||
| fce8dc3208 | |||
| 2384b73908 | |||
| 7cd0eaf9e1 | |||
| 9383a6f6ed | |||
| 624bd6bff7 | |||
| 67f02def43 | |||
| 1a2bc3f65b | |||
| 56d025f150 | |||
| 602811908b | |||
| fc90617e9c | |||
| a44581e5d6 | |||
| eb061bd9de | |||
| 6533991e7d | |||
| c75297fcf6 | |||
| 19b7c8ec06 | |||
| c3ebc9b951 | |||
| 117be0d85e | |||
| 12818e02f8 | |||
| 7ceaf4d5f2 | |||
| 76da684c8d | |||
| 795701a447 | |||
| 5340d696cf | |||
| 0a14123a0b | |||
| 7cd03248c8 | |||
| 4c6fc4d6d5 | |||
| 6996dacdf7 | |||
| 798812d5ef | |||
| 9f4c7382aa | |||
| d34de09673 | |||
| 1ff4f1ef0f | |||
| 9a9be253e3 | |||
| 38e59d7a5e | |||
| 915f0143f7 | |||
| e0bb02b65e | |||
| 0d5011c5be | |||
| 34d899806a | |||
| 928cdba17a | |||
| 17a5c92174 | |||
| 58f99ae846 | |||
| ef9a620ac6 | |||
| 01f71cf50c | |||
| 39966d1c65 | |||
| cbf72c7eba | |||
| 4207455440 | |||
| 9983d13122 | |||
| 1b55e88cd5 | |||
| 2326f1f94b | |||
| 27ea221130 | |||
| 9cd4743d1c | |||
| e75fda168e | |||
| ec021516c7 | |||
| 82e1b458f3 | |||
| fc7a5815ef | |||
| 1cf5faff21 | |||
| 16cb4f5a6f | |||
| 9b22327bb0 | |||
| dba89cc4e4 | |||
| 91d4cb58b8 | |||
| 374ebce9b5 | |||
| 083c5f62cb | |||
| 592a479c30 | |||
| 6439200e95 | |||
| 9cb1471170 | |||
| 21e26e2604 | |||
| 94323e91fa | |||
| bef6941a2b | |||
| 805b6bf163 | |||
| fee454da5d | |||
| a765bcd5d0 | |||
| 7e50c7af40 | |||
| a12d6a4015 | |||
| da1cc91ff1 | |||
| 08235e1aa5 | |||
| 57dbd50092 | |||
| 4a2baaf87d | |||
| 65e748c464 | |||
| 346fe604d9 | |||
| 600af0e527 | |||
| 2dd993257a | |||
| c2f7d6d495 | |||
| 6a8422ad1f | |||
| 8f45f7230e | |||
| ae2e1bcb97 | |||
| 3d07a877f2 | |||
| bf38a286cd | |||
| 8f18f73b33 | |||
| 1a2352009b | |||
| eedf6f9337 | |||
| b9b8f3fc15 | |||
| b46acc88e3 | |||
| b4c3124168 | |||
| 20dabbfd03 | |||
| 003e7a3203 | |||
| c142e9e983 | |||
| 549677ffd4 | |||
| dcc3e52ad1 | |||
| 4b6888049f | |||
| f45892e975 | |||
| 16c511c167 | |||
|
9986149e2c
|
|||
| 31f72b0459 | |||
| e060737de9 | |||
| 28b23a9de1 | |||
| 649f5223b4 | |||
| 1cc674e4ee | |||
| 41e98d5783 | |||
| 6ffcd6f6fa | |||
| 33a15fd17a | |||
| 084543e59b | |||
| b0081e3d7a | |||
| ccc9f2cae5 | |||
| f34bd7c0f1 | |||
| 8cb5eb9384 | |||
| 02a4c43f61 | |||
| c49f6af38b | |||
| 69c0c5a907 | |||
| 4de577eede | |||
| c9dfbcc19f | |||
| 36f3d42dbe | |||
| 49f7fe5f6e | |||
| 28943f1ecb | |||
| 46cefb5712 | |||
| 74985fe331 | |||
| 4861d99cb1 | |||
| d2592839b0 | |||
| bbeb2f661e | |||
| 5f0fb5dcbd | |||
| 6f6cb0fd6b | |||
| 15dc970f53 | |||
| fc40d235d0 | |||
| f304589ea1 | |||
| 22f7de45e8 | |||
| 05da369aad | |||
| 9671f89a48 | |||
| 9567a94dea | |||
| 1ba8619a37 | |||
| 973b472c1f | |||
| 20bf93c344 | |||
| fd834314ba | |||
| a78c0fe9ac | |||
| 9c57d4f887 | |||
| 5d067b1f8b | |||
| 48d0145dc8 | |||
| 0da92c2fa1 | |||
| 4e3efa3814 | |||
| 77085a544e | |||
| 8ffecb4b7c | |||
| c569ff5f34 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: psychotoxic
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -103,14 +103,82 @@ jobs:
|
||||
workspaces: src-tauri
|
||||
- name: install npm dependencies
|
||||
run: npm install
|
||||
- name: write Apple API key (macOS only)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
|
||||
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
# Apple signing + notarization (macOS runner only — ignored on Windows)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
|
||||
# Tauri Updater signing — produces .sig files alongside the update bundles
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
- name: re-sign updater bundle + upload .sig (macOS only)
|
||||
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
|
||||
# done, which invalidates the .sig tauri CLI created (different hash).
|
||||
# We can't stop the repack (it's tied to includeUpdaterJson), so we
|
||||
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
TARGET_ARG='${{ matrix.settings.args }}'
|
||||
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
|
||||
TARGET="aarch64-apple-darwin"
|
||||
ARCH="aarch64"
|
||||
else
|
||||
TARGET="x86_64-apple-darwin"
|
||||
ARCH="x64"
|
||||
fi
|
||||
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::Expected tarball missing: $TARBALL"
|
||||
ls -la "$(dirname "$TARBALL")" || true
|
||||
exit 1
|
||||
fi
|
||||
npx @tauri-apps/cli signer sign "$TARBALL"
|
||||
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
|
||||
gh release upload "app-v${VERSION}" \
|
||||
"Psysonic_${ARCH}.app.tar.gz.sig" \
|
||||
--clobber
|
||||
|
||||
generate-manifest:
|
||||
needs: [create-release, build-macos-windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/generate-update-manifest.js
|
||||
- name: upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
@@ -125,7 +193,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev
|
||||
libasound2-dev squashfs-tools cmake
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
@@ -148,7 +216,8 @@ jobs:
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -156,5 +225,93 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
# Verifies that `nix build .#psysonic` still works against the current source,
|
||||
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
|
||||
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
|
||||
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
|
||||
# without having to compile locally.
|
||||
#
|
||||
# The refreshed lock/hash files are committed back to `main` when they change.
|
||||
verify-nix:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# Full history so we can push the auto-commit back to the default branch.
|
||||
fetch-depth: 0
|
||||
# Checkout main, not the tag — we want to push lock/hash refreshes to
|
||||
# the moving branch, not the immutable tag ref.
|
||||
ref: main
|
||||
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
|
||||
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
|
||||
# server-side). The action watches the nix store during subsequent build
|
||||
# steps and uploads new paths automatically.
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
cat nix/upstream-sources.json
|
||||
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
# The cachix-action daemon writes a post-build-hook into the user
|
||||
# nix.conf, but the Determinate Nix daemon that runs the builds reads
|
||||
# the system nix.conf — so the hook never fires and only a couple of
|
||||
# early prep paths get uploaded. Force an explicit closure push here;
|
||||
# cachix dedupes against anything already in the cache.
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
|
||||
- name: open + auto-merge PR with refreshed lock and hash (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ needs.create-release.outputs.package_version }}"
|
||||
BRANCH="chore/nix-lock-refresh-v${VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated after the v${VERSION} release: refreshes \`flake.lock\` and \`nix/upstream-sources.json\` so the Cachix substituter resolves the latest pin."
|
||||
gh pr merge "$BRANCH" --squash --delete-branch
|
||||
|
||||
@@ -31,6 +31,7 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
@@ -40,3 +41,10 @@ memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
# Third-party clones for local research (not committed)
|
||||
research/
|
||||
|
||||
# Nix build output symlink
|
||||
result
|
||||
result-*
|
||||
|
||||
+497
@@ -5,6 +5,503 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
|
||||
>
|
||||
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
|
||||
>
|
||||
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
|
||||
|
||||
## [1.43.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- **User Management — admin-gated tab in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the active server is Navidrome and the logged-in user is an admin, Settings gets a new "Users" tab. Lists every user with username, display name, email, last-access timestamp and assigned libraries. Add / edit / delete via Navidrome's native REST API (`/api/user`) using a Bearer token obtained from `/auth/login` — the Subsonic API doesn't expose this, so non-Navidrome servers don't get the tab.
|
||||
|
||||
- **User Management — per-user library assignment** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#222](https://github.com/Psychotoxical/psysonic/pull/222))*: Mirrors the Navidrome web client. Non-admin users get a checkbox picker showing every library on the server; the picker is hidden for admins (Navidrome auto-grants them access to all libraries). Inline validation prevents saving a non-admin with zero libraries.
|
||||
|
||||
- **User Management — last-access timestamp per user** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Each row shows when the user was last active, formatted as a localised relative time (`vor 5 Min.`, `2h ago`, etc.) using `Intl.RelativeTimeFormat`. Tooltip carries the absolute timestamp. Users who have never logged in show "Never".
|
||||
|
||||
- **Seekable streaming + instant local playback — first cut** *(by [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh))*: New `RangedHttpSource` + `LocalFileSource` audio backends. Seek operations on remote tracks now issue HTTP `Range` requests instead of restarting the stream from byte 0, and locally cached files start playing instantly without going through the HTTP path at all. WaveformSeek commits the seek on mouseup (not during drag), and progress ticks during a drag are ignored so the playhead doesn't jitter back and forth. **Note:** the underlying seek/buffer behaviour is not fully sorted yet — expect follow-up changes in the next releases as edge cases (slow proxies, partial-content retries, codec-specific quirks) get ironed out.
|
||||
|
||||
- **Mini player — queue-style meta block, action toolbar, vertical volume slider** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The mini's right column gets a richer track-info block matching the queue panel's styling. A dedicated action toolbar (love / queue / context menu) sits below the transport. The horizontal volume slider is replaced by a tall vertical one on the right edge for a more compact footprint.
|
||||
|
||||
- **Settings — compact spacing pass + row hover affordance** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#223](https://github.com/Psychotoxical/psysonic/pull/223))*: Section margins, card padding and divider spacing all tightened — every Settings tab fits more content per viewport. Each toggle row gains a subtle accent-tinted hover background that bleeds to the card edges so the active row is visually obvious.
|
||||
|
||||
- **Floating player bar — toggleable variant** *(by [@kveld9](https://github.com/kveld9), PR [#216](https://github.com/Psychotoxical/psysonic/pull/216))*: Settings → Appearance → "Floating player bar" turns the player bar into a floating, rounded panel that sits above the page content with a margin around all four edges. Off by default. Solid background, works with every theme.
|
||||
|
||||
- **Floating player bar — liquid-glass look on macOS and Windows** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the floating bar is enabled, macOS and Windows users get a gentler glass-effect background (subtle blur + tint) on top of @kveld9's solid variant. Linux keeps the solid look — WebKitGTK's `backdrop-filter` cost is too high for an always-visible panel. A new `data-platform` attribute on `<html>` is the generic platform-gate that other CSS can hook into.
|
||||
|
||||
- **NVIDIA proprietary driver — DMA-BUF auto-disabled on Linux** *(by [@kveld9](https://github.com/kveld9), PR [#217](https://github.com/Psychotoxical/psysonic/pull/217), refactored by [@Psychotoxical](https://github.com/Psychotoxical))*: Detects the NVIDIA proprietary driver at startup and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` for the WebKitGTK process, avoiding rendering glitches that show up specifically on that combo. Confirmed via blind A/B testing — only the proprietary driver is targeted; Nouveau / AMD / Intel are not touched.
|
||||
|
||||
- **Lyrics — cubic ease-out scroll animator** *(by [@kilyabin](https://github.com/kilyabin), PRs [#214](https://github.com/Psychotoxical/psysonic/pull/214) / [#215](https://github.com/Psychotoxical/psysonic/pull/215))*: The lyrics auto-scroll animation is replaced by a smoother cubic ease-out curve (renamed internally from `springScroll` to `easeScroll`). Active line transitions are noticeably less jerky on long line-spacing changes.
|
||||
|
||||
- **Fullscreen lyrics — fade bottom edge of plain lyrics scroll viewport** *(by [@kilyabin](https://github.com/kilyabin))*: Plain (unsynced) lyrics in the fullscreen player now fade out at the bottom of the scroll viewport via a `mask-image` gradient, matching the existing fade on the synced-lyrics overlay.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mini player — main window minimises on open + width cap on non-tiling WMs** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Opening the mini now reliably minimises the main window (previously hit-or-miss on some WMs), and the mini's width is capped on non-tiling Linux WMs so it doesn't open larger than its intended footprint when the user's WM hands it the full screen.
|
||||
|
||||
- **Artist page — Top Songs continues playback past the last track** *(by [@kveld9](https://github.com/kveld9), PR [#220](https://github.com/Psychotoxical/psysonic/pull/220))*: Playing a song from the Artist page's Top Songs row no longer stops after the row's last track — the queue continues into the surrounding context as intended.
|
||||
|
||||
- **Padding fixes across several pages** *(by [@kveld9](https://github.com/kveld9), PR [#221](https://github.com/Psychotoxical/psysonic/pull/221))*: Layout polish, mostly aligning content to the page-level container padding instead of the inner card padding.
|
||||
|
||||
- **Jayfin theme — WCAG AA contrast fixes for nav + primary buttons** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Hover and active states on the Jayfin theme's sidebar nav items and primary buttons now pass WCAG AA contrast against the underlying background.
|
||||
|
||||
- **Lyrics — sidebar lyrics with YouLy+ source render as a single line** *(by [@kilyabin](https://github.com/kilyabin))*: Lines from the YouLyrics+ source were being split across multiple visual lines in the QueuePanel lyrics pane. Now collapse onto one line as intended.
|
||||
|
||||
- **Settings → Lyrics Sources — drag-and-drop survives mode toggle** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Reordering lyrics sources via drag-and-drop no longer resets when toggling the synced-vs-plain mode.
|
||||
|
||||
- **Folder browser — auto-contrast text on selected row** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Selected rows in the folder browser now compute text colour from the row's background luminance, so light themes don't paint white-on-white text.
|
||||
|
||||
- **Titlebar — theme-independent traffic-lights + song pill** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The macOS-style traffic-lights and the now-playing pill in the titlebar use fixed colours instead of theme tokens, so they stay legible on every theme without needing per-theme overrides.
|
||||
|
||||
### Reverted
|
||||
|
||||
- **Reverted: fs-player WebKitGTK CPU-cut patch** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: An earlier perf patch in the Fullscreen Player that disabled compositing under WebKitGTK turned out to cause animation regressions in real-world use. Reverted; the original code path is back.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AudioMuse toggle — Alpha badge dropped** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The AudioMuse-AI integration has been stable for several releases; the "Alpha" tag in Settings → Server is removed.
|
||||
|
||||
## [1.42.1] - 2026-04-19
|
||||
|
||||
> **🚨 Critical bug fix for Windows users.** On 1.42.0, opening the mini player on Windows could stall Tauri's event loop: the mini would appear as a blank white window, neither the main window nor the mini could be closed, and the only way out was killing the process via Task Manager. **Please update immediately if you're on Windows 1.42.0.** macOS and Linux were not affected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mini player no longer hangs the app on Windows** *([@Psychotoxical](https://github.com/Psychotoxical))*: Creating the second WebView2 webview lazily from the `open_mini_player` invoke handler reliably froze the app on Windows — the mini opened blank, both windows became unresponsive, and the user had to kill the process from Task Manager. The builder + `main.minimize()` combo racing against WebView2's first paint was the trigger. The mini webview is now pre-built hidden in Tauri's `.setup()` on Windows, so the first open is a pure show/hide instead of creation + minimize. `open_mini_player` is simpler on all platforms, the minimize-main dance around show/hide is skipped on Windows, and Windows also goes back to the native window decorations (the earlier `decorations: false` mini titlebar was part of the hang surface).
|
||||
- **Mini player syncs immediately on first open** *([@Psychotoxical](https://github.com/Psychotoxical))*: With the mini pre-created on Windows, the mount-time `mini:ready` event could race past the main window's bridge listener and leave the mini without a snapshot when the user actually opened it. The mini now also re-emits `mini:ready` on every window focus, so opening the mini always triggers a fresh sync regardless of startup ordering.
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional “Preload mini player” setting on Linux + macOS** *([@Psychotoxical](https://github.com/Psychotoxical))*: Settings → General → App behaviour. Off by default. When enabled, the mini player window is built hidden at app start so the first open is instant instead of waiting a few seconds for WebKit to boot + React to hydrate + the bridge snapshot to arrive. Costs one extra WebKit process in the background permanently (~50–100 MB RAM). Windows always preloads regardless of this toggle — it's how we work around the hang above, not an opt-in feature there.
|
||||
|
||||
## [1.42.0] - 2026-04-19
|
||||
|
||||
> **🛠️ Note on the 1.41.0 jump:** The 1.41.0 tag exists as an internal Draft release on GitHub — it was used to wire up and verify the Cachix substituter pipeline and never went public. **1.42.0 is the first public release after 1.40.0** and consolidates everything that was prepared for 1.41.0 plus the work landed on top in the days since.
|
||||
>
|
||||
> **❄️ Cachix is live for NixOS users.** The `psysonic.cachix.org` substituter is now actually fed by every release. Earlier 1.40.x runs were silently skipping the cache push (see *Fixed* below), so the first user to ask for a given output paid the full compile cost. Starting with 1.42.0, `nix run github:Psychotoxical/psysonic` and the NixOS module both pull the prebuilt closure straight from Cachix — no local Rust + symphonia + libopus build required.
|
||||
|
||||
### Added
|
||||
|
||||
- **Mini player — feature-complete second cut** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: The early-alpha mini from the internal 1.41.0 prep gets the rest of the workflow it was missing.
|
||||
- **Expandable queue panel** with full track list, search-style overlay scrollbar (no width-eating gutter), drag-to-reorder using the existing PsyDnD system, and a localized right-click context menu (Play now / Remove from queue / Open album / Go to artist / Favorite / Song info — all forwarded to the main window via Tauri events so the source-of-truth playerStore stays consistent).
|
||||
- **Custom in-page titlebar** on Windows + Linux with a drag region, the current track title and the queue / pin / open-main / close action icons. macOS keeps the native traffic-lights titlebar so the system look is preserved. The lower toolbar from the alpha is gone — its four buttons live in the titlebar now.
|
||||
- **Persistent geometry**: window position, expanded-queue height and queue-open state all survive an app restart. Position is written to `<app_config_dir>/mini_player_pos.json` on every move (throttled), and re-applied after each show — Linux WMs (Mutter/KWin) re-centre hidden windows on show, so without re-applying the position would be lost on the second open.
|
||||
- **User-bindable keyboard shortcut** in Settings → Shortcuts (`open-mini-player`, default unbound). The same chord toggles between main and mini regardless of which window has focus.
|
||||
- **Layout polish**: cover shrinks 112 → 84 px, the right column gets title / artist / transport in a single block, progress + toolbar take full width.
|
||||
- **Live theme / font / language sync**: changes in the main window propagate to an open mini via the shared localStorage `storage` event — no need to close + re-open the mini after rebinding a shortcut or switching themes.
|
||||
- **Always-on-top reliability fix**: WMs that silently ignore `set_always_on_top(true)` when the flag is "already true" (KWin, certain Mutter releases) get a forced false → true cycle so the constraint is actually re-evaluated. The frontend also re-asserts the pin state on mount and on focus, so the user no longer has to click the pin button twice for it to stick.
|
||||
|
||||
- **Player bar — click-to-toggle duration / remaining time** *(contributed by [@kveld9](https://github.com/kveld9), PR [#212](https://github.com/Psychotoxical/psysonic/pull/212))*: Click the time read-out in the player bar to swap between total duration (`3:45`) and remaining time (`-2:34`). Updates live, persisted to `themeStore.showRemainingTime`. A small swap icon (⇄) and hover highlight signal the interaction.
|
||||
|
||||
- **Queue — ReplayGain in tech strip, expandable badge** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), originally by [@cucadmuh](https://github.com/cucadmuh) in PRs [#196](https://github.com/Psychotoxical/psysonic/pull/196) / [#201](https://github.com/Psychotoxical/psysonic/pull/201) — UX iteration by [@Psychotoxical](https://github.com/Psychotoxical) on cucadmuh's feedback)*: Tracks with ReplayGain metadata now show a small `RG ⌄` pill at the end of the codec/bitrate/sample-rate strip. Hover reveals the values via tooltip; click expands a second line ("ReplayGain · T -8.9 dB · A -11.0 dB · Peak 0.998") that is persisted across sessions. Hides itself for tracks without RG metadata.
|
||||
|
||||
- **Changelog — sidebar banner + dedicated `/whats-new` page** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The auto-popup modal that nagged the user on first launch after each update is replaced by a discreet sidebar banner. Clicking it opens a full `/whats-new` page that renders the latest CHANGELOG section in app — no separate Markdown viewer, no broken links to GitHub.
|
||||
|
||||
- **Favorites — genre column + Top Favorite Artists row** *(Issue [#87](https://github.com/Psychotoxical/psysonic/issues/87), by [@Psychotoxical](https://github.com/Psychotoxical))*: The Favorites tracklist now has a toggleable Genre column (alongside the existing Album column and multi-genre filter). A new horizontally scrolling "Top Favorite Artists" row sits between Radio Stations and Songs, aggregated from starred tracks and sorted by star count. Clicking an artist card narrows the song list to that artist.
|
||||
|
||||
- **Compilation filter on All Albums** *(Issue [#65](https://github.com/Psychotoxical/psysonic/issues/65), by [@Psychotoxical](https://github.com/Psychotoxical))*: A tri-state toggle in the Albums page header (All / Only compilations / Hide compilations) that reads the OpenSubsonic `isCompilation` tag exposed by Navidrome 0.61+. Client-side filter, no additional server calls. Translated into all 8 supported locales.
|
||||
|
||||
- **Sticky header on Albums, New Releases, Artists** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The header row with search/sort/genre/year controls now pins to the top while scrolling, so filters stay reachable without jumping back up. Works the same on all three browse pages.
|
||||
|
||||
- **Device Sync — album artist on both panels** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Album entries in both the library (left) and on-device (right) panels now display `Album · Artist` inline, so sampler discs and self-titled albums are no longer guesswork. Playlists unchanged.
|
||||
|
||||
- **NixOS — first-class flake install guide** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PRs [#209](https://github.com/Psychotoxical/psysonic/pull/209) / [#210](https://github.com/Psychotoxical/psysonic/pull/210))*: A new top-level `nixos-install.md` walks through adding Psysonic as a flake input, installing via `environment.systemPackages` / `home.packages`, and wiring up the public `psysonic.cachix.org` substituter so every NixOS user pulls prebuilt binaries. README links to it directly.
|
||||
|
||||
- **README — AppImage in the Linux install options + Cachix badge** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Linux install section now lists AppImage alongside `.deb`, `.rpm`, AUR and Nix flakes. A Cachix badge on the README header signals that NixOS users get prebuilt binaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Genre filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The inline tagbox + dropdown (capped at 60 entries, ate header space when expanded) is replaced by a compact button that opens a portal-rendered popover with a search field and the full scrollable list of genres. Selected genres sort to the top. Used on Albums, New Releases, Random Albums and Favorites.
|
||||
|
||||
- **Year filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The From/To number inputs in the Albums header became a single button with a popover mirroring the genre filter pattern. When the filter is active, the button shows the range (e.g. `2020–2024`) in accent colour.
|
||||
|
||||
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`A–Z (Album)`, `A–Z (Artist)`) collapse into one dropdown button showing the current choice. Generic `SortDropdown` component, reusable for other pages.
|
||||
|
||||
- **Device Sync — album/playlist meta inline** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `BrowserRow` renders secondary info inline with a `·` separator in muted colour instead of a separate right-aligned column, matching the on-device panel's format.
|
||||
|
||||
- **README — Arch/AUR fold-up** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Arch / AUR install instructions are folded into the Linux install section so the README stops scrolling forever.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Player bar — black-flash on WebKitGTK** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Linux users occasionally saw the entire player bar paint fully black for one frame when an unrelated layer elsewhere on the page invalidated. `contain: layout paint` makes the bar its own paint boundary so it can no longer be pulled into a surrounding dirty rect. No-op on platforms that don't exhibit the flash (Wayland-with-GPU, Chromium webviews on Windows / macOS).
|
||||
|
||||
- **Player bar — time-toggle tooltip uses the in-app TooltipPortal** *(follow-up to PR [#212](https://github.com/Psychotoxical/psysonic/pull/212), by [@Psychotoxical](https://github.com/Psychotoxical))*: The new time-swap control was rendering the native browser `title=` tooltip (unstyled OS popup, ignored by every other control). Switched to `data-tooltip="…"` so it matches every other player-bar tooltip.
|
||||
|
||||
- **Fullscreen player — lyrics menu toggle + readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Re-clicking the mic icon now actually closes the lyrics settings panel instead of the outside-click handler closing it and the click re-opening it — the trigger button is excluded from the outside-check. The panel itself is now a solid surface (no backdrop blur, near-opaque background, higher-contrast button text) so settings remain readable over the busy fullscreen background.
|
||||
|
||||
- **i18n — ArtistCardLocal album count** *(contributed by [@cucadmuh](https://github.com/cucadmuh))*: Local artist cards were rendering the album count with hardcoded German (`Album` / `Alben`). Switched to the existing plural-aware `artists.albumCount` key which already covers all 8 locales including Russian Slavic plurals.
|
||||
|
||||
- **Release CI — Cachix never receiving the psysonic closure** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `cachix-action` installs its post-build hook via `NIX_USER_CONF_FILES`, but the Determinate Nix daemon that runs the actual builds reads the system nix.conf — so the hook never fired. Only a couple of early prep paths ever reached the cache, never the compiled `psysonic` output. The release workflow now pushes the full closure explicitly after `nix build`; Cachix dedupes against paths already present, so redundancy is cheap.
|
||||
|
||||
### Contributors
|
||||
|
||||
- [@kveld9](https://github.com/kveld9) — click-to-toggle duration / remaining time in the player bar.
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal, ReplayGain UX feedback that drove the expandable badge, NixOS install guide, README polish.
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Install now** in the notification modal downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper re-approval, no manual DMG handling. The modal shows trust badges ("Notarized by Apple" + "Signature verified"), a 3-second restart countdown after install with a manual "Restart now" option, and hides redundant buttons during each download/install phase. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
|
||||
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Device Sync — fixed naming scheme + playlist folders** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The user-configurable filename template is gone. Every sync now writes files under a single, non-negotiable scheme:
|
||||
- Album / artist sources: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
|
||||
- Playlist sources: `Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}` plus a self-contained `.m3u8` that references sibling filenames.
|
||||
|
||||
**Why:** different OSes normalised separators and special characters differently, so the same library synced from macOS and then plugged into a Windows machine appeared "different" and re-downloaded every album. The fixed scheme ends that forever.
|
||||
|
||||
**Playlist folders instead of the album tree:** playlists used to be scattered across the album structure as `.m3u8` references. For playlists with 40 artists that meant 40 new folders on the stick. Now every playlist is one self-contained folder; the `.m3u8` sits inside it and references siblings, so you can copy the whole folder anywhere.
|
||||
|
||||
**Migration for existing sticks:** a "Reorganize existing files…" button on the Device Sync page reads the legacy template from the v1 manifest, computes per-track rename pairs, detects collisions, and executes atomic `fs::rename`s. Empty directories left behind are cleaned up automatically. Playlist tracks synced under the old scheme are left for the next sync to re-download into the new playlist folder, rather than being force-moved.
|
||||
|
||||
**Album-Artist fallback:** libraries without an albumArtist tag fall back to the track artist — "Unknown Artist" is only ever a last-resort placeholder.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
|
||||
|
||||
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
|
||||
|
||||
### Contributors
|
||||
|
||||
- **PR [#205](https://github.com/Psychotoxical/psysonic/pull/205)** — Apple Music-style scrolling lyrics with spring-physics scroll, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#206](https://github.com/Psychotoxical/psysonic/pull/206)** — Golos Text + Unbounded fonts with Cyrillic support, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#207](https://github.com/Psychotoxical/psysonic/pull/207)** — WebKitGTK wheel scroll mode toggle, by [@cucadmuh](https://github.com/cucadmuh).
|
||||
|
||||
All three now credited in Settings → About.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.13] - 2026-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- **YouLyPlus — word-by-word synced lyrics (karaoke)** *(Issue [#172](https://github.com/Psychotoxical/psysonic/issues/172), by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → Lyrics now exposes a mode toggle between the existing **Standard** pipeline (Server tags + LRCLIB + Netease, configurable order) and a new **YouLyPlus** mode that fetches karaoke-style word-sync lyrics from the public `lyricsplus` aggregator (Apple Music / Spotify / Musixmatch / QQ Music). When a track has no YouLyPlus entry the app silently falls back to the Standard pipeline, so obscure titles still resolve. Active word highlighting in both the sidebar Lyrics pane and the Fullscreen Player. Five backend mirrors are tried on network failure; no API keys on the user side — subscription costs are borne by the lyricsplus operator.
|
||||
|
||||
- **Static-only lyrics option** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A new toggle renders synced lyrics as plain static text — no auto-scroll, no word highlighting — for users who prefer to read rather than follow. Works in both Standard and YouLyPlus modes.
|
||||
|
||||
- **Discord Rich Presence — collapsible advanced options** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The *Fetch covers from Apple Music* toggle and the *Custom text templates* form are now tucked under a single collapsible **Advanced Discord options** header (default collapsed) that only appears when Discord Rich Presence is enabled. Reduces vertical noise in Settings → General for the common case.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS — spurious microphone permission prompt (real fix)** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The 1.34.12 attempt of removing `NSMicrophoneUsageDescription` did not actually suppress the prompt — on modern macOS, TCC fires at AudioUnit instantiation time, not at Info.plist level. Root cause: `cpal` (via `rodio`) instantiates an `AUHAL` output unit (`IOType::HalOutput`), which macOS classifies as input-capable even for playback-only apps. Psysonic now ships a vendored `cpal 0.15.3` at `src-tauri/patches/cpal-0.15.3/` wired via `[patch.crates-io]`; the patch forces `IOType::DefaultOutput` for all output streams, which never touches input and never triggers the mic dialog. **Tradeoff:** per-device output selection is a no-op on macOS — the stream always follows the system default (change via System Settings → Sound or the menu-bar speaker icon). Matches the behaviour of Apple Music and Spotify on macOS. Settings surfaces this with an explanatory notice on macOS and hides the device picker there.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.12] - 2026-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Playback source indicator in Queue** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: The current-track tech strip in the Queue panel now shows a **source badge** indicating how the track was loaded: `stream` (live from server), `preloaded` (buffered before playback), or `cache` (served from local hot cache). Preload tracking is wired through the Rust audio engine so the badge reflects actual playback origin, not just current state.
|
||||
|
||||
- **ReplayGain metadata in Queue tech strip** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#196](https://github.com/Psychotoxical/psysonic/pull/196))*: The current-track tech strip now shows track and album ReplayGain values alongside bitrate and format when the file contains gain tags.
|
||||
|
||||
- **Discord Rich Presence enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#198](https://github.com/Psychotoxical/psysonic/pull/198))*: Discord Rich Presence received several improvements: dead/unused fields removed, the `{paused}` placeholder that Discord does not support was dropped, and a `timeChanged` invoke loop that fired redundantly on every progress tick was eliminated. The DRP timer is now accurate and stable.
|
||||
|
||||
- **Context menu in Search results** *(contributed by [@kveld9](https://github.com/kveld9), PR [#191](https://github.com/Psychotoxical/psysonic/pull/191))*: Song rows in the Search panel now support the full right-click context menu (Play, Queue, Playlist, etc.) — previously search results were click-only with no context actions.
|
||||
|
||||
- **Spotify CSV playlist import** *(contributed by [@kveld9](https://github.com/kveld9), PR [#190](https://github.com/Psychotoxical/psysonic/pull/190))*: Playlists exported from Spotify as CSV can now be imported directly into Psysonic. Tracks are matched by ISRC when available, with title/artist fallback. Unmatched tracks are listed in a report after import. Duplicate checking is done before writing.
|
||||
|
||||
- **CLI completions and expanded player controls** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#187](https://github.com/Psychotoxical/psysonic/pull/187))*: The `psysonic` CLI gains shell completions for bash/fish/zsh/elvish, new subcommands for library browsing and audio device listing, a server switcher command, and an opaque play-ID scheme for stable track references. The tray icon on Linux no longer requires `libayatana-appindicator` / `libindicator` — it falls back gracefully when the library is absent.
|
||||
|
||||
- **Albums and Playlists header redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#186](https://github.com/Psychotoxical/psysonic/pull/186))*: The header sections on the Albums and Playlists pages have been redesigned for a cleaner, more consistent layout.
|
||||
|
||||
- **Favorites page redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#184](https://github.com/Psychotoxical/psysonic/pull/184))*: The Favorites page has been overhauled with sortable columns, a gender filter, an age range filter, and additional metadata columns.
|
||||
|
||||
- **Split Mix navigation mode** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A new toggle in Settings switches the Mix section between a single **Build a Mix** hub entry and **two separate sidebar entries** — Random Mix and Random Albums — for users who prefer direct access. Navigation items are now defined in `src/config/navItems.ts`; the toggle is stored as `randomNavMode` in authStore.
|
||||
|
||||
- **Device Sync improvements** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Device Sync received several updates: a JSON manifest is now written to the device root on every sync (and read back automatically when the device is mounted); a **Cancel** button interrupts a running sync cleanly; a font picker was added to the sync page; sync status display was fixed; and the filename template builder now works correctly on all platforms.
|
||||
|
||||
- **Radio — ICY StreamTitle forwarded to MPRIS** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: While playing internet radio, the current song title parsed from ICY `StreamTitle` metadata is now forwarded to MPRIS `xesam:title` on Linux so that the track name appears in desktop notification shells and media controls.
|
||||
|
||||
- **Help page — expanded coverage** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added missing help sections covering Device Sync, Internet Radio, CLI usage, Playlists, Infinite Queue, Lyrics sources, Audio device selection, Backup & Restore, and Now Playing details.
|
||||
|
||||
- **Tracklist column reset and privacy policy** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A reset button in the tracklist column picker restores the default column set. The Device Sync page received a cross-platform filename template fix. A privacy policy page was added documenting data usage for Last.fm, LRCLIB, NetEase, and Discord.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming playback stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#200](https://github.com/Psychotoxical/psysonic/pull/200))*: Several edge cases in the Rust audio engine around stream start, mid-track seeking, and track transitions were hardened. Cache promotion (moving a preloaded track into the hot cache) is now safer under concurrent access. Stream decoder errors during transitions no longer leave the engine in a stuck state.
|
||||
|
||||
- **CSV import reliability** *(contributed by [@kveld9](https://github.com/kveld9), PR [#199](https://github.com/Psychotoxical/psysonic/pull/199))*: The CSV import pipeline now guards the `ISRC` field type before calling `toUpperCase`, preventing a crash on rows with numeric or null ISRC values. The playlist public/private toggle in the edit modal (accidentally removed during a post-merge fix) is restored.
|
||||
|
||||
- **Tracklist column picker** *(contributed by [@kveld9](https://github.com/kveld9), PR [#188](https://github.com/Psychotoxical/psysonic/pull/188) and PR [#192](https://github.com/Psychotoxical/psysonic/pull/192))*: Fixed a column picker overflow where the dropdown was clipped by the tracklist container. Also fixed column toggle state and alignment issues in the picker UI. An `overflow-x: visible` regression introduced in PR #188 was subsequently reverted.
|
||||
|
||||
- **macOS — spurious microphone permission prompt** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Removed `NSMicrophoneUsageDescription` from `Info.plist`. It was inherited from an earlier Tauri template but Psysonic never uses the microphone; its presence caused macOS to show a permission dialog on first launch.
|
||||
|
||||
- **Device Sync — auto-import and disconnect cleanup** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The sync manifest is now automatically imported when the Device Sync page is opened if a device with a manifest is already mounted. The sync file list is cleared when the device is disconnected.
|
||||
|
||||
- **Audio — streaming decoder log labels** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: Rust log lines from the streaming decoder are now tagged with the source type, making it easier to distinguish stream vs. local decode paths in debug output.
|
||||
|
||||
- **Theme — Latte and GTA readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Improved contrast and text readability in the Catppuccin Latte and GTA themes.
|
||||
|
||||
- **i18n — missing `common.play` key** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added the `common.play` translation key to all 8 locales; it was missing after PR #186 which introduced its usage.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Waveform seekbar — realtime waveform style** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The `realtime_waveform` CSS class and its associated style block were removed. This style was applied during live streaming and produced a low-quality rendering mode that was no longer needed after the streaming architecture improvements.
|
||||
|
||||
---
|
||||
|
||||
*Thank you to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for the playback source indicator, ReplayGain in the tech strip, streaming stability hardening, and CLI improvements — four substantial PRs.*
|
||||
*[@kveld9](https://github.com/kveld9) for the CSV import, search context menu, Discord RP enhancements, Favorites redesign, and header redesign — a very productive cycle.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.11] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Opus audio playback** *(Issue [#180](https://github.com/Psychotoxical/psysonic/issues/180), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#183](https://github.com/Psychotoxical/psysonic/pull/183))*: Psysonic can now decode Opus audio natively via `symphonia-adapter-libopus`, which bundles and compiles libopus from source. Previously `.opus` files were sent to the server for transcoding — a workaround that never worked reliably. Native decoding is now used directly; the server is no longer involved. Note: building from source requires `cmake` to be installed (see README).
|
||||
|
||||
- **Device Sync — synchronise your library to USB and SD card players** *(Issue [#161](https://github.com/Psychotoxical/psysonic/issues/161), by [@Psychotoxical](https://github.com/Psychotoxical))*: A fully overhauled Device Sync page lets you copy music from your Navidrome library to any mounted USB drive or SD card. Browse albums via live search (300 ms debounce) or a random album selection. Choose a filename template (Artist/Album/Track format), pick a target folder, and review a pre-sync summary showing files to add, files to delete, and available space — including a warning if the device would run out of space after accounting for pending deletions. Already-synced files are detected and skipped automatically so incremental syncs are fast.
|
||||
|
||||
- **3 visual toggles** *(contributed by [@kveld9](https://github.com/kveld9), PR [#181](https://github.com/Psychotoxical/psysonic/pull/181))*: Three new toggles in Settings → Appearance:
|
||||
- **Cover art background** — enables/disables the blurred album art background in Album Detail and the Hero section.
|
||||
- **Playlist cover photo** — shows/hides the cover collage at the top of Playlist Detail pages.
|
||||
- **Show bitrate badge** — toggles the bitrate label displayed on tracks in the queue and track lists.
|
||||
|
||||
- **8 community themes** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: A new **Community** theme group appears directly below Psysonic Themes in the Theme Picker, containing eight new themes: **AMOLED Black Pure** (pure black for OLED), **Monochrome Dark** (grayscale), **Amber Night** (warm golden amber), **Phosphor Green** (classic terminal green), **Midnight Blue** (deep blue), **Rose Dark** (pink/rose accents), **Sepia Dark** (warm cream sepia), and **Ice Blue** (cool cyan). Psysonic now ships with 75 themes across 9 groups.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HTTPS streaming failures and server URL trailing slash** *(Issue [#178](https://github.com/Psychotoxical/psysonic/issues/178), by [@Psychotoxical](https://github.com/Psychotoxical) with fix ported from PR [#179](https://github.com/Psychotoxical/psysonic/pull/179) by [@kveld9](https://github.com/kveld9))*: Two bugs that broke HTTPS server connections are now fixed. A trailing slash in the configured server URL caused double-slash stream URLs (`//rest/stream.view`) that reverse proxies like Caddy would reject, and also caused album browsing to return 0 results. Additionally, `reqwest` now loads the OS native certificate store alongside Mozilla's root store — fixing HTTPS streaming failures when the server certificate is signed by a local CA (e.g. Caddy's internal CA) that is trusted in the system keychain but not in Mozilla's bundle.
|
||||
|
||||
- **Server display in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The server list in Settings → Servers now shows the URL and username on separate lines instead of a single truncated `username@url` string. Protocol prefixes (`http://`, `https://`) are stripped for cleaner display. HTTPS connections show a green lock icon.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Waveform seekbar — live theme updates** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: The canvas-based seekbar now listens for `data-theme` attribute changes via `MutationObserver` and redraws immediately with the new theme colours. Switching themes no longer requires an app restart to update the waveform.
|
||||
|
||||
---
|
||||
|
||||
*Thank you to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for implementing native Opus decoding — a long-requested feature that finally makes `.opus` libraries fully playable.*
|
||||
*[@kveld9](https://github.com/kveld9) for three PRs in one release: the SSL/trailing-slash fix, visual customisation toggles, and eight new community themes with a live waveform update fix.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.10] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- **AppImage bundle for Linux** + X11/XWayland enforcement on all Linux packages: CI now builds `.AppImage` in addition to `.deb` and `.rpm`. `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` are set automatically at startup on all Linux packages — WebKitGTK on Wayland is unstable. Both environment variables are still overridable by setting them before launch.
|
||||
|
||||
- **Audio output device selection** *(Issue [#169](https://github.com/Psychotoxical/psysonic/issues/169))*: Settings → Audio now shows a dropdown of all available output devices. The current OS default is pinned at the top with a label; a Refresh button re-enumerates silently. A device watcher detects hot-plug events and emits `audio:device-reset` after ~9 s of consecutive misses, preventing false positives on busy ALSA devices. On Linux, technical ALSA prefixes are stripped for display (`sysdefault:CARD=U192k` → `U192k`).
|
||||
|
||||
- **Vision Dark & Vision Navy — colorblind-safe themes** *(Issue [#166](https://github.com/Psychotoxical/psysonic/issues/166))*: Two new themes using a Purple & Gold palette designed to be safe for Deuteranopia, Protanopia, and Tritanopia. Vision Dark pairs near-black `#0D0B12` with Gold `#FFD700` (~14.7:1 WCAG AAA); Vision Navy uses deep navy `#0A1628` + Gold (~14.5:1 WCAG AAA). Both appear under a new **Accessibility** group in the Theme Picker. These themes are a first step toward proper colorblind support and will be revised and expanded in upcoming releases — structural improvements such as secondary indicators and pattern/shape cues are still on the roadmap.
|
||||
|
||||
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
|
||||
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
|
||||
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fullscreen Player — animation overhead in no-compositing mode** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#175](https://github.com/Psychotoxical/psysonic/pull/175))*: In software-rendering mode (`WEBKIT_DISABLE_COMPOSITING_MODE=1`) the mesh blob pan animations are now stopped (static gradients are preserved), the portrait drift animation is stopped, and `box-shadow` is removed from the seekbar played bar. The seekbar played bar width changes on every playback tick; triggering a full shadow repaint in software mode caused significant CPU overhead.
|
||||
|
||||
- **Folder Browser — arrow keys with modifier keys** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#174](https://github.com/Psychotoxical/psysonic/pull/174))*: Column and list arrow-key handling is now skipped when any modifier key is held, preventing conflicts with browser focus navigation and OS-level shortcuts. Modifier detection uses both `nativeEvent` and `getModifierState` for WebKit/WebView2 compatibility.
|
||||
|
||||
- **Audio output device — Linux stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#176](https://github.com/Psychotoxical/psysonic/pull/176))*: Pinned ALSA/cpal device IDs now stay stable when enumeration temporarily omits the active sink or returns an equivalent name. The Linux device-watcher no longer clears the pin based solely on missing list entries — only macOS and Windows treat repeated absence as "device unplugged". The Settings refresh flow calls `canonicalize` and refetches the list; an i18n label is now shown when the active device is no longer in the enumerated list.
|
||||
|
||||
- **Login — server URL field** *(Issue [#171](https://github.com/Psychotoxical/psysonic/issues/171))*: The placeholder text in the Add Server form was previously a hardcoded English string. It is now fully localised and clarifies that `https://` URLs are accepted.
|
||||
|
||||
- **Offline mode — non-blocking banner** *(Issue [#170](https://github.com/Psychotoxical/psysonic/issues/170))*: The full-screen blocking overlay shown when Psysonic starts without a cached library is replaced with a slim banner at the top of the content area. The banner includes a direct link to Server Settings so the user can fix the connection without navigating manually.
|
||||
|
||||
---
|
||||
|
||||
*Special thanks to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for the significant Folder Browser improvements, the modifier-chord keybindings and and the Linux audio stability fixes — four PRs in one release cycle, remarkable.*
|
||||
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
|
||||
*[@kveld9](https://github.com/kveld9) for the playlist management overhaul.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.9] - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-select in Playlist Detail & Favorites** *(Issue [#157](https://github.com/Psychotoxical/psysonic/issues/157))*: The same Ctrl/Cmd+Click multi-select system that was previously exclusive to album track lists is now available everywhere. Hold Ctrl (or ⌘ on macOS) to enter select mode, Shift+Click to range-select, click the header checkbox to toggle all. Selected tracks can be dragged as a group directly into the queue. A bulk action bar appears with **Add to Playlist** and **Clear selection** options. Works in Playlist Detail (main tracklist) and in the Favorites song list.
|
||||
|
||||
- **"Open Artist" in context menu**: Song context menus now show an **Open Artist** entry directly below **Open Album**, navigating to the artist detail page. Previously only accessible via the tracklist artist link.
|
||||
|
||||
- **"Add to Playlist" for Artists**: The context menu for artists now includes an **Add to Playlist** submenu. Psysonic fetches all albums from the artist and collects every track, then forwards them to the playlist picker — identical to the existing album-level submenu.
|
||||
|
||||
- **Infinite queue — Instant Mix strategy** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#163](https://github.com/Psychotoxical/psysonic/pull/163))*: When Infinite Queue is enabled, Psysonic now builds the upcoming track list using the same artist-driven logic as Instant Mix. It fetches **Top Songs** and **Similar Songs** for the current track's artist, shuffles and deduplicates the pool, and only falls back to fully random songs when no artist-driven candidates are available. This results in much more coherent listening sessions that stay close to your current musical context.
|
||||
|
||||
- **Fullscreen Player — appearance settings** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: Settings → Appearance → Fullscreen Player now offers a toggle to show/hide the artist portrait and a 0–80 % dimming slider for the background portrait.
|
||||
|
||||
- **Build a Mix hub** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#155](https://github.com/Psychotoxical/psysonic/pull/155))*: The previous *Random Mix* and *Random Albums* sidebar entries have been merged into a single **Build a Mix** page (Wand icon) at `/random`. A landing card lets you choose between *Mix by Tracks* and *Mix by Albums*. Old routes remain fully functional.
|
||||
|
||||
- **Spanish translation** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#159](https://github.com/Psychotoxical/psysonic/pull/159))*: Complete Spanish (es) locale with 964 translated strings. Psysonic now ships in 8 languages: English, German, French, Dutch, Chinese, Norwegian, Russian, and Spanish.
|
||||
|
||||
- **Column-header sorting for Albums & Playlists** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))*: Track lists in Album Detail and Playlist Detail now support click-to-sort directly on the column headers. Three-click cycle: ascending → descending → natural order. Sortable columns: Title, Artist, Album, Favourite, Rating, Duration. The active column is shown bold with a ▲/▼ indicator.
|
||||
|
||||
- **Folder Browser — keyboard navigation & context menus** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))*: Full keyboard navigation in the Folder Browser with arrow keys, Enter to open, and Ctrl+Enter to open the context menu. Context menus for all row types include keyboard-operable submenus and star-rating control via arrow keys. The now-playing path is visually emphasized and updates live. Adaptive column layout prioritizes right-side visibility for deep directory trees. A new configurable *Open Folder Browser* keybinding is available in Settings → Keyboard.
|
||||
|
||||
- **PLS/M3U playlist resolution for Internet Radio**: Stations configured with a `.pls` or `.m3u`/`.m3u8` URL (e.g. SomaFM, schizoid.in) are now resolved to their first direct stream URL before playback. ICY metadata fetching also auto-resolves playlist URLs. Previously these stations would fail to play or show no track info.
|
||||
|
||||
- **Lyrics sources — configurable order & per-source toggle**: The old *Server First* toggle has been replaced with a full drag-to-reorder list in Settings → General. Three sources — **Server** (embedded/OpenSubsonic), **LRCLIB**, and **Netease Cloud Music** — can each be individually enabled or disabled, and their priority order is fully customisable. Embedded SYLT tags from local files always win unconditionally.
|
||||
|
||||
- **ReplayGain Pre-Gain & Fallback** *(audio)*: Two new sliders in Settings → Audio → ReplayGain:
|
||||
- **Pre-Gain** (0–+6 dB): added on top of every ReplayGain-tagged track for users who prefer a louder default.
|
||||
- **Fallback Gain** (−6–0 dB): applied to untagged tracks and internet radio streams, preventing volume jumps when switching between tagged and untagged content.
|
||||
|
||||
- **Context-aware Remix button in Build a Mix**: When a genre filter is active, the Remix button now re-fetches the same genre instead of resetting to the full library pool. An *All Songs* chip is available as the first genre option to return to the global mix without leaving the page.
|
||||
|
||||
- **AlbumTrackList multi-select & psyDnD** *(tracklist polish)*: Album track lists now support full multi-select with Ctrl/Cmd+Click, Shift+Click range selection, and drag-to-queue for multiple tracks simultaneously. The `TrackRow` component is `React.memo` with fine-grained Zustand selectors, so only the toggled row re-renders on selection change (O(1)).
|
||||
|
||||
- **Mute/unmute restores previous volume**: The mute button in the player bar now restores the volume to its level before muting instead of always jumping to 70 %.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Statistics — accurate counts for large libraries**: The statistics page was previously capped at 10 pages (≈ 5,000 albums), causing incorrect totals on larger libraries. The pagination loop now runs until the server returns a partial page, regardless of library size. Sort type changed to `alphabeticalByName` for stable pagination.
|
||||
|
||||
- **Statistics — Artists count tooltip**: The Artists card now shows a tooltip (dotted underline, cursor: help) explaining that the count reflects album artists only — a Subsonic API limitation. Featured or guest artists who do not have their own album are not counted. The tooltip is localised in all 8 languages.
|
||||
|
||||
- **Artists page — alphabet navigation hover effect**: The A–Z filter buttons had inline styles that prevented `:hover` CSS from applying. Buttons are now styled via `.artists-alpha-btn` CSS class with an accent-coloured hover highlight and a subtle glow ring.
|
||||
|
||||
- **Hot Cache — eviction & prefetch budget**: Eviction now correctly keeps only the current and next track; prefetch fetches up to five tracks when under the size cap but always fetches the immediate next; the previous current track is given a grace period until the debounce fires; eviction runs immediately on MB limit or folder changes; the cap is re-read after each download completes. Live disk usage is now shown on the Audio settings page.
|
||||
|
||||
- **Hot Cache + Preload — mutual exclusion on rehydration**: Users who had both Hot Cache and Preload enabled before the mutual-exclusion rule was introduced will have both automatically reset to off on first launch, preventing a conflicting state.
|
||||
|
||||
- **Fullscreen Player — Linux compositing performance** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: A new `no_compositing_mode` Tauri command detects Linux software-rendering mode and adds an `html.no-compositing` class, which swaps GPU-only CSS effects (`backdrop-filter`, `filter`, `mask-image`) for software-friendly equivalents throughout the fullscreen player.
|
||||
|
||||
- **Fullscreen Player — long lyric lines wrapping**: Long words in lyric lines now wrap correctly instead of overflowing the container.
|
||||
|
||||
- **Russian locale** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))*: Numerous translation improvements across the application, replacing machine-translated or awkward phrasings with natural Russian.
|
||||
|
||||
- **npm audit vulnerabilities**: Updated `axios` and `vite` to address reported security advisories.
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Remove from Queue" context menu item** now has a **Trash** icon, matching the destructive action style of other delete operations.
|
||||
- **Playlist Detail — filter-mode drag**: Rows in a filtered/sorted playlist view can now be dragged to the queue as single songs (previously dragging was disabled entirely in filter mode).
|
||||
- **Infinite queue deduplication**: Tracks already present in the queue are excluded from the candidate pool, preventing the same song from appearing twice in a row during Infinite Queue sessions.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to **v1.34.9**:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Infinite queue via Instant Mix strategy (PR [#163](https://github.com/Psychotoxical/psysonic/pull/163)), Folder Browser keyboard navigation & context menus (PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))
|
||||
- [@kilyabin](https://github.com/kilyabin) — Fullscreen Player performance & appearance settings (PR [#156](https://github.com/Psychotoxical/psysonic/pull/156)), Build a Mix hub (PR [#155](https://github.com/Psychotoxical/psysonic/pull/155)), Russian locale improvements (PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))
|
||||
- [@Kveld9](https://github.com/Kveld9) — Spanish translation (PR [#159](https://github.com/Psychotoxical/psysonic/pull/159)), Column-header sorting (PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))
|
||||
|
||||
A huge thank you to all three of you — your contributions have made this one of the most feature-packed patch releases yet. Psysonic keeps getting better because of people like you. 🙌
|
||||
|
||||
---
|
||||
|
||||
## [1.34.8] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
|
||||
|
||||
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now supports [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) if it is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic keeps its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
|
||||
|
||||
- **ICY metadata & AzuraCast radio** *(contributed by [@nisrael](https://github.com/nisrael), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
|
||||
|
||||
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Multi-artist tracks**: Tracks with multiple artists (OpenSubsonic `artists[]` field, e.g. semicolon-separated entries) now display each artist individually. Artists with their own profile page are clickable links; artists without one appear as plain text. Separated by `·`.
|
||||
|
||||
- **Gapless + Preload Gate**: The gapless chain and the preload gate now run on separate paths. Previously both could fire simultaneously, causing a brief black flash on track change.
|
||||
|
||||
- **Replay Gain — missing album gain**: When no album gain tag is present, Psysonic now correctly falls back to track gain instead of skipping gain correction entirely.
|
||||
|
||||
- **Statistics — music library scope**: Genre insights now respect the currently selected music library. Fetch results are cached to avoid redundant server requests. Playback durations are displayed in localized units.
|
||||
|
||||
- **Russian locale**: "Most Played" in the sidebar, home page, and page title now uses «Популярное».
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Reset to defaults" buttons** in Settings → Input are now styled as warning buttons (red border).
|
||||
- **Lyrics button** removed from the player bar (redundant with the queue panel tab).
|
||||
- **Icons**: Advanced search now uses the `TextSearch` icon; artist bio button now uses `Highlighter`.
|
||||
- **Album chip** in the album detail header is now opaque across all themes.
|
||||
- **Hot Cache and Hi-Res Audio**: Alpha badges removed — both features are production-ready.
|
||||
- **CPU optimisations**: Next-track buffering and preload settings have been consolidated into a unified control.
|
||||
|
||||
### Theme Fixes
|
||||
|
||||
- **Middle Earth**: Removed vertical stripe pattern from sidebar; improved queue artist contrast on hover; fixed album detail artist colour, bio text, and "Read more" link readability; "Next Tracks" divider label is now lighter.
|
||||
- **Toy Tale**: Fixed sidebar section labels (System/Library), queue tab buttons (Lyrics/Queue), inactive artist text, and "Next Tracks" divider label — all were too dark to read.
|
||||
- **Tetrastack**: Raised all purple and blue palette values (`#a020f0` → `#c070ff`, `#0060f0` → `#4090ff`); raised `--text-muted` from `#3a3a6a` to `#7878b8` — affected settings descriptions, artist names in tracklists, and queue labels.
|
||||
- **Horde & Alliance**: Removed repeating horizontal line pattern from sidebar.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — AudioMuse-AI Navidrome integration (PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))
|
||||
- [@sorensiimSalling](https://github.com/sorensiimSalling) — ICY metadata & AzuraCast radio support (PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))
|
||||
|
||||
You make Psysonic better. 🙌
|
||||
|
||||
---
|
||||
|
||||
## [1.34.7] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows — Taskbar Thumbnail Toolbar**: Prev / Play-Pause / Next media buttons now appear in the Windows taskbar thumbnail preview (the popup that appears when hovering over the taskbar icon). Buttons emit the same `media:*` events as the tray menu and souvlaki. The Play/Pause icon swaps in real-time as playback state changes.
|
||||
|
||||
- **Windows — High-quality taskbar icons**: The taskbar thumbnail toolbar icons are now loaded from embedded `.ico` assets (`play.ico`, `pause.ico`, `prev.ico`, `next.ico`) via `CreateIconFromResourceEx`, replacing the previous monochrome GDI drawing code. All four icons are properly cleaned up on window destruction.
|
||||
|
||||
- **Professional update modal**: The in-app updater now shows a polished modal with the full release changelog, a **Skip this version** option, and an OS-aware direct download button (`.dmg` on macOS, `.exe` on Windows, `.deb`/`.rpm` on Linux) as a fallback if the auto-update fails. The modal is fully localised in all 7 supported languages.
|
||||
|
||||
- **Self-hosted fonts — no internet required**: All 10 UI fonts are now shipped as WOFF2 files bundled into the app via `@fontsource-variable` npm packages. The previous Google Fonts CDN dependency has been removed entirely — Psysonic now renders correctly with no internet connection and without any external requests on startup.
|
||||
|
||||
- **Help — 11 new FAQ entries**: The Help page covers previously undocumented features across Ratings (how to rate songs/albums/artists, removing a rating, Skip-to-1★, rating filter for mixes), Folder Browser, Theme Scheduler, UI Scale, Seekbar styles, AutoEQ, Replay Gain, Hot Cache, and offline playlist caching. All 7 locales updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Embedded lyrics (MP3 & FLAC)**: A new `get_embedded_lyrics` Tauri command reads lyrics tags directly from local files — `SYLT`/`USLT` frames for MP3 (via the `id3` crate) and `SYNCEDLYRICS`/`LYRICS` tags for FLAC (via `lofty`). Additionally: the LRC parser now correctly handles timestamps without fractional seconds (e.g. `[01:23]`), and the Subsonic structured-lyrics parser now accepts both `synced` and `issynced` field names for compatibility with different server versions.
|
||||
|
||||
- **Linux — player bar disappearing at high zoom / small window sizes**: All `grid-template-rows` definitions now use `minmax(0, 1fr)` instead of bare `1fr`, and the `min-height: 720px` constraint on the app shell has been removed. The player bar no longer gets pushed off-screen when the window is small or the UI scale is above 100 %.
|
||||
|
||||
- **Windows — "Open folder" in Settings crashing**: The Settings page uses a Rust `open_folder` command instead of the Tauri `shell:open` API, which was blocked by the capability scope on Windows for local paths.
|
||||
|
||||
- **macOS — Artist Radio crashing WKWebView after ~10 minutes**: Storing `currentTime` in the persisted Zustand state caused up to ~1,200 synchronous `localStorage.setItem` calls per radio session, eventually crashing the WKWebView SQLite backend. `currentTime` has been removed from the persist partializer. Old played radio tracks are also now trimmed from the queue (keeping the last 5) to cap the localStorage payload during queue top-up.
|
||||
|
||||
- **Artist Radio — predictable track order**: The initial Artist Radio queue is now shuffled via Fisher-Yates, so positions 2+ draw from similar-artist tracks in a random order rather than always playing the server's top-5 tracks in sequence.
|
||||
|
||||
- **Internet Radio — stall / buffering recovery**: Stall events on the HTML5 `<audio>` element now trigger automatic reconnection (up to 5 retries), recovering from transient network interruptions without requiring a manual restart.
|
||||
|
||||
- **Corrupt MP3s — VLC-style frame tolerance**: The audio decoder now tolerates up to 100 consecutive bad frames before giving up (previously 3), matching VLC's behavior for files with invalid `main_data` offset frames. Frame-drop log messages are suppressed in release builds.
|
||||
|
||||
- **Statistics — album/song totals respect selected music library**: Album and track counts on the Statistics page were previously derived from `getGenres()`, which is not scoped to the active music folder. Both counts are now derived from the same paginated `getAlbumList` pass used for playtime, with the same 5,000-album cap and a `≥` prefix when capped. *(PR [#138](https://github.com/Psychotoxical/psysonic/pull/138) by [@cucadmuh](https://github.com/cucadmuh))*
|
||||
|
||||
- **Fullscreen — resize grips visible in native fullscreen**: Resize grips are now hidden whenever the window enters native fullscreen on all platforms (previously only tracked on Linux). An initial check on mount also catches windows that start in a maximized or fullscreen state.
|
||||
|
||||
- **Albums page — year filter input height**: The "From year" / "To year" inputs in the Albums filter bar now match the height and font size of adjacent buttons, fixing the mixed-height row introduced in v1.34.4.
|
||||
|
||||
- **Russian locale — missing lyrics-source strings**: The `lyricsServerFirst` and related settings strings were not translated in the Russian locale. *(PR [#140](https://github.com/Psychotoxical/psysonic/pull/140) by [@kilyabin](https://github.com/kilyabin))*
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Statistics music-folder scope fix (PR [#138](https://github.com/Psychotoxical/psysonic/pull/138))
|
||||
- [@kilyabin](https://github.com/kilyabin) — Russian locale lyrics strings (PR [#140](https://github.com/Psychotoxical/psysonic/pull/140))
|
||||
|
||||
---
|
||||
|
||||
## [1.34.6] - 2026-04-08
|
||||
|
||||
> I'm sorry this is already the third release today — every time we shipped a critical fix, another critical issue surfaced. Hopefully this one holds. 🤞
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Privacy Policy
|
||||
|
||||
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
|
||||
|
||||
## Data sent to third-party services
|
||||
|
||||
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
|
||||
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Last.fm
|
||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
|
||||
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
|
||||
### NetEase Cloud Music (Lyrics)
|
||||
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
|
||||
|
||||
### Apple Music / iTunes Search API
|
||||
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
|
||||
|
||||
### Discord Rich Presence
|
||||
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
|
||||
|
||||
## Data stored locally
|
||||
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Last.fm session key
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
## No telemetry
|
||||
|
||||
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
|
||||
|
||||
## Open source
|
||||
|
||||
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
|
||||
@@ -8,14 +8,19 @@
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/ckVPGPMS">
|
||||
<a href="https://discord.gg/AMnDRErm4u">
|
||||
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
|
||||
</a>
|
||||
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
|
||||
@@ -32,74 +37,26 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 67 beautiful themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations. A time-based **Theme Scheduler** can automatically switch between a day and night theme.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, Chinese, Norwegian, and Russian.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: Lyrics pane in the sidebar and fullscreen player — powered by LRCLIB and your Navidrome server. Synced lyrics auto-scroll with line highlighting and click-to-seek; plain-text fallback for unsynced tracks.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 🎛️ **10-Band Graphic EQ**: Built-in EQ with presets and the ability to save custom presets. **AutoEQ** support lets you load headphone correction profiles automatically.
|
||||
- 🔀 **Gapless & Crossfade**: True gapless playback and configurable crossfade between tracks (mutually exclusive).
|
||||
- 📻 **Internet Radio**: Built-in internet radio player — browse and play any ICY/HLS stream directly within Psysonic.
|
||||
- ⭐ **Ratings**: Rate songs, albums, and artists with 1–5 stars via the context menu, player bar, or album detail view. Supports the OpenSubsonic ratings extension. Auto-rate-down songs you skip repeatedly (configurable threshold). Filter Random Mix and Random Albums by minimum star rating.
|
||||
- 🖥️ **Fullscreen Player**: A dedicated fullscreen view with album art, animated lyrics overlay, and artist image — toggled with a single click.
|
||||
- 📋 **Playlist Management**: Create, edit, rename, and delete playlists. Drag-and-drop track reordering, song search, and smart suggestions right inside the playlist view.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays, multi-select album actions, and detailed artist pages with related albums.
|
||||
- 〰️ **Multi-Style Seekbar**: 10 canvas-drawn seekbar styles — Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker & UI Scale**: 10 UI fonts and a global zoom slider (80–150%) to match your display and taste.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔔 **System Tray**: Minimize Psysonic to the system tray. Play/Pause, Prev, Next, and Show/Hide controls available from the tray icon.
|
||||
- 💾 **Backup & Restore**: Export and import all your settings, themes, and server profiles in one click.
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
|
||||
- ⚡ **Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
|
||||
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
|
||||
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
|
||||
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
|
||||
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
|
||||
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
|
||||
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (1–5 stars), and multi-select actions.
|
||||
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
|
||||
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
|
||||
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
|
||||
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
|
||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm, NixOS flake).
|
||||
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] AutoEQ — automatic headphone correction profile loader
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Multi-style seekbar (10 styles: Waveform, Bar, Thick, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, Retro Tape)
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts, recent scrobbles, top-rated songs & artists
|
||||
- [x] Synchronized lyrics via LRCLIB and Navidrome server (in-sidebar + fullscreen, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] Internet Radio (ICY/HLS streams)
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
- [x] Advanced Search (text + genre + year + result-type filters)
|
||||
- [x] 67 themes across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Time-based Theme Scheduler (auto day/night theme switching)
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese, Norwegian, Russian)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts) + global UI scale slider
|
||||
- [x] Playlist management (create, edit, delete, drag-and-drop reorder, suggestions)
|
||||
- [x] Fullscreen player with synced lyrics overlay
|
||||
- [x] System tray icon with playback controls
|
||||
- [x] Song / Album / Artist ratings (1–5 stars, OpenSubsonic extension)
|
||||
- [x] Auto-rate-down on repeated skips + minimum-rating filter for mixes
|
||||
- [x] Album multi-select actions
|
||||
- [x] Custom Linux titlebar
|
||||
- [x] Backup & Restore (settings export/import)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 67 themes
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
|
||||
@@ -119,29 +76,9 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
|
||||
**Manual Installation:**
|
||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
||||
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
|
||||
|
||||
### 🍎 macOS
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
|
||||
> [!WARNING]
|
||||
> **Gatekeeper Note:**
|
||||
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
|
||||
> ```sh
|
||||
> xattr -cr /Applications/Psysonic.app
|
||||
> ```
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
**Arch Linux (AUR):**
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
@@ -151,6 +88,25 @@ Psysonic is available in the **AUR** in two versions. Choose the one that best f
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
**❄️ NixOS (flakes):**
|
||||
- `nix run github:Psychotoxical/psysonic` — one-shot launch
|
||||
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
|
||||
|
||||
### 🍎 macOS
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
|
||||
|
||||
> [!NOTE]
|
||||
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
1. Download and install Psysonic.
|
||||
@@ -165,6 +121,10 @@ If you want to build Psysonic from source or contribute to the project:
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v18+)
|
||||
- [Rust](https://www.rust-lang.org/) (v1.75+)
|
||||
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
|
||||
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
|
||||
- macOS: `brew install cmake`
|
||||
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
|
||||
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
|
||||
|
||||
### Setup
|
||||
@@ -199,3 +159,7 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
|
||||
|
||||
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Shell completion for `psysonic`
|
||||
|
||||
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
|
||||
|
||||
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
|
||||
|
||||
## zsh
|
||||
|
||||
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completions
|
||||
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
|
||||
fpath=(~/.zsh/completions $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
If you use a plugin manager, point its `fpath` at this repo’s `completions/` directory instead.
|
||||
|
||||
## bash
|
||||
|
||||
Source the script once (e.g. in `~/.bashrc`):
|
||||
|
||||
```sh
|
||||
source /path/to/psysonic/completions/psysonic.bash
|
||||
```
|
||||
|
||||
Use this only under **bash** (including macOS’s `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
|
||||
|
||||
## Device names after `audio-device set`
|
||||
|
||||
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
|
||||
|
||||
## Folder ids after `library set`
|
||||
|
||||
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
|
||||
@@ -0,0 +1,144 @@
|
||||
#compdef psysonic
|
||||
# Zsh completion for Psysonic CLI (see `psysonic --help`).
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_audio_device_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_audio_device_json)" || return
|
||||
local -a devs
|
||||
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
|
||||
(( $#devs )) && compadd -a devs
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_folder_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_library_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_server_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_snapshot_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --player completions
|
||||
}
|
||||
|
||||
integer i pidx=0
|
||||
for (( i = 2; i < CURRENT; i++ )); do
|
||||
[[ ${words[i]} == --player ]] && pidx=i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
|
||||
compadd help bash zsh
|
||||
return
|
||||
fi
|
||||
_psysonic_globals
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub
|
||||
if (( pidx + 1 <= CURRENT - 1 )); then
|
||||
sub=( ${words[pidx + 1,CURRENT - 1]} )
|
||||
else
|
||||
sub=()
|
||||
fi
|
||||
|
||||
integer n=${#sub[@]}
|
||||
if (( n == 0 )); then
|
||||
compadd -J verbs -X 'player command' -- \
|
||||
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
|
||||
audio-device library server search mix
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[1]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd default
|
||||
_psysonic_audio_device_ids
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
compadd all
|
||||
_psysonic_library_folder_ids
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && compadd append new
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
_psysonic_server_ids
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
compadd track album artist
|
||||
elif (( n >= 2 )); then
|
||||
_message -e descriptions 'search query'
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && compadd off all one
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && compadd 0 1 2 3 4 5
|
||||
;;
|
||||
seek)
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,167 @@
|
||||
# bash completion for Psysonic (see `psysonic --help`).
|
||||
# Install: source /path/to/completions/psysonic.bash
|
||||
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
||||
#
|
||||
# Uses no `mapfile` so bash 3.2 (macOS default) works.
|
||||
#
|
||||
# compopt is bash-only (programmable completion). Guard so sourcing this file
|
||||
# under zsh or plain sh does not print "command not found: compopt".
|
||||
|
||||
_psysonic_compopt() {
|
||||
command -v compopt &>/dev/null || return 0
|
||||
compopt "$@" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
_psysonic_compreply_from_compgen() {
|
||||
# $1 = compgen -W word list, $2 = current word
|
||||
COMPREPLY=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W "$1" -- "$2")
|
||||
}
|
||||
|
||||
_psysonic_audio_device_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_library_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_snapshot_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
local i pidx=0
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
|
||||
done
|
||||
|
||||
if (( pidx == 0 )); then
|
||||
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
|
||||
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
|
||||
return
|
||||
fi
|
||||
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
local -a sub=()
|
||||
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
|
||||
sub+=("${COMP_WORDS[i]}")
|
||||
done
|
||||
local n=${#sub[@]}
|
||||
|
||||
if (( n == 0 )); then
|
||||
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
|
||||
return
|
||||
fi
|
||||
|
||||
case ${sub[0]} in
|
||||
audio-device)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf d
|
||||
jf="$(_psysonic_audio_device_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r d; do
|
||||
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
|
||||
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'default' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
library)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf id line
|
||||
jf="$(_psysonic_library_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r id; do
|
||||
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
|
||||
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||
done < <(compgen -W 'all' -- "$cur")
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf sid
|
||||
jf="$(_psysonic_snapshot_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r sid; do
|
||||
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
|
||||
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'track album artist' "$cur"
|
||||
elif (( n >= 2 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek|volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _psysonic_complete psysonic
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
description = ''
|
||||
Psysonic for NixOS / nixpkgs: installable app + dev shell.
|
||||
|
||||
Packages:
|
||||
nix build .#psysonic # or .#default — desktop app (.desktop + icon)
|
||||
nix profile install .#psysonic
|
||||
|
||||
Run (after build, or from any clone with flake):
|
||||
nix run .#psysonic
|
||||
nix run github:Psychotoxical/psysonic
|
||||
|
||||
Development:
|
||||
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
|
||||
nix shell .#devShells.default # same environment without entering subshell semantics
|
||||
|
||||
Release pipeline updates `flake.lock` (nixpkgs pin refresh) and
|
||||
`nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push —
|
||||
see `.github/workflows/release.yml` (verify-nix job). Package version
|
||||
is read from `package.json`; nothing in this file needs manual bumping
|
||||
per release.
|
||||
'';
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forSystem = f: lib.genAttrs systems f;
|
||||
|
||||
mkShellFor =
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
gstPlugins = with pkgs.gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
rustc
|
||||
cargo
|
||||
cmake
|
||||
pkg-config
|
||||
openssl
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
|
||||
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
unset CI
|
||||
'';
|
||||
|
||||
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
|
||||
};
|
||||
|
||||
upstreamMeta = lib.importJSON ./nix/upstream-sources.json;
|
||||
|
||||
psysonicFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells = forSystem (system: { default = mkShellFor system; });
|
||||
|
||||
packages = forSystem (system: {
|
||||
psysonic = psysonicFor system;
|
||||
default = psysonicFor system;
|
||||
});
|
||||
|
||||
apps = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
in
|
||||
{
|
||||
default = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle.
|
||||
# Source: `self` (this repo). Package version is read from package.json.
|
||||
# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release
|
||||
# workflow (see .github/workflows/release.yml, verify-nix job).
|
||||
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchNpmDeps,
|
||||
npmHooks,
|
||||
rustPlatform,
|
||||
cargo,
|
||||
rustc,
|
||||
pkg-config,
|
||||
cmake,
|
||||
openssl,
|
||||
gtk3,
|
||||
webkitgtk_4_1,
|
||||
libsoup_3,
|
||||
glib-networking,
|
||||
alsa-lib,
|
||||
libayatana-appindicator,
|
||||
atk,
|
||||
cairo,
|
||||
gdk-pixbuf,
|
||||
glib,
|
||||
pango,
|
||||
librsvg,
|
||||
cargo-tauri,
|
||||
nodejs,
|
||||
makeWrapper,
|
||||
wrapGAppsHook4,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
gst_all_1,
|
||||
src,
|
||||
upstreamMeta,
|
||||
}:
|
||||
|
||||
let
|
||||
version = (lib.importJSON (src + "/package.json")).version;
|
||||
# WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base).
|
||||
gstPlugins = with gst_all_1; [
|
||||
gstreamer
|
||||
gst-plugins-base
|
||||
gst-plugins-good
|
||||
gst-plugins-bad
|
||||
];
|
||||
gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
|
||||
srcClean = lib.cleanSourceWith {
|
||||
inherit src;
|
||||
filter =
|
||||
path: _:
|
||||
let
|
||||
f = toString path;
|
||||
in
|
||||
!(lib.hasInfix "/node_modules/" f)
|
||||
&& !(lib.hasInfix "/dist/" f)
|
||||
&& !(lib.hasInfix "/target/" f)
|
||||
&& !(lib.hasInfix "/.git/" f)
|
||||
&& !(lib.hasInfix "/result/" f)
|
||||
&& !(lib.hasInfix "/.flatpak-builder/" f);
|
||||
};
|
||||
npmDeps = fetchNpmDeps {
|
||||
src = srcClean;
|
||||
hash = upstreamMeta.npmDepsHash;
|
||||
};
|
||||
cargoLockFile = src + "/src-tauri/Cargo.lock";
|
||||
in
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "psysonic";
|
||||
inherit version;
|
||||
src = srcClean;
|
||||
inherit npmDeps;
|
||||
|
||||
strictDeps = true;
|
||||
|
||||
# cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root
|
||||
dontUseCmakeConfigure = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
npmHooks.npmConfigHook
|
||||
cargo
|
||||
rustc
|
||||
rustPlatform.cargoSetupHook
|
||||
pkg-config
|
||||
cmake
|
||||
makeWrapper
|
||||
wrapGAppsHook4
|
||||
copyDesktopItems
|
||||
cargo-tauri
|
||||
nodejs
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk3
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib-networking
|
||||
openssl
|
||||
alsa-lib
|
||||
libayatana-appindicator
|
||||
atk
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
librsvg
|
||||
]
|
||||
++ gstPlugins;
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
cargoDeps = rustPlatform.importCargoLock {
|
||||
lockFile = cargoLockFile;
|
||||
# Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml.
|
||||
# Keep in sync with that block — importCargoLock needs the source to match
|
||||
# the lockfile entries for patched crates (otherwise it tries to fetch from
|
||||
# crates.io and the hash mismatches).
|
||||
outputHashes = { };
|
||||
};
|
||||
|
||||
dontUseCargoParallelJobs = true;
|
||||
|
||||
env = {
|
||||
OPENSSL_DIR = "${openssl.dev}";
|
||||
OPENSSL_LIB_DIR = "${openssl.out}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${openssl.dev}/include";
|
||||
VITE_LASTFM_API_KEY = "";
|
||||
VITE_LASTFM_API_SECRET = "";
|
||||
};
|
||||
|
||||
# beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle -v)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 src-tauri/target/release/psysonic -t $out/bin
|
||||
install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "psysonic";
|
||||
desktopName = "Psysonic";
|
||||
comment = "Subsonic-compatible music player";
|
||||
icon = "psysonic";
|
||||
exec = "psysonic";
|
||||
categories = [ "AudioVideo" "Audio" "Player" ];
|
||||
})
|
||||
];
|
||||
|
||||
postFixup = ''
|
||||
wrapProgram $out/bin/psysonic \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
|
||||
--set GDK_BACKEND x11 \
|
||||
--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
|
||||
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Desktop music player for Subsonic-compatible servers";
|
||||
homepage = "https://github.com/Psychotoxical/psysonic";
|
||||
license = lib.licenses.gpl3Only;
|
||||
mainProgram = "psysonic";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-/QZZla80pqqCrDP+lkOOwuMNRYe4AthaTW3fR4fCrck="
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Installing Psysonic on NixOS (flake)
|
||||
|
||||
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Flakes** enabled (e.g. in `configuration.nix`):
|
||||
|
||||
```nix
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
```
|
||||
|
||||
## Binary cache (Cachix)
|
||||
|
||||
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
|
||||
|
||||
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
|
||||
- **Substituter URL:** `https://psysonic.cachix.org`
|
||||
- **Public key** (trust this only if it matches what you expect from the cache owners):
|
||||
|
||||
```text
|
||||
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
|
||||
```
|
||||
|
||||
### NixOS (`configuration.nix` or a flake module)
|
||||
|
||||
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"https://psysonic.cachix.org"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
|
||||
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
|
||||
|
||||
## Install on NixOS (flake configuration)
|
||||
|
||||
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
|
||||
|
||||
### Example: top-level `flake.nix` + `nixosConfigurations`
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
psysonic.url = "github:Psychotoxical/psysonic";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs: let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{
|
||||
environment.systemPackages = [
|
||||
inputs.psysonic.packages.${system}.psysonic
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# …
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
### Pinning a revision or tag
|
||||
|
||||
Follow **`main`** (above) to track the moving branch, or pin for reproducibility:
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.34.13"; # example: release tag
|
||||
```
|
||||
|
||||
Use a tag or commit SHA that exists on GitHub; the release workflow keeps **`flake.lock`** and **`nix/upstream-sources.json`** (`npmDepsHash`) in sync on tagged releases.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
- **NixOS flake host**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#my-host
|
||||
```
|
||||
|
||||
- **Home Manager** (if used separately)
|
||||
|
||||
```bash
|
||||
home-manager switch --flake .#my-user@my-host
|
||||
```
|
||||
|
||||
## Home Manager
|
||||
|
||||
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
|
||||
|
||||
```nix
|
||||
home.packages = [
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
## Troubleshooting (Linux / WebKit)
|
||||
|
||||
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
|
||||
|
||||
## More detail in-repo
|
||||
|
||||
- **`flake.nix`** — package outputs, `devShell`, supported systems (see comments there for `nix build` / `nix develop`).
|
||||
- **`nix/psysonic.nix`** — how the app is built from this source tree.
|
||||
- **`.github/workflows/release.yml`** — `verify-nix` job: refreshes lock/npm hash and pushes store paths to Cachix on release tags.
|
||||
Generated
+187
-80
@@ -1,17 +1,31 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.42.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.42.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
@@ -22,6 +36,7 @@
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
@@ -32,6 +47,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
@@ -773,6 +789,132 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/figtree": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.2.10.tgz",
|
||||
"integrity": "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/golos-text": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/golos-text/-/golos-text-5.2.8.tgz",
|
||||
"integrity": "sha512-cLT8Gu9tSQTOjfPY+qnrqQwafUUJkZu0s9hTbQbtaeknTyV36c/FQc5hvTRvgeUOT4cp/Xf0dkHdZatwj3g2Nw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.2.7.tgz",
|
||||
"integrity": "sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/outfit": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
|
||||
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/plus-jakarta-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-iQecBizIdZxezODNHzOn4SvvRMrZL/S8k4MEXGDynCmUrImVW0VmX+tIAMqnADwH4haXlHSXqMgU6+kcfBQJdw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/rubik": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.2.8.tgz",
|
||||
"integrity": "sha512-vGDExLzB4a2Fj9mca5LqNoA2ZKcU9o+x5FEBLte/nxYkCB9hOQwZS6ZlItUv+Ssn7YMzKauGuI/Po+YueFuZbg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/space-grotesk": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
|
||||
"integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/unbounded": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.2.8.tgz",
|
||||
"integrity": "sha512-DWC/HEdNNbjMH6ngeeCAPExKMsedoY+pV3ZnRXzFcAzXuGHB6dEwsXNVQ4fiuuYMGguq9TSAEUat4Oy5prdwWQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -931,9 +1073,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -948,9 +1087,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -965,9 +1101,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -982,9 +1115,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -999,9 +1129,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1016,9 +1143,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1033,9 +1157,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1050,9 +1171,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1067,9 +1185,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1084,9 +1199,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1101,9 +1213,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1118,9 +1227,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1135,9 +1241,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1361,9 +1464,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1381,9 +1481,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1401,9 +1498,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1421,9 +1515,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1441,9 +1532,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1505,21 +1593,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz",
|
||||
"integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-global-shortcut": {
|
||||
@@ -1663,6 +1751,16 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1842,14 +1940,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
@@ -2178,9 +2276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2539,6 +2637,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2596,10 +2700,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
@@ -2896,9 +3003,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+19
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.43.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,10 +12,24 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/golos-text": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
@@ -26,6 +40,7 @@
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
@@ -36,6 +51,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.6
|
||||
pkgver=1.42.1
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -18,6 +18,7 @@ makedepends=(
|
||||
'cargo'
|
||||
'clang'
|
||||
'nasm'
|
||||
'cmake'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates latest.json for the Tauri updater from a GitHub release.
|
||||
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
|
||||
//
|
||||
// macOS-only for now — Windows + Linux are added once their signing pipelines
|
||||
// (Certum cert for Windows, native package managers for Linux) are wired up.
|
||||
//
|
||||
// Required env vars: VERSION, GITHUB_TOKEN
|
||||
// Usage: node scripts/generate-update-manifest.js
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const VERSION = process.env.VERSION;
|
||||
const REPO = 'Psychotoxical/psysonic';
|
||||
const TAG = `app-v${VERSION}`;
|
||||
|
||||
if (!VERSION) {
|
||||
console.error('VERSION env var required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Platform → update bundle filename (produced by tauri-action with updater plugin)
|
||||
const PLATFORM_FILES = {
|
||||
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
|
||||
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
|
||||
};
|
||||
|
||||
const platforms = {};
|
||||
|
||||
// A real minisign .sig file is multi-line and ~200+ chars.
|
||||
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
|
||||
function validateSignature(sig, platform, sigFile) {
|
||||
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
|
||||
` Got: ${sig}\n` +
|
||||
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
|
||||
);
|
||||
}
|
||||
if (sig.length < 80) {
|
||||
throw new Error(
|
||||
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
|
||||
const sigFile = `${filename}.sig`;
|
||||
try {
|
||||
execSync(
|
||||
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const signature = fs.readFileSync(sigFile, 'utf8').trim();
|
||||
validateSignature(signature, platform, sigFile);
|
||||
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
|
||||
platforms[platform] = { signature, url };
|
||||
console.log(`✓ ${platform}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
console.error('No platforms found — aborting manifest generation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let notes = '';
|
||||
try {
|
||||
const raw = execSync(
|
||||
`gh release view "${TAG}" --repo "${REPO}" --json body`,
|
||||
{ stdio: 'pipe' }
|
||||
).toString();
|
||||
notes = JSON.parse(raw).body ?? '';
|
||||
} catch {
|
||||
console.warn('Could not fetch release notes');
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: VERSION,
|
||||
notes,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
};
|
||||
|
||||
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
|
||||
Generated
+980
-280
File diff suppressed because it is too large
Load Diff
+37
-3
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.6"
|
||||
version = "1.43.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -32,7 +32,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
@@ -40,10 +40,34 @@ biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
@@ -52,3 +76,13 @@ thread-priority = "1"
|
||||
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
|
||||
# of failing the entire probe
|
||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||
|
||||
# Local patch for cpal's CoreAudio backend on macOS:
|
||||
# - Forces IOType::DefaultOutput for all output streams so that macOS never
|
||||
# triggers the TCC microphone-permission prompt. Upstream cpal uses
|
||||
# IOType::HalOutput when the user pins a non-default device, which routes
|
||||
# through AUHAL — that component is flagged as input-capable and triggers
|
||||
# the mic consent dialog on first launch / after each update.
|
||||
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
|
||||
# system default). Surfaced in the Settings UI.
|
||||
cpal = { path = "patches/cpal-0.15.3" }
|
||||
|
||||
@@ -12,11 +12,5 @@
|
||||
Respected by ad-hoc signatures on some macOS versions. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
|
||||
<!-- Psysonic is a music PLAYER only — no microphone access needed.
|
||||
This suppresses the macOS microphone permission prompt triggered
|
||||
by cpal/CoreAudio enumerating input devices during audio init. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Psysonic is a music player only — it does not record audio.
|
||||
This description is shown if macOS prompts for microphone access
|
||||
(triggered by CoreAudio enumerating input devices during init). -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
|
||||
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Psysonic",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "mini"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
@@ -36,7 +36,9 @@
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart"
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-start-dragging","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "ac6cbb2ba55e61665a35ab88ae136a83380d1354"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
name: cpal
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
|
||||
clippy-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update apt
|
||||
run: sudo apt update
|
||||
- name: Install alsa
|
||||
run: sudo apt-get install libasound2-dev
|
||||
- name: Install libjack
|
||||
run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
target: armv7-linux-androideabi
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all --all-features
|
||||
- name: Run clippy for Android target
|
||||
run: cargo clippy --all --features asio --features oboe/fetch-prebuilt --target armv7-linux-androideabi
|
||||
|
||||
rustfmt-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: Run rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
cargo-publish:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Update apt
|
||||
run: sudo apt update
|
||||
- name: Install alsa
|
||||
run: sudo apt-get install libasound2-dev
|
||||
- name: Verify publish crate
|
||||
uses: katyo/publish-crates@v2
|
||||
with:
|
||||
dry-run: true
|
||||
ignore-unpublished-changes: true
|
||||
- name: Publish crate
|
||||
uses: katyo/publish-crates@v2
|
||||
with:
|
||||
ignore-unpublished-changes: true
|
||||
registry-token: ${{ secrets.CRATESIO_TOKEN }}
|
||||
|
||||
ubuntu-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update apt
|
||||
run: sudo apt update
|
||||
- name: Install alsa
|
||||
run: sudo apt-get install libasound2-dev
|
||||
- name: Install libjack
|
||||
run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Run without features
|
||||
run: cargo test --all --no-default-features --verbose
|
||||
- name: Run all features
|
||||
run: cargo test --all --all-features --verbose
|
||||
|
||||
linux-check-and-test-armv7:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: armv7-unknown-linux-gnueabihf
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t cross/cpal_armv7:v1 ./
|
||||
|
||||
- name: Install cross
|
||||
run: cargo install cross
|
||||
|
||||
- name: Check without features for armv7
|
||||
run: cross check --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose
|
||||
|
||||
- name: Test without features for armv7
|
||||
run: cross test --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose
|
||||
|
||||
- name: Check all features for armv7
|
||||
run: cross check --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose
|
||||
|
||||
- name: Test all features for armv7
|
||||
run: cross test --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose
|
||||
|
||||
asmjs-wasm32-test:
|
||||
strategy:
|
||||
matrix:
|
||||
target: [wasm32-unknown-emscripten]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Emscripten toolchain
|
||||
uses: mymindstorm/setup-emsdk@v14
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
- name: Build beep example
|
||||
run: cargo build --example beep --release --target ${{ matrix.target }}
|
||||
|
||||
wasm32-bindgen-test:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
target: [wasm32-unknown-unknown]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
- name: Build beep example
|
||||
run: cargo build --example beep --target ${{ matrix.target }} --features=wasm-bindgen
|
||||
|
||||
wasm32-wasi-test:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
target: [wasm32-wasi]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
- name: Build beep example
|
||||
run: cargo build --example beep --target ${{ matrix.target }}
|
||||
|
||||
windows-test:
|
||||
strategy:
|
||||
matrix:
|
||||
version: [x86_64, i686]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install ASIO SDK
|
||||
env:
|
||||
LINK: https://www.steinberg.net/asiosdk
|
||||
run: |
|
||||
curl -L -o asio.zip $env:LINK
|
||||
7z x -oasio asio.zip
|
||||
move asio\*\* asio\
|
||||
- name: Install ASIO4ALL
|
||||
run: choco install asio4all
|
||||
- name: Install llvm and clang
|
||||
run: choco install llvm
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: ${{ matrix.version }}-pc-windows-msvc
|
||||
- name: Run without features
|
||||
run: cargo test --all --no-default-features --verbose
|
||||
- name: Run all features
|
||||
run: |
|
||||
$Env:CPAL_ASIO_DIR = "$Env:GITHUB_WORKSPACE\asio"
|
||||
cargo test --all --all-features --verbose
|
||||
|
||||
macos-test:
|
||||
runs-on: macOS-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install llvm and clang
|
||||
run: brew install llvm
|
||||
- name: Install stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build beep example
|
||||
run: cargo build --example beep
|
||||
- name: Run without features
|
||||
run: cargo test --all --no-default-features --verbose
|
||||
- name: Run all features
|
||||
run: cargo test --all --all-features --verbose
|
||||
|
||||
android-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable (Android target)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
target: armv7-linux-androideabi
|
||||
- name: Check android
|
||||
run: cargo check --example android --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
|
||||
- name: Check beep
|
||||
run: cargo check --example beep --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
|
||||
- name: Check enumerate
|
||||
run: cargo check --example enumerate --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
|
||||
- name: Check feedback
|
||||
run: cargo check --example feedback --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
|
||||
- name: Check record_wav
|
||||
run: cargo check --example record_wav --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
|
||||
|
||||
android-apk-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable (Android targets)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-linux-androideabi,aarch64-linux-android,i686-linux-android,x86_64-linux-android
|
||||
- name: Set Up Android tools
|
||||
run: |
|
||||
${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT --install "platforms;android-30"
|
||||
- name: Install Cargo APK
|
||||
run: cargo install cargo-apk
|
||||
- name: Build APK
|
||||
run: cargo apk build --example android
|
||||
|
||||
ios-build:
|
||||
runs-on: macOS-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install llvm and clang
|
||||
run: brew install llvm
|
||||
- name: Install stable (iOS targets)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-ios,x86_64-apple-ios
|
||||
- name: Install cargo lipo
|
||||
run: cargo install cargo-lipo
|
||||
- name: Build iphonesimulator feedback example
|
||||
run: cd examples/ios-feedback && xcodebuild -scheme cpal-ios-example -configuration Debug -derivedDataPath build -sdk iphonesimulator
|
||||
|
||||
wasm-beep-build:
|
||||
# this only confirms that the Rust source builds
|
||||
# and checks to prevent regressions like #721.
|
||||
#
|
||||
# It does not test the javascript/web integration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install stable (wasm32 target)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- name: Cargo Build
|
||||
working-directory: ./examples/wasm-beep
|
||||
run: cargo build --target wasm32-unknown-unknown
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
.cargo/
|
||||
.DS_Store
|
||||
recorded.wav
|
||||
rls*.log
|
||||
@@ -0,0 +1,196 @@
|
||||
# Unreleased
|
||||
|
||||
# Version 0.15.3 (2024-03-04)
|
||||
|
||||
- Add `try_with_sample_rate`, a non-panicking variant of `with_sample_rate`.
|
||||
- struct `platform::Stream` is now #[must_use].
|
||||
- enum `SupportedBufferSize` and struct `SupportedStreamConfigRange` are now `Copy`.
|
||||
- `platform::Device` is now `Clone`.
|
||||
- Remove `parking_lot` dependency in favor of the std library.
|
||||
- Fix crash on web/wasm when `atomics` flag is enabled.
|
||||
- Improve Examples: Migrate wasm example to `trunk`, Improve syth-thones example.
|
||||
- Improve CI: Update actions, Use Android 30 API level in CI, Remove `asmjs-unknown-emscripten` target.
|
||||
- Update `windows` dependency to v0.54
|
||||
- Update `jni` dependency to 0.21
|
||||
- Update `alsa` dependency to 0.9
|
||||
- Update `oboe` dependency to 0.6
|
||||
- Update `ndk` dependency to 0.8 and disable `default-features`.
|
||||
- Update `wasm-bindgen` to 0.2.89
|
||||
|
||||
# Version 0.15.2 (2023-03-30)
|
||||
|
||||
- webaudio: support multichannel output streams
|
||||
- Update `windows` dependency
|
||||
- wasapi: fix some thread panics
|
||||
|
||||
# Version 0.15.1 (2023-03-14)
|
||||
|
||||
- Add feature `oboe-shared-stdcxx` to enable `shared-stdcxx` on `oboe` for Android support
|
||||
- Remove `thiserror` dependency
|
||||
- Swith `mach` dependency to `mach2`
|
||||
|
||||
# Version 0.15.0 (2023-01-29)
|
||||
|
||||
- Update `windows-rs`, `jack`, `coreaudio-sys`, `oboe`, `alsa` dependencies
|
||||
- Switch to the `dasp_sample` crate for the sample trait
|
||||
- Switch to `web-sys` on the emscripten target
|
||||
- Adopt edition 2021
|
||||
- Add disconnection detection on Mac OS
|
||||
|
||||
# Version 0.14.1 (2022-10-23)
|
||||
|
||||
- Support the 0.6.1 release of `alsa-rs`
|
||||
- Fix `asio` feature broken in 0.14.0
|
||||
- `NetBSD` support
|
||||
- CI improvements
|
||||
|
||||
# Version 0.14.0 (2022-08-22)
|
||||
|
||||
- Switch to `windows-rs` crate
|
||||
- Turn `ndk-glue` into a dev-dependency and use `ndk-context` instead
|
||||
- Update dependencies (ndk, ndk-glue, parking_lot, once_cell, jack)
|
||||
|
||||
# Version 0.13.5 (2022-01-28)
|
||||
|
||||
- Faster sample format conversion
|
||||
- Update dependencies (ndk, oboe, ndk-glue, jack, alsa, nix)
|
||||
|
||||
# Version 0.13.4 (2021-08-08)
|
||||
|
||||
- wasapi: Allow both threading models and switch the default to STA
|
||||
- Update dependencies (core-foundation-sys, jni, rust-jack)
|
||||
- Alsa: improve stream setup parameters
|
||||
|
||||
# Version 0.13.3 (2021-03-29)
|
||||
|
||||
- Give each thread a unique name
|
||||
- Fix distortion regression on some alsa configs
|
||||
|
||||
# Version 0.13.2 (2021-03-16)
|
||||
|
||||
- Update dependencies (ndk, nix, oboe, jni, etc)
|
||||
|
||||
# Version 0.13.1 (2020-11-08)
|
||||
|
||||
- Don't panic when device is plugged out on Windows
|
||||
- Update `parking_lot` dependency
|
||||
|
||||
# Version 0.13.0 (2020-10-28)
|
||||
|
||||
- Add Android support via `oboe-rs`.
|
||||
- Add Android APK build an CI job.
|
||||
|
||||
# Version 0.12.1 (2020-07-23)
|
||||
|
||||
- Bugfix release to get the asio feature working again.
|
||||
|
||||
# Version 0.12.0 (2020-07-09)
|
||||
|
||||
- Large refactor removing the blocking EventLoop API.
|
||||
- Rename many `Format` types to `StreamConfig`:
|
||||
- `Format` type's `data_type` field renamed to `sample_format`.
|
||||
- `Shape` -> `StreamConfig` - The configuration input required to build a stream.
|
||||
- `Format` -> `SupportedStreamConfig` - Describes a single supported stream configuration.
|
||||
- `SupportedFormat` -> `SupportedStreamConfigRange` - Describes a range of supported configurations.
|
||||
- `Device::default_input/output_format` -> `Device::default_input/output_config`.
|
||||
- `Device::supported_input/output_formats` -> `Device::supported_input/output_configs`.
|
||||
- `Device::SupportedInput/OutputFormats` -> `Device::SupportedInput/OutputConfigs`.
|
||||
- `SupportedFormatsError` -> `SupportedStreamConfigsError`
|
||||
- `DefaultFormatError` -> `DefaultStreamConfigError`
|
||||
- `BuildStreamError::FormatNotSupported` -> `BuildStreamError::StreamConfigNotSupported`
|
||||
- Address deprecated use of `mem::uninitialized` in WASAPI.
|
||||
- Removed `UnknownTypeBuffer` in favour of specifying sample type.
|
||||
- Added `build_input/output_stream_raw` methods allowing for dynamically
|
||||
handling sample format type.
|
||||
- Added support for DragonFly platform.
|
||||
- Add `InputCallbackInfo` and `OutputCallbackInfo` types and update expected
|
||||
user data callback function signature to provide these.
|
||||
|
||||
# Version 0.11.0 (2019-12-11)
|
||||
|
||||
- Fix some underruns that could occur in ALSA.
|
||||
- Add name to `HostId`.
|
||||
- Use `snd_pcm_hw_params_set_buffer_time_near` rather than `set_buffer_time_max`
|
||||
in ALSA backend.
|
||||
- Remove many uses of `std::mem::uninitialized`.
|
||||
- Fix WASAPI capture logic.
|
||||
- Panic on stream ID overflow rather than returning an error.
|
||||
- Use `ringbuffer` crate in feedback example.
|
||||
- Move errors into a separate module.
|
||||
- Switch from `failure` to `thiserror` for error handling.
|
||||
- Add `winbase` winapi feature to solve windows compile error issues.
|
||||
- Lots of CI improvements.
|
||||
|
||||
# Version 0.10.0 (2019-07-05)
|
||||
|
||||
- core-foundation-sys and coreaudio-rs version bumps.
|
||||
- Add an ASIO host, available under Windows.
|
||||
- Introduce a new Host API, adding support for alternative audio APIs.
|
||||
- Remove sleep loop on macOS in favour of using a `Condvar`.
|
||||
- Allow users to handle stream callback errors with a new `StreamEvent` type.
|
||||
- Overhaul error handling throughout the crate.
|
||||
- Remove unnecessary Mutex from ALSA and WASAPI backends in favour of channels.
|
||||
- Remove `panic!` from OutputBuffer Deref impl as it is no longer necessary.
|
||||
|
||||
# Version 0.9.0 (2019-06-06)
|
||||
|
||||
- Better buffer handling
|
||||
- Fix logic error in frame/sample size
|
||||
- Added error handling for unknown ALSA device errors
|
||||
- Fix resuming a paused stream on Windows (wasapi).
|
||||
- Implement `default_output_format` for emscripten backend.
|
||||
|
||||
# Version 0.8.1 (2018-03-18)
|
||||
|
||||
- Fix the handling of non-default sample rates for coreaudio input streams.
|
||||
|
||||
# Version 0.8.0 (2018-02-15)
|
||||
|
||||
- Add `record_wav.rs` example. Records 3 seconds to
|
||||
`$CARGO_MANIFEST_DIR/recorded.wav` using default input device.
|
||||
- Update `enumerate.rs` example to display default input/output devices and
|
||||
formats.
|
||||
- Add input stream support to coreaudio, alsa and windows backends.
|
||||
- Introduce `StreamData` type for handling either input or output streams in
|
||||
`EventLoop::run` callback.
|
||||
- Add `Device::supported_{input/output}_formats` methods.
|
||||
- Add `Device::default_{input/output}_format` methods.
|
||||
- Add `default_{input/output}_device` functions.
|
||||
- Replace usage of `Voice` with `Stream` throughout the crate.
|
||||
- Remove `Endpoint` in favour of `Device` for supporting both input and output
|
||||
streams.
|
||||
|
||||
# Version 0.7.0 (2018-02-04)
|
||||
|
||||
- Rename `ChannelsCount` to `ChannelCount`.
|
||||
- Rename `SamplesRate` to `SampleRate`.
|
||||
- Rename the `min_samples_rate` field of `SupportedFormat` to `min_sample_rate`
|
||||
- Rename the `with_max_samples_rate()` method of`SupportedFormat` to `with_max_sample_rate()`
|
||||
- Rename the `samples_rate` field of `Format` to `sample_rate`
|
||||
- Changed the type of the `channels` field of the `SupportedFormat` struct from `Vec<ChannelPosition>` to `ChannelCount` (an alias to `u16`)
|
||||
- Remove unused ChannelPosition API.
|
||||
- Implement `Endpoint` and `Format` Enumeration for macOS.
|
||||
- Implement format handling for macos `build_voice` method.
|
||||
|
||||
# Version 0.6.0 (2017-12-11)
|
||||
|
||||
- Changed the emscripten backend to consume less CPU.
|
||||
- Added improvements to the crate documentation.
|
||||
- Implement `pause` and `play` for ALSA backend.
|
||||
- Reduced the number of allocations in the CoreAudio backend.
|
||||
- Fixes for macOS build (#186, #189).
|
||||
|
||||
# Version 0.5.1 (2017-10-21)
|
||||
|
||||
- Added `Sample::to_i16()`, `Sample::to_u16()` and `Sample::from`.
|
||||
|
||||
# Version 0.5.0 (2017-10-21)
|
||||
|
||||
- Removed the dependency on the `futures` library.
|
||||
- Removed the `Voice` and `SamplesStream` types.
|
||||
- Added `EventLoop::build_voice`, `EventLoop::destroy_voice`, `EventLoop::play`,
|
||||
and `EventLoop::pause` that can be used to create, destroy, play and pause voices.
|
||||
- Added a `VoiceId` struct that is now used to identify a voice owned by an `EventLoop`.
|
||||
- Changed `EventLoop::run()` to take a callback that is called whenever a voice requires sound data.
|
||||
- Changed `supported_formats()` to produce a list of `SupportedFormat` instead of `Format`. A
|
||||
`SupportedFormat` must then be turned into a `Format` in order to build a voice.
|
||||
@@ -0,0 +1,184 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.70"
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
description = "Low-level cross-platform audio I/O library in pure Rust."
|
||||
documentation = "https://docs.rs/cpal"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"audio",
|
||||
"sound",
|
||||
]
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustaudio/cpal"
|
||||
|
||||
[[example]]
|
||||
name = "android"
|
||||
crate-type = ["cdylib"]
|
||||
path = "examples/android.rs"
|
||||
|
||||
[[example]]
|
||||
name = "beep"
|
||||
|
||||
[[example]]
|
||||
name = "enumerate"
|
||||
|
||||
[[example]]
|
||||
name = "feedback"
|
||||
|
||||
[[example]]
|
||||
name = "record_wav"
|
||||
|
||||
[[example]]
|
||||
name = "synth_tones"
|
||||
|
||||
[dependencies.dasp_sample]
|
||||
version = "0.11"
|
||||
|
||||
[dev-dependencies.anyhow]
|
||||
version = "1.0"
|
||||
|
||||
[dev-dependencies.clap]
|
||||
version = "4.0"
|
||||
features = ["derive"]
|
||||
|
||||
[dev-dependencies.hound]
|
||||
version = "3.5"
|
||||
|
||||
[dev-dependencies.ringbuf]
|
||||
version = "0.3"
|
||||
|
||||
[features]
|
||||
asio = [
|
||||
"asio-sys",
|
||||
"num-traits",
|
||||
]
|
||||
oboe-shared-stdcxx = ["oboe/shared-stdcxx"]
|
||||
|
||||
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.js-sys]
|
||||
version = "0.3.35"
|
||||
|
||||
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.wasm-bindgen]
|
||||
version = "0.2.58"
|
||||
optional = true
|
||||
|
||||
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.web-sys]
|
||||
version = "0.3.35"
|
||||
features = [
|
||||
"AudioContext",
|
||||
"AudioContextOptions",
|
||||
"AudioBuffer",
|
||||
"AudioBufferSourceNode",
|
||||
"AudioNode",
|
||||
"AudioDestinationNode",
|
||||
"Window",
|
||||
"AudioContextState",
|
||||
]
|
||||
|
||||
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.alsa]
|
||||
version = "0.9"
|
||||
|
||||
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.jack]
|
||||
version = "0.11"
|
||||
optional = true
|
||||
|
||||
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.libc]
|
||||
version = "0.2"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.core-foundation-sys]
|
||||
version = "0.8.2"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.mach2]
|
||||
version = "0.4"
|
||||
|
||||
[target."cfg(target_os = \"android\")".dependencies.jni]
|
||||
version = "0.21"
|
||||
|
||||
[target."cfg(target_os = \"android\")".dependencies.ndk]
|
||||
version = "0.8"
|
||||
default-features = false
|
||||
|
||||
[target."cfg(target_os = \"android\")".dependencies.ndk-context]
|
||||
version = "0.1"
|
||||
|
||||
[target."cfg(target_os = \"android\")".dependencies.oboe]
|
||||
version = "0.6"
|
||||
features = ["java-interface"]
|
||||
|
||||
[target."cfg(target_os = \"android\")".dev-dependencies.ndk-glue]
|
||||
version = "0.7"
|
||||
|
||||
[target."cfg(target_os = \"emscripten\")".dependencies.js-sys]
|
||||
version = "0.3.35"
|
||||
|
||||
[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen]
|
||||
version = "0.2.89"
|
||||
|
||||
[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen-futures]
|
||||
version = "0.4.33"
|
||||
|
||||
[target."cfg(target_os = \"emscripten\")".dependencies.web-sys]
|
||||
version = "0.3.35"
|
||||
features = [
|
||||
"AudioContext",
|
||||
"AudioContextOptions",
|
||||
"AudioBuffer",
|
||||
"AudioBufferSourceNode",
|
||||
"AudioNode",
|
||||
"AudioDestinationNode",
|
||||
"Window",
|
||||
"AudioContextState",
|
||||
]
|
||||
|
||||
[target."cfg(target_os = \"ios\")".dependencies.coreaudio-rs]
|
||||
version = "0.11"
|
||||
features = [
|
||||
"audio_unit",
|
||||
"core_audio",
|
||||
"audio_toolbox",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies.coreaudio-rs]
|
||||
version = "0.11"
|
||||
features = [
|
||||
"audio_unit",
|
||||
"core_audio",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies.asio-sys]
|
||||
version = "0.2"
|
||||
optional = true
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies.num-traits]
|
||||
version = "0.2.6"
|
||||
optional = true
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies.windows]
|
||||
version = "0.54.0"
|
||||
features = [
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Foundation",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_Media_KernelStreaming",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Media_Multimedia",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
]
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
[package]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
description = "Low-level cross-platform audio I/O library in pure Rust."
|
||||
repository = "https://github.com/rustaudio/cpal"
|
||||
documentation = "https://docs.rs/cpal"
|
||||
license = "Apache-2.0"
|
||||
keywords = ["audio", "sound"]
|
||||
edition = "2021"
|
||||
rust-version = "1.70"
|
||||
|
||||
[features]
|
||||
asio = ["asio-sys", "num-traits"] # Only available on Windows. See README for setup instructions.
|
||||
oboe-shared-stdcxx = ["oboe/shared-stdcxx"] # Only available on Android. See README for what it does.
|
||||
|
||||
[dependencies]
|
||||
dasp_sample = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0"
|
||||
hound = "3.5"
|
||||
ringbuf = "0.3"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dev-dependencies]
|
||||
ndk-glue = "0.7"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.54.0", features = [
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Foundation",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_Media_KernelStreaming",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Media_Multimedia",
|
||||
"Win32_UI_Shell_PropertiesSystem"
|
||||
]}
|
||||
asio-sys = { version = "0.2", path = "asio-sys", optional = true }
|
||||
num-traits = { version = "0.2.6", optional = true }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd"))'.dependencies]
|
||||
alsa = "0.9"
|
||||
libc = "0.2"
|
||||
jack = { version = "0.11", optional = true }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||
core-foundation-sys = "0.8.2" # For linking to CoreFoundation.framework and handling device name `CFString`s.
|
||||
mach2 = "0.4" # For access to mach_timebase type.
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio"] }
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio", "audio_toolbox"] }
|
||||
|
||||
[target.'cfg(target_os = "emscripten")'.dependencies]
|
||||
wasm-bindgen = { version = "0.2.89" }
|
||||
wasm-bindgen-futures = "0.4.33"
|
||||
js-sys = { version = "0.3.35" }
|
||||
web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
wasm-bindgen = { version = "0.2.58", optional = true }
|
||||
js-sys = { version = "0.3.35" }
|
||||
web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
oboe = { version = "0.6", features = [ "java-interface" ] }
|
||||
ndk = { version = "0.8", default-features = false }
|
||||
ndk-context = "0.1"
|
||||
jni = "0.21"
|
||||
|
||||
[[example]]
|
||||
name = "android"
|
||||
path = "examples/android.rs"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[[example]]
|
||||
name = "beep"
|
||||
|
||||
[[example]]
|
||||
name = "enumerate"
|
||||
|
||||
[[example]]
|
||||
name = "feedback"
|
||||
|
||||
[[example]]
|
||||
name = "record_wav"
|
||||
|
||||
[[example]]
|
||||
name = "synth_tones"
|
||||
@@ -0,0 +1,7 @@
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
image = "cross/cpal_armv7:v1"
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf.env]
|
||||
passthrough = [
|
||||
"RUSTFLAGS",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM rustembedded/cross:armv7-unknown-linux-gnueabihf
|
||||
|
||||
ENV PKG_CONFIG_ALLOW_CROSS 1
|
||||
ENV PKG_CONFIG_PATH /usr/lib/arm-linux-gnueabihf/pkgconfig/
|
||||
|
||||
RUN dpkg --add-architecture armhf && \
|
||||
apt-get update && \
|
||||
apt-get install libasound2-dev:armhf -y && \
|
||||
apt-get install libjack-jackd2-dev:armhf libjack-jackd2-0:armhf -y \
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,142 @@
|
||||
# CPAL - Cross-Platform Audio Library
|
||||
|
||||
[](https://github.com/RustAudio/cpal/actions)
|
||||
[](https://crates.io/crates/cpal) [](https://docs.rs/cpal/)
|
||||
|
||||
Low-level library for audio input and output in pure Rust.
|
||||
|
||||
This library currently supports the following:
|
||||
|
||||
- Enumerate supported audio hosts.
|
||||
- Enumerate all available audio devices.
|
||||
- Get the current default input and output devices.
|
||||
- Enumerate known supported input and output stream formats for a device.
|
||||
- Get the current default input and output stream formats for a device.
|
||||
- Build and run input and output PCM streams on a chosen device with a given stream format.
|
||||
|
||||
Currently, supported hosts include:
|
||||
|
||||
- Linux (via ALSA or JACK)
|
||||
- Windows (via WASAPI by default, see ASIO instructions below)
|
||||
- macOS (via CoreAudio)
|
||||
- iOS (via CoreAudio)
|
||||
- Android (via Oboe)
|
||||
- Emscripten
|
||||
|
||||
Note that on Linux, the ALSA development files are required. These are provided
|
||||
as part of the `libasound2-dev` package on Debian and Ubuntu distributions and
|
||||
`alsa-lib-devel` on Fedora.
|
||||
|
||||
## Compiling for Web Assembly
|
||||
|
||||
If you are interested in using CPAL with WASM, please see [this guide](https://github.com/RustAudio/cpal/wiki/Setting-up-a-new-CPAL-WASM-project) in our Wiki which walks through setting up a new project from scratch.
|
||||
|
||||
## Feature flags for audio backends
|
||||
|
||||
Some audio backends are optional and will only be compiled with a [feature flag](https://doc.rust-lang.org/cargo/reference/features.html).
|
||||
|
||||
- JACK (on Linux): `jack`
|
||||
- ASIO (on Windows): `asio`
|
||||
|
||||
Oboe can either use a shared or static runtime. The static runtime is used by default, but activating the
|
||||
`oboe-shared-stdcxx` feature makes it use the shared runtime, which requires `libc++_shared.so` from the Android NDK to
|
||||
be present during execution.
|
||||
|
||||
## ASIO on Windows
|
||||
|
||||
[ASIO](https://en.wikipedia.org/wiki/Audio_Stream_Input/Output) is an audio
|
||||
driver protocol by Steinberg. While it is available on multiple operating
|
||||
systems, it is most commonly used on Windows to work around limitations of
|
||||
WASAPI including access to large numbers of channels and lower-latency audio
|
||||
processing.
|
||||
|
||||
CPAL allows for using the ASIO SDK as the audio host on Windows instead of
|
||||
WASAPI.
|
||||
|
||||
### Locating the ASIO SDK
|
||||
|
||||
The location of ASIO SDK is exposed to CPAL by setting the `CPAL_ASIO_DIR` environment variable.
|
||||
|
||||
The build script will try to find the ASIO SDK by following these steps in order:
|
||||
|
||||
1. Check if `CPAL_ASIO_DIR` is set and if so use the path to point to the SDK.
|
||||
2. Check if the ASIO SDK is already installed in the temporary directory, if so use that and set the path of `CPAL_ASIO_DIR` to the output of `std::env::temp_dir().join("asio_sdk")`.
|
||||
3. If the ASIO SDK is not already installed, download it from <https://www.steinberg.net/asiosdk> and install it in the temporary directory. The path of `CPAL_ASIO_DIR` will be set to the output of `std::env::temp_dir().join("asio_sdk")`.
|
||||
|
||||
In an ideal situation you don't need to worry about this step.
|
||||
|
||||
### Preparing the build environment
|
||||
|
||||
1. `bindgen`, the library used to generate bindings to the C++ SDK, requires
|
||||
clang. **Download and install LLVM** from
|
||||
[here](http://releases.llvm.org/download.html) under the "Pre-Built Binaries"
|
||||
section. The version as of writing this is 17.0.1.
|
||||
2. Add the LLVM `bin` directory to a `LIBCLANG_PATH` environment variable. If
|
||||
you installed LLVM to the default directory, this should work in the command
|
||||
prompt:
|
||||
```
|
||||
setx LIBCLANG_PATH "C:\Program Files\LLVM\bin"
|
||||
```
|
||||
3. If you don't have any ASIO devices or drivers available, you can [**download
|
||||
and install ASIO4ALL**](http://www.asio4all.org/). Be sure to enable the
|
||||
"offline" feature during installation despite what the installer says about
|
||||
it being useless.
|
||||
4. Our build script assumes that Microsoft Visual Studio is installed if the host OS for compilation is Windows. The script will try to find `vcvarsall.bat`
|
||||
and execute it with the right host and target machine architecture regardless of the Microsoft Visual Studio version.
|
||||
If there are any errors encountered in this process which is unlikely,
|
||||
you may find the `vcvarsall.bat` manually and execute it with your machine architecture as an argument.
|
||||
The script will detect this and skip the step.
|
||||
|
||||
A manually executed command example for 64 bit machines:
|
||||
|
||||
```
|
||||
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" amd64
|
||||
```
|
||||
|
||||
For more information please refer to the documentation of [`vcvarsall.bat``](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax).
|
||||
|
||||
5. Select the ASIO host at the start of our program with the following code:
|
||||
|
||||
```rust
|
||||
let host;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
host = cpal::host_from_id(cpal::HostId::Asio).expect("failed to initialise ASIO host");
|
||||
}
|
||||
```
|
||||
|
||||
If you run into compilations errors produced by `asio-sys` or `bindgen`, make
|
||||
sure that `CPAL_ASIO_DIR` is set correctly and try `cargo clean`.
|
||||
|
||||
6. Make sure to enable the `asio` feature when building CPAL:
|
||||
|
||||
```
|
||||
cargo build --features "asio"
|
||||
```
|
||||
|
||||
or if you are using CPAL as a dependency in a downstream project, enable the
|
||||
feature like this:
|
||||
|
||||
```toml
|
||||
cpal = { version = "*", features = ["asio"] }
|
||||
```
|
||||
|
||||
_Updated as of ASIO version 2.3.3._
|
||||
|
||||
### Cross compilation
|
||||
|
||||
When Windows is the host and the target OS, the build script of `asio-sys` supports all cross compilation targets
|
||||
which are supported by the MSVC compiler. An exhaustive list of combinations could be found [here](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax) with the addition of undocumented `arm64`, `arm64_x86`, `arm64_amd64` and `arm64_arm` targets. (5.11.2023)
|
||||
|
||||
It is also possible to compile Windows applications with ASIO support on Linux and macOS.
|
||||
|
||||
For both platforms the common way to do this is to use the [MinGW-w64](https://www.mingw-w64.org/) toolchain.
|
||||
|
||||
Make sure that you have included the `MinGW-w64` include directory in your `CPLUS_INCLUDE_PATH` environment variable.
|
||||
Make sure that LLVM is installed and include directory is also included in your `CPLUS_INCLUDE_PATH` environment variable.
|
||||
|
||||
Example for macOS for the target of `x86_64-pc-windows-gnu` where `mingw-w64` is installed via brew:
|
||||
|
||||
```
|
||||
export CPLUS_INCLUDE_PATH="$CPLUS_INCLUDE_PATH:/opt/homebrew/Cellar/mingw-w64/11.0.1/toolchain-x86_64/x86_64-w64-mingw32/include"
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::env;
|
||||
|
||||
const CPAL_ASIO_DIR: &str = "CPAL_ASIO_DIR";
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed={}", CPAL_ASIO_DIR);
|
||||
|
||||
// If ASIO directory isn't set silently return early
|
||||
// otherwise set the asio config flag
|
||||
match env::var(CPAL_ASIO_DIR) {
|
||||
Err(_) => {}
|
||||
Ok(_) => println!("cargo:rustc-cfg=asio"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
extern crate anyhow;
|
||||
extern crate cpal;
|
||||
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
SizedSample,
|
||||
};
|
||||
use cpal::{FromSample, Sample};
|
||||
|
||||
#[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "full"))]
|
||||
fn main() {
|
||||
let host = cpal::default_host();
|
||||
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.expect("failed to find output device");
|
||||
|
||||
let config = device.default_output_config().unwrap();
|
||||
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => run::<i8>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&device, &config.into()).unwrap(),
|
||||
// cpal::SampleFormat::I24 => run::<I24>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&device, &config.into()).unwrap(),
|
||||
// cpal::SampleFormat::I48 => run::<I48>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::I64 => run::<i64>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::U8 => run::<u8>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&device, &config.into()).unwrap(),
|
||||
// cpal::SampleFormat::U24 => run::<U24>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::U32 => run::<u32>(&device, &config.into()).unwrap(),
|
||||
// cpal::SampleFormat::U48 => run::<U48>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::U64 => run::<u64>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::F32 => run::<f32>(&device, &config.into()).unwrap(),
|
||||
cpal::SampleFormat::F64 => run::<f64>(&device, &config.into()).unwrap(),
|
||||
sample_format => panic!("Unsupported sample format '{sample_format}'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run<T>(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let sample_rate = config.sample_rate.0 as f32;
|
||||
let channels = config.channels as usize;
|
||||
|
||||
// Produce a sinusoid of maximum amplitude.
|
||||
let mut sample_clock = 0f32;
|
||||
let mut next_value = move || {
|
||||
sample_clock = (sample_clock + 1.0) % sample_rate;
|
||||
(sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin()
|
||||
};
|
||||
|
||||
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
write_data(data, channels, &mut next_value)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
|
||||
where
|
||||
T: Sample + FromSample<f32>,
|
||||
{
|
||||
for frame in output.chunks_mut(channels) {
|
||||
let value: T = T::from_sample(next_sample());
|
||||
for sample in frame.iter_mut() {
|
||||
*sample = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use clap::Parser;
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
FromSample, Sample, SizedSample,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "CPAL beep example", long_about = None)]
|
||||
struct Opt {
|
||||
/// The audio device to use
|
||||
#[arg(short, long, default_value_t = String::from("default"))]
|
||||
device: String,
|
||||
|
||||
/// Use the JACK host
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
#[arg(short, long)]
|
||||
#[allow(dead_code)]
|
||||
jack: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
// Conditionally compile with jack if the feature is specified.
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
// Manually check for flags. Can be passed through cargo with -- e.g.
|
||||
// cargo run --release --example beep --features jack -- --jack
|
||||
let host = if opt.jack {
|
||||
cpal::host_from_id(cpal::available_hosts()
|
||||
.into_iter()
|
||||
.find(|id| *id == cpal::HostId::Jack)
|
||||
.expect(
|
||||
"make sure --features jack is specified. only works on OSes where jack is available",
|
||||
)).expect("jack host unavailable")
|
||||
} else {
|
||||
cpal::default_host()
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
not(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
)),
|
||||
not(feature = "jack")
|
||||
))]
|
||||
let host = cpal::default_host();
|
||||
|
||||
let device = if opt.device == "default" {
|
||||
host.default_output_device()
|
||||
} else {
|
||||
host.output_devices()?
|
||||
.find(|x| x.name().map(|y| y == opt.device).unwrap_or(false))
|
||||
}
|
||||
.expect("failed to find output device");
|
||||
println!("Output device: {}", device.name()?);
|
||||
|
||||
let config = device.default_output_config().unwrap();
|
||||
println!("Default output config: {:?}", config);
|
||||
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => run::<i8>(&device, &config.into()),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&device, &config.into()),
|
||||
// cpal::SampleFormat::I24 => run::<I24>(&device, &config.into()),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&device, &config.into()),
|
||||
// cpal::SampleFormat::I48 => run::<I48>(&device, &config.into()),
|
||||
cpal::SampleFormat::I64 => run::<i64>(&device, &config.into()),
|
||||
cpal::SampleFormat::U8 => run::<u8>(&device, &config.into()),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&device, &config.into()),
|
||||
// cpal::SampleFormat::U24 => run::<U24>(&device, &config.into()),
|
||||
cpal::SampleFormat::U32 => run::<u32>(&device, &config.into()),
|
||||
// cpal::SampleFormat::U48 => run::<U48>(&device, &config.into()),
|
||||
cpal::SampleFormat::U64 => run::<u64>(&device, &config.into()),
|
||||
cpal::SampleFormat::F32 => run::<f32>(&device, &config.into()),
|
||||
cpal::SampleFormat::F64 => run::<f64>(&device, &config.into()),
|
||||
sample_format => panic!("Unsupported sample format '{sample_format}'"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run<T>(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let sample_rate = config.sample_rate.0 as f32;
|
||||
let channels = config.channels as usize;
|
||||
|
||||
// Produce a sinusoid of maximum amplitude.
|
||||
let mut sample_clock = 0f32;
|
||||
let mut next_value = move || {
|
||||
sample_clock = (sample_clock + 1.0) % sample_rate;
|
||||
(sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin()
|
||||
};
|
||||
|
||||
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
write_data(data, channels, &mut next_value)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
|
||||
where
|
||||
T: Sample + FromSample<f32>,
|
||||
{
|
||||
for frame in output.chunks_mut(channels) {
|
||||
let value: T = T::from_sample(next_sample());
|
||||
for sample in frame.iter_mut() {
|
||||
*sample = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
extern crate anyhow;
|
||||
extern crate cpal;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS);
|
||||
let available_hosts = cpal::available_hosts();
|
||||
println!("Available hosts:\n {:?}", available_hosts);
|
||||
|
||||
for host_id in available_hosts {
|
||||
println!("{}", host_id.name());
|
||||
let host = cpal::host_from_id(host_id)?;
|
||||
|
||||
let default_in = host.default_input_device().map(|e| e.name().unwrap());
|
||||
let default_out = host.default_output_device().map(|e| e.name().unwrap());
|
||||
println!(" Default Input Device:\n {:?}", default_in);
|
||||
println!(" Default Output Device:\n {:?}", default_out);
|
||||
|
||||
let devices = host.devices()?;
|
||||
println!(" Devices: ");
|
||||
for (device_index, device) in devices.enumerate() {
|
||||
println!(" {}. \"{}\"", device_index + 1, device.name()?);
|
||||
|
||||
// Input configs
|
||||
if let Ok(conf) = device.default_input_config() {
|
||||
println!(" Default input stream config:\n {:?}", conf);
|
||||
}
|
||||
let input_configs = match device.supported_input_configs() {
|
||||
Ok(f) => f.collect(),
|
||||
Err(e) => {
|
||||
println!(" Error getting supported input configs: {:?}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !input_configs.is_empty() {
|
||||
println!(" All supported input stream configs:");
|
||||
for (config_index, config) in input_configs.into_iter().enumerate() {
|
||||
println!(
|
||||
" {}.{}. {:?}",
|
||||
device_index + 1,
|
||||
config_index + 1,
|
||||
config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Output configs
|
||||
if let Ok(conf) = device.default_output_config() {
|
||||
println!(" Default output stream config:\n {:?}", conf);
|
||||
}
|
||||
let output_configs = match device.supported_output_configs() {
|
||||
Ok(f) => f.collect(),
|
||||
Err(e) => {
|
||||
println!(" Error getting supported output configs: {:?}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !output_configs.is_empty() {
|
||||
println!(" All supported output stream configs:");
|
||||
for (config_index, config) in output_configs.into_iter().enumerate() {
|
||||
println!(
|
||||
" {}.{}. {:?}",
|
||||
device_index + 1,
|
||||
config_index + 1,
|
||||
config
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Feeds back the input stream directly into the output stream.
|
||||
//!
|
||||
//! Assumes that the input and output devices can use the same stream configuration and that they
|
||||
//! support the f32 sample format.
|
||||
//!
|
||||
//! Uses a delay of `LATENCY_MS` milliseconds in case the default input and output streams are not
|
||||
//! precisely synchronised.
|
||||
|
||||
use clap::Parser;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use ringbuf::HeapRb;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "CPAL feedback example", long_about = None)]
|
||||
struct Opt {
|
||||
/// The input audio device to use
|
||||
#[arg(short, long, value_name = "IN", default_value_t = String::from("default"))]
|
||||
input_device: String,
|
||||
|
||||
/// The output audio device to use
|
||||
#[arg(short, long, value_name = "OUT", default_value_t = String::from("default"))]
|
||||
output_device: String,
|
||||
|
||||
/// Specify the delay between input and output
|
||||
#[arg(short, long, value_name = "DELAY_MS", default_value_t = 150.0)]
|
||||
latency: f32,
|
||||
|
||||
/// Use the JACK host
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
#[arg(short, long)]
|
||||
#[allow(dead_code)]
|
||||
jack: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
// Conditionally compile with jack if the feature is specified.
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
// Manually check for flags. Can be passed through cargo with -- e.g.
|
||||
// cargo run --release --example beep --features jack -- --jack
|
||||
let host = if opt.jack {
|
||||
cpal::host_from_id(cpal::available_hosts()
|
||||
.into_iter()
|
||||
.find(|id| *id == cpal::HostId::Jack)
|
||||
.expect(
|
||||
"make sure --features jack is specified. only works on OSes where jack is available",
|
||||
)).expect("jack host unavailable")
|
||||
} else {
|
||||
cpal::default_host()
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
not(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
)),
|
||||
not(feature = "jack")
|
||||
))]
|
||||
let host = cpal::default_host();
|
||||
|
||||
// Find devices.
|
||||
let input_device = if opt.input_device == "default" {
|
||||
host.default_input_device()
|
||||
} else {
|
||||
host.input_devices()?
|
||||
.find(|x| x.name().map(|y| y == opt.input_device).unwrap_or(false))
|
||||
}
|
||||
.expect("failed to find input device");
|
||||
|
||||
let output_device = if opt.output_device == "default" {
|
||||
host.default_output_device()
|
||||
} else {
|
||||
host.output_devices()?
|
||||
.find(|x| x.name().map(|y| y == opt.output_device).unwrap_or(false))
|
||||
}
|
||||
.expect("failed to find output device");
|
||||
|
||||
println!("Using input device: \"{}\"", input_device.name()?);
|
||||
println!("Using output device: \"{}\"", output_device.name()?);
|
||||
|
||||
// We'll try and use the same configuration between streams to keep it simple.
|
||||
let config: cpal::StreamConfig = input_device.default_input_config()?.into();
|
||||
|
||||
// Create a delay in case the input and output devices aren't synced.
|
||||
let latency_frames = (opt.latency / 1_000.0) * config.sample_rate.0 as f32;
|
||||
let latency_samples = latency_frames as usize * config.channels as usize;
|
||||
|
||||
// The buffer to share samples
|
||||
let ring = HeapRb::<f32>::new(latency_samples * 2);
|
||||
let (mut producer, mut consumer) = ring.split();
|
||||
|
||||
// Fill the samples with 0.0 equal to the length of the delay.
|
||||
for _ in 0..latency_samples {
|
||||
// The ring buffer has twice as much space as necessary to add latency here,
|
||||
// so this should never fail
|
||||
producer.push(0.0).unwrap();
|
||||
}
|
||||
|
||||
let input_data_fn = move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
let mut output_fell_behind = false;
|
||||
for &sample in data {
|
||||
if producer.push(sample).is_err() {
|
||||
output_fell_behind = true;
|
||||
}
|
||||
}
|
||||
if output_fell_behind {
|
||||
eprintln!("output stream fell behind: try increasing latency");
|
||||
}
|
||||
};
|
||||
|
||||
let output_data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut input_fell_behind = false;
|
||||
for sample in data {
|
||||
*sample = match consumer.pop() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
input_fell_behind = true;
|
||||
0.0
|
||||
}
|
||||
};
|
||||
}
|
||||
if input_fell_behind {
|
||||
eprintln!("input stream fell behind: try increasing latency");
|
||||
}
|
||||
};
|
||||
|
||||
// Build streams.
|
||||
println!(
|
||||
"Attempting to build both streams with f32 samples and `{:?}`.",
|
||||
config
|
||||
);
|
||||
let input_stream = input_device.build_input_stream(&config, input_data_fn, err_fn, None)?;
|
||||
let output_stream = output_device.build_output_stream(&config, output_data_fn, err_fn, None)?;
|
||||
println!("Successfully built streams.");
|
||||
|
||||
// Play the streams.
|
||||
println!(
|
||||
"Starting the input and output streams with `{}` milliseconds of latency.",
|
||||
opt.latency
|
||||
);
|
||||
input_stream.play()?;
|
||||
output_stream.play()?;
|
||||
|
||||
// Run for 3 seconds before closing.
|
||||
println!("Playing for 3 seconds... ");
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
drop(input_stream);
|
||||
drop(output_stream);
|
||||
println!("Done!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn err_fn(err: cpal::StreamError) {
|
||||
eprintln!("an error occurred on stream: {}", err);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Records a WAV file (roughly 3 seconds long) using the default input device and config.
|
||||
//!
|
||||
//! The input data is recorded to "$CARGO_MANIFEST_DIR/recorded.wav".
|
||||
|
||||
use clap::Parser;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, Sample};
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "CPAL record_wav example", long_about = None)]
|
||||
struct Opt {
|
||||
/// The audio device to use
|
||||
#[arg(short, long, default_value_t = String::from("default"))]
|
||||
device: String,
|
||||
|
||||
/// Use the JACK host
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
#[arg(short, long)]
|
||||
#[allow(dead_code)]
|
||||
jack: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
// Conditionally compile with jack if the feature is specified.
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
// Manually check for flags. Can be passed through cargo with -- e.g.
|
||||
// cargo run --release --example beep --features jack -- --jack
|
||||
let host = if opt.jack {
|
||||
cpal::host_from_id(cpal::available_hosts()
|
||||
.into_iter()
|
||||
.find(|id| *id == cpal::HostId::Jack)
|
||||
.expect(
|
||||
"make sure --features jack is specified. only works on OSes where jack is available",
|
||||
)).expect("jack host unavailable")
|
||||
} else {
|
||||
cpal::default_host()
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
not(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
)),
|
||||
not(feature = "jack")
|
||||
))]
|
||||
let host = cpal::default_host();
|
||||
|
||||
// Set up the input device and stream with the default input config.
|
||||
let device = if opt.device == "default" {
|
||||
host.default_input_device()
|
||||
} else {
|
||||
host.input_devices()?
|
||||
.find(|x| x.name().map(|y| y == opt.device).unwrap_or(false))
|
||||
}
|
||||
.expect("failed to find input device");
|
||||
|
||||
println!("Input device: {}", device.name()?);
|
||||
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.expect("Failed to get default input config");
|
||||
println!("Default input config: {:?}", config);
|
||||
|
||||
// The WAV file we're recording to.
|
||||
const PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/recorded.wav");
|
||||
let spec = wav_spec_from_config(&config);
|
||||
let writer = hound::WavWriter::create(PATH, spec)?;
|
||||
let writer = Arc::new(Mutex::new(Some(writer)));
|
||||
|
||||
// A flag to indicate that recording is in progress.
|
||||
println!("Begin recording...");
|
||||
|
||||
// Run the input stream on a separate thread.
|
||||
let writer_2 = writer.clone();
|
||||
|
||||
let err_fn = move |err| {
|
||||
eprintln!("an error occurred on stream: {}", err);
|
||||
};
|
||||
|
||||
let stream = match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data, _: &_| write_input_data::<i8, i8>(data, &writer_2),
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
cpal::SampleFormat::I16 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data, _: &_| write_input_data::<i16, i16>(data, &writer_2),
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
cpal::SampleFormat::I32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data, _: &_| write_input_data::<i32, i32>(data, &writer_2),
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||
&config.into(),
|
||||
move |data, _: &_| write_input_data::<f32, f32>(data, &writer_2),
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
sample_format => {
|
||||
return Err(anyhow::Error::msg(format!(
|
||||
"Unsupported sample format '{sample_format}'"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
stream.play()?;
|
||||
|
||||
// Let recording go for roughly three seconds.
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
drop(stream);
|
||||
writer.lock().unwrap().take().unwrap().finalize()?;
|
||||
println!("Recording {} complete!", PATH);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sample_format(format: cpal::SampleFormat) -> hound::SampleFormat {
|
||||
if format.is_float() {
|
||||
hound::SampleFormat::Float
|
||||
} else {
|
||||
hound::SampleFormat::Int
|
||||
}
|
||||
}
|
||||
|
||||
fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> hound::WavSpec {
|
||||
hound::WavSpec {
|
||||
channels: config.channels() as _,
|
||||
sample_rate: config.sample_rate().0 as _,
|
||||
bits_per_sample: (config.sample_format().sample_size() * 8) as _,
|
||||
sample_format: sample_format(config.sample_format()),
|
||||
}
|
||||
}
|
||||
|
||||
type WavWriterHandle = Arc<Mutex<Option<hound::WavWriter<BufWriter<File>>>>>;
|
||||
|
||||
fn write_input_data<T, U>(input: &[T], writer: &WavWriterHandle)
|
||||
where
|
||||
T: Sample,
|
||||
U: Sample + hound::Sample + FromSample<T>,
|
||||
{
|
||||
if let Ok(mut guard) = writer.try_lock() {
|
||||
if let Some(writer) = guard.as_mut() {
|
||||
for &sample in input.iter() {
|
||||
let sample: U = U::from_sample(sample);
|
||||
writer.write_sample(sample).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* This example expose parameter to pass generator of sample.
|
||||
Good starting point for integration of cpal into your application.
|
||||
*/
|
||||
|
||||
extern crate anyhow;
|
||||
extern crate clap;
|
||||
extern crate cpal;
|
||||
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
SizedSample,
|
||||
};
|
||||
use cpal::{FromSample, Sample};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let stream = stream_setup_for()?;
|
||||
stream.play()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(4000));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub enum Waveform {
|
||||
Sine,
|
||||
Square,
|
||||
Saw,
|
||||
Triangle,
|
||||
}
|
||||
|
||||
pub struct Oscillator {
|
||||
pub sample_rate: f32,
|
||||
pub waveform: Waveform,
|
||||
pub current_sample_index: f32,
|
||||
pub frequency_hz: f32,
|
||||
}
|
||||
|
||||
impl Oscillator {
|
||||
fn advance_sample(&mut self) {
|
||||
self.current_sample_index = (self.current_sample_index + 1.0) % self.sample_rate;
|
||||
}
|
||||
|
||||
fn set_waveform(&mut self, waveform: Waveform) {
|
||||
self.waveform = waveform;
|
||||
}
|
||||
|
||||
fn calculate_sine_output_from_freq(&self, freq: f32) -> f32 {
|
||||
let two_pi = 2.0 * std::f32::consts::PI;
|
||||
(self.current_sample_index * freq * two_pi / self.sample_rate).sin()
|
||||
}
|
||||
|
||||
fn is_multiple_of_freq_above_nyquist(&self, multiple: f32) -> bool {
|
||||
self.frequency_hz * multiple > self.sample_rate / 2.0
|
||||
}
|
||||
|
||||
fn sine_wave(&mut self) -> f32 {
|
||||
self.advance_sample();
|
||||
self.calculate_sine_output_from_freq(self.frequency_hz)
|
||||
}
|
||||
|
||||
fn generative_waveform(&mut self, harmonic_index_increment: i32, gain_exponent: f32) -> f32 {
|
||||
self.advance_sample();
|
||||
let mut output = 0.0;
|
||||
let mut i = 1;
|
||||
while !self.is_multiple_of_freq_above_nyquist(i as f32) {
|
||||
let gain = 1.0 / (i as f32).powf(gain_exponent);
|
||||
output += gain * self.calculate_sine_output_from_freq(self.frequency_hz * i as f32);
|
||||
i += harmonic_index_increment;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn square_wave(&mut self) -> f32 {
|
||||
self.generative_waveform(2, 1.0)
|
||||
}
|
||||
|
||||
fn saw_wave(&mut self) -> f32 {
|
||||
self.generative_waveform(1, 1.0)
|
||||
}
|
||||
|
||||
fn triangle_wave(&mut self) -> f32 {
|
||||
self.generative_waveform(2, 2.0)
|
||||
}
|
||||
|
||||
fn tick(&mut self) -> f32 {
|
||||
match self.waveform {
|
||||
Waveform::Sine => self.sine_wave(),
|
||||
Waveform::Square => self.square_wave(),
|
||||
Waveform::Saw => self.saw_wave(),
|
||||
Waveform::Triangle => self.triangle_wave(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_setup_for() -> Result<cpal::Stream, anyhow::Error>
|
||||
where
|
||||
{
|
||||
let (_host, device, config) = host_device_setup()?;
|
||||
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => make_stream::<i8>(&device, &config.into()),
|
||||
cpal::SampleFormat::I16 => make_stream::<i16>(&device, &config.into()),
|
||||
cpal::SampleFormat::I32 => make_stream::<i32>(&device, &config.into()),
|
||||
cpal::SampleFormat::I64 => make_stream::<i64>(&device, &config.into()),
|
||||
cpal::SampleFormat::U8 => make_stream::<u8>(&device, &config.into()),
|
||||
cpal::SampleFormat::U16 => make_stream::<u16>(&device, &config.into()),
|
||||
cpal::SampleFormat::U32 => make_stream::<u32>(&device, &config.into()),
|
||||
cpal::SampleFormat::U64 => make_stream::<u64>(&device, &config.into()),
|
||||
cpal::SampleFormat::F32 => make_stream::<f32>(&device, &config.into()),
|
||||
cpal::SampleFormat::F64 => make_stream::<f64>(&device, &config.into()),
|
||||
sample_format => Err(anyhow::Error::msg(format!(
|
||||
"Unsupported sample format '{sample_format}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_device_setup(
|
||||
) -> Result<(cpal::Host, cpal::Device, cpal::SupportedStreamConfig), anyhow::Error> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow::Error::msg("Default output device is not available"))?;
|
||||
println!("Output device : {}", device.name()?);
|
||||
|
||||
let config = device.default_output_config()?;
|
||||
println!("Default output config : {:?}", config);
|
||||
|
||||
Ok((host, device, config))
|
||||
}
|
||||
|
||||
pub fn make_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
) -> Result<cpal::Stream, anyhow::Error>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let num_channels = config.channels as usize;
|
||||
let mut oscillator = Oscillator {
|
||||
waveform: Waveform::Sine,
|
||||
sample_rate: config.sample_rate.0 as f32,
|
||||
current_sample_index: 0.0,
|
||||
frequency_hz: 440.0,
|
||||
};
|
||||
let err_fn = |err| eprintln!("Error building output sound stream: {}", err);
|
||||
|
||||
let time_at_start = std::time::Instant::now();
|
||||
println!("Time at start: {:?}", time_at_start);
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |output: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
// for 0-1s play sine, 1-2s play square, 2-3s play saw, 3-4s play triangle_wave
|
||||
let time_since_start = std::time::Instant::now()
|
||||
.duration_since(time_at_start)
|
||||
.as_secs_f32();
|
||||
if time_since_start < 1.0 {
|
||||
oscillator.set_waveform(Waveform::Sine);
|
||||
} else if time_since_start < 2.0 {
|
||||
oscillator.set_waveform(Waveform::Triangle);
|
||||
} else if time_since_start < 3.0 {
|
||||
oscillator.set_waveform(Waveform::Square);
|
||||
} else if time_since_start < 4.0 {
|
||||
oscillator.set_waveform(Waveform::Saw);
|
||||
} else {
|
||||
oscillator.set_waveform(Waveform::Sine);
|
||||
}
|
||||
process_frame(output, &mut oscillator, num_channels)
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn process_frame<SampleType>(
|
||||
output: &mut [SampleType],
|
||||
oscillator: &mut Oscillator,
|
||||
num_channels: usize,
|
||||
) where
|
||||
SampleType: Sample + FromSample<f32>,
|
||||
{
|
||||
for frame in output.chunks_mut(num_channels) {
|
||||
let value: SampleType = SampleType::from_sample(oscillator.tick());
|
||||
|
||||
// copy the same value to all channels
|
||||
for sample in frame.iter_mut() {
|
||||
*sample = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// The requested host, although supported on this platform, is unavailable.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct HostUnavailable;
|
||||
|
||||
impl Display for HostUnavailable {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("the requested host is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for HostUnavailable {}
|
||||
|
||||
/// Some error has occurred that is specific to the backend from which it was produced.
|
||||
///
|
||||
/// This error is often used as a catch-all in cases where:
|
||||
///
|
||||
/// - It is unclear exactly what error might be produced by the backend API.
|
||||
/// - It does not make sense to add a variant to the enclosing error type.
|
||||
/// - No error was expected to occur at all, but we return an error to avoid the possibility of a
|
||||
/// `panic!` caused by some unforeseen or unknown reason.
|
||||
///
|
||||
/// **Note:** If you notice a `BackendSpecificError` that you believe could be better handled in a
|
||||
/// cross-platform manner, please create an issue or submit a pull request with a patch that adds
|
||||
/// the necessary error variant to the appropriate error enum.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BackendSpecificError {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl Display for BackendSpecificError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"A backend-specific error has occurred: {}",
|
||||
self.description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BackendSpecificError {}
|
||||
|
||||
/// An error that might occur while attempting to enumerate the available devices on a system.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DevicesError {
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for DevicesError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DevicesError {}
|
||||
|
||||
impl From<BackendSpecificError> for DevicesError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that may occur while attempting to retrieve a device name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DeviceNameError {
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for DeviceNameError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DeviceNameError {}
|
||||
|
||||
impl From<BackendSpecificError> for DeviceNameError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// Error that can happen when enumerating the list of supported formats.
|
||||
#[derive(Debug)]
|
||||
pub enum SupportedStreamConfigsError {
|
||||
/// The device no longer exists. This can happen if the device is disconnected while the
|
||||
/// program is running.
|
||||
DeviceNotAvailable,
|
||||
/// We called something the C-Layer did not understand
|
||||
InvalidArgument,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for SupportedStreamConfigsError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
Self::DeviceNotAvailable => f.write_str("The requested device is no longer available. For example, it has been unplugged."),
|
||||
Self::InvalidArgument => f.write_str("Invalid argument passed to the backend. For example, this happens when trying to read capture capabilities when the device does not support it.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SupportedStreamConfigsError {}
|
||||
|
||||
impl From<BackendSpecificError> for SupportedStreamConfigsError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// May occur when attempting to request the default input or output stream format from a [`Device`](crate::Device).
|
||||
#[derive(Debug)]
|
||||
pub enum DefaultStreamConfigError {
|
||||
/// The device no longer exists. This can happen if the device is disconnected while the
|
||||
/// program is running.
|
||||
DeviceNotAvailable,
|
||||
/// Returned if e.g. the default input format was requested on an output-only audio device.
|
||||
StreamTypeNotSupported,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for DefaultStreamConfigError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
DefaultStreamConfigError::DeviceNotAvailable => f.write_str(
|
||||
"The requested device is no longer available. For example, it has been unplugged.",
|
||||
),
|
||||
DefaultStreamConfigError::StreamTypeNotSupported => {
|
||||
f.write_str("The requested stream type is not supported by the device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DefaultStreamConfigError {}
|
||||
|
||||
impl From<BackendSpecificError> for DefaultStreamConfigError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
/// Error that can happen when creating a [`Stream`](crate::Stream).
|
||||
#[derive(Debug)]
|
||||
pub enum BuildStreamError {
|
||||
/// The device no longer exists. This can happen if the device is disconnected while the
|
||||
/// program is running.
|
||||
DeviceNotAvailable,
|
||||
/// The specified stream configuration is not supported.
|
||||
StreamConfigNotSupported,
|
||||
/// We called something the C-Layer did not understand
|
||||
///
|
||||
/// On ALSA device functions called with a feature they do not support will yield this. E.g.
|
||||
/// Trying to use capture capabilities on an output only format yields this.
|
||||
InvalidArgument,
|
||||
/// Occurs if adding a new Stream ID would cause an integer overflow.
|
||||
StreamIdOverflow,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for BuildStreamError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
BuildStreamError::DeviceNotAvailable => f.write_str(
|
||||
"The requested device is no longer available. For example, it has been unplugged.",
|
||||
),
|
||||
BuildStreamError::StreamConfigNotSupported => {
|
||||
f.write_str("The requested stream configuration is not supported by the device.")
|
||||
}
|
||||
BuildStreamError::InvalidArgument => f.write_str(
|
||||
"The requested device does not support this capability (invalid argument)",
|
||||
),
|
||||
BuildStreamError::StreamIdOverflow => {
|
||||
f.write_str("Adding a new stream ID would cause an overflow")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BuildStreamError {}
|
||||
|
||||
impl From<BackendSpecificError> for BuildStreamError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that might occur when calling [`Stream::play()`](crate::traits::StreamTrait::play).
|
||||
///
|
||||
/// As of writing this, only macOS may immediately return an error while calling this method. This
|
||||
/// is because both the alsa and wasapi backends only enqueue these commands and do not process
|
||||
/// them immediately.
|
||||
#[derive(Debug)]
|
||||
pub enum PlayStreamError {
|
||||
/// The device associated with the stream is no longer available.
|
||||
DeviceNotAvailable,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for PlayStreamError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
PlayStreamError::DeviceNotAvailable => {
|
||||
f.write_str("the device associated with the stream is no longer available")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PlayStreamError {}
|
||||
|
||||
impl From<BackendSpecificError> for PlayStreamError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that might occur when calling [`Stream::pause()`](crate::traits::StreamTrait::pause).
|
||||
///
|
||||
/// As of writing this, only macOS may immediately return an error while calling this method. This
|
||||
/// is because both the alsa and wasapi backends only enqueue these commands and do not process
|
||||
/// them immediately.
|
||||
#[derive(Debug)]
|
||||
pub enum PauseStreamError {
|
||||
/// The device associated with the stream is no longer available.
|
||||
DeviceNotAvailable,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for PauseStreamError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
PauseStreamError::DeviceNotAvailable => {
|
||||
f.write_str("the device associated with the stream is no longer available")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PauseStreamError {}
|
||||
|
||||
impl From<BackendSpecificError> for PauseStreamError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that might occur while a stream is running.
|
||||
#[derive(Debug)]
|
||||
pub enum StreamError {
|
||||
/// The device no longer exists. This can happen if the device is disconnected while the
|
||||
/// program is running.
|
||||
DeviceNotAvailable,
|
||||
/// See the [`BackendSpecificError`] docs for more information about this error variant.
|
||||
BackendSpecific { err: BackendSpecificError },
|
||||
}
|
||||
|
||||
impl Display for StreamError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BackendSpecific { err } => err.fmt(f),
|
||||
StreamError::DeviceNotAvailable => f.write_str(
|
||||
"The requested device is no longer available. For example, it has been unplugged.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for StreamError {}
|
||||
|
||||
impl From<BackendSpecificError> for StreamError {
|
||||
fn from(err: BackendSpecificError) -> Self {
|
||||
Self::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::alsa;
|
||||
use super::{Device, DeviceHandles};
|
||||
use crate::{BackendSpecificError, DevicesError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// ALSA's implementation for `Devices`.
|
||||
pub struct Devices {
|
||||
hint_iter: alsa::device_name::HintIter,
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
pub fn new() -> Result<Self, DevicesError> {
|
||||
Ok(Devices {
|
||||
hint_iter: alsa::device_name::HintIter::new_str(None, "pcm")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for Devices {}
|
||||
unsafe impl Sync for Devices {}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
loop {
|
||||
match self.hint_iter.next() {
|
||||
None => return None,
|
||||
Some(hint) => {
|
||||
let name = match hint.name {
|
||||
None => continue,
|
||||
// Ignoring the `null` device.
|
||||
Some(name) if name == "null" => continue,
|
||||
Some(name) => name,
|
||||
};
|
||||
|
||||
if let Ok(handles) = DeviceHandles::open(&name) {
|
||||
return Some(Device {
|
||||
name,
|
||||
handles: Arc::new(Mutex::new(handles)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn default_input_device() -> Option<Device> {
|
||||
Some(Device {
|
||||
name: "default".to_owned(),
|
||||
handles: Arc::new(Mutex::new(Default::default())),
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn default_output_device() -> Option<Device> {
|
||||
Some(Device {
|
||||
name: "default".to_owned(),
|
||||
handles: Arc::new(Mutex::new(Default::default())),
|
||||
})
|
||||
}
|
||||
|
||||
impl From<alsa::Error> for DevicesError {
|
||||
fn from(err: alsa::Error) -> Self {
|
||||
let err: BackendSpecificError = err.into();
|
||||
err.into()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
|
||||
use super::sys;
|
||||
use crate::BackendSpecificError;
|
||||
use crate::DefaultStreamConfigError;
|
||||
use crate::DeviceNameError;
|
||||
use crate::DevicesError;
|
||||
use crate::SampleFormat;
|
||||
use crate::SampleRate;
|
||||
use crate::SupportedBufferSize;
|
||||
use crate::SupportedStreamConfig;
|
||||
use crate::SupportedStreamConfigRange;
|
||||
use crate::SupportedStreamConfigsError;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A ASIO Device
|
||||
#[derive(Clone)]
|
||||
pub struct Device {
|
||||
/// The driver represented by this device.
|
||||
pub driver: Arc<sys::Driver>,
|
||||
|
||||
// Input and/or Output stream.
|
||||
// A driver can only have one of each.
|
||||
// They need to be created at the same time.
|
||||
pub asio_streams: Arc<Mutex<sys::AsioStreams>>,
|
||||
pub current_buffer_index: Arc<AtomicI32>,
|
||||
}
|
||||
|
||||
/// All available devices.
|
||||
pub struct Devices {
|
||||
asio: Arc<sys::Asio>,
|
||||
drivers: std::vec::IntoIter<String>,
|
||||
}
|
||||
|
||||
impl PartialEq for Device {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.driver.name() == other.driver.name()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Device {}
|
||||
|
||||
impl Hash for Device {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.driver.name().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok(self.driver.name().to_string())
|
||||
}
|
||||
|
||||
/// Gets the supported input configs.
|
||||
/// TODO currently only supports the default.
|
||||
/// Need to find all possible configs.
|
||||
pub fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
// Retrieve the default config for the total supported channels and supported sample
|
||||
// format.
|
||||
let f = match self.default_input_config() {
|
||||
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
|
||||
Ok(f) => f,
|
||||
};
|
||||
|
||||
// Collect a config for every combination of supported sample rate and number of channels.
|
||||
let mut supported_configs = vec![];
|
||||
for &rate in crate::COMMON_SAMPLE_RATES {
|
||||
if !self
|
||||
.driver
|
||||
.can_sample_rate(rate.0.into())
|
||||
.ok()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for channels in 1..f.channels + 1 {
|
||||
supported_configs.push(SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: rate,
|
||||
max_sample_rate: rate,
|
||||
buffer_size: f.buffer_size,
|
||||
sample_format: f.sample_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(supported_configs.into_iter())
|
||||
}
|
||||
|
||||
/// Gets the supported output configs.
|
||||
/// TODO currently only supports the default.
|
||||
/// Need to find all possible configs.
|
||||
pub fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
// Retrieve the default config for the total supported channels and supported sample
|
||||
// format.
|
||||
let f = match self.default_output_config() {
|
||||
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
|
||||
Ok(f) => f,
|
||||
};
|
||||
|
||||
// Collect a config for every combination of supported sample rate and number of channels.
|
||||
let mut supported_configs = vec![];
|
||||
for &rate in crate::COMMON_SAMPLE_RATES {
|
||||
if !self
|
||||
.driver
|
||||
.can_sample_rate(rate.0.into())
|
||||
.ok()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for channels in 1..f.channels + 1 {
|
||||
supported_configs.push(SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: rate,
|
||||
max_sample_rate: rate,
|
||||
buffer_size: f.buffer_size,
|
||||
sample_format: f.sample_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(supported_configs.into_iter())
|
||||
}
|
||||
|
||||
/// Returns the default input config
|
||||
pub fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let channels = self.driver.channels().map_err(default_config_err)?.ins as u16;
|
||||
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
|
||||
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
|
||||
let buffer_size = SupportedBufferSize::Range {
|
||||
min: min as u32,
|
||||
max: max as u32,
|
||||
};
|
||||
// Map th ASIO sample type to a CPAL sample type
|
||||
let data_type = self.driver.input_data_type().map_err(default_config_err)?;
|
||||
let sample_format = convert_data_type(&data_type)
|
||||
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
|
||||
Ok(SupportedStreamConfig {
|
||||
channels,
|
||||
sample_rate,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the default output config
|
||||
pub fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let channels = self.driver.channels().map_err(default_config_err)?.outs as u16;
|
||||
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
|
||||
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
|
||||
let buffer_size = SupportedBufferSize::Range {
|
||||
min: min as u32,
|
||||
max: max as u32,
|
||||
};
|
||||
let data_type = self.driver.output_data_type().map_err(default_config_err)?;
|
||||
let sample_format = convert_data_type(&data_type)
|
||||
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
|
||||
Ok(SupportedStreamConfig {
|
||||
channels,
|
||||
sample_rate,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
pub fn new(asio: Arc<sys::Asio>) -> Result<Self, DevicesError> {
|
||||
let drivers = asio.driver_names().into_iter();
|
||||
Ok(Devices { asio, drivers })
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
|
||||
/// Load drivers and return device
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
loop {
|
||||
match self.drivers.next() {
|
||||
Some(name) => match self.asio.load_driver(&name) {
|
||||
Ok(driver) => {
|
||||
let driver = Arc::new(driver);
|
||||
let asio_streams = Arc::new(Mutex::new(sys::AsioStreams {
|
||||
input: None,
|
||||
output: None,
|
||||
}));
|
||||
return Some(Device {
|
||||
driver,
|
||||
asio_streams,
|
||||
current_buffer_index: Arc::new(AtomicI32::new(-1)),
|
||||
});
|
||||
}
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn convert_data_type(ty: &sys::AsioSampleType) -> Option<SampleFormat> {
|
||||
let fmt = match *ty {
|
||||
sys::AsioSampleType::ASIOSTInt16MSB => SampleFormat::I16,
|
||||
sys::AsioSampleType::ASIOSTInt16LSB => SampleFormat::I16,
|
||||
sys::AsioSampleType::ASIOSTFloat32MSB => SampleFormat::F32,
|
||||
sys::AsioSampleType::ASIOSTFloat32LSB => SampleFormat::F32,
|
||||
sys::AsioSampleType::ASIOSTInt32MSB => SampleFormat::I32,
|
||||
sys::AsioSampleType::ASIOSTInt32LSB => SampleFormat::I32,
|
||||
_ => return None,
|
||||
};
|
||||
Some(fmt)
|
||||
}
|
||||
|
||||
fn default_config_err(e: sys::AsioError) -> DefaultStreamConfigError {
|
||||
match e {
|
||||
sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => {
|
||||
DefaultStreamConfigError::DeviceNotAvailable
|
||||
}
|
||||
sys::AsioError::NoRate => DefaultStreamConfigError::StreamTypeNotSupported,
|
||||
err => {
|
||||
let description = format!("{}", err);
|
||||
BackendSpecificError { description }.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
extern crate asio_sys as sys;
|
||||
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
|
||||
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
|
||||
StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
pub use self::device::{Device, Devices, SupportedInputConfigs, SupportedOutputConfigs};
|
||||
pub use self::stream::Stream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
mod device;
|
||||
mod stream;
|
||||
|
||||
/// The host for ASIO.
|
||||
#[derive(Debug)]
|
||||
pub struct Host {
|
||||
asio: Arc<sys::Asio>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
let asio = Arc::new(sys::Asio::new());
|
||||
let host = Host { asio };
|
||||
Ok(host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
true
|
||||
//unimplemented!("check how to do this using asio-sys")
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new(self.asio.clone())
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
// ASIO has no concept of a default device, so just use the first.
|
||||
self.input_devices().ok().and_then(|mut ds| ds.next())
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
// ASIO has no concept of a default device, so just use the first.
|
||||
self.output_devices().ok().and_then(|mut ds| ds.next())
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Device::name(self)
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_input_configs(self)
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_output_configs(self)
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_input_config(self)
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_output_config(self)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Device::build_input_stream_raw(
|
||||
self,
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Device::build_output_stream_raw(
|
||||
self,
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
Stream::play(self)
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
Stream::pause(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
extern crate asio_sys as sys;
|
||||
extern crate num_traits;
|
||||
|
||||
use self::num_traits::PrimInt;
|
||||
use super::Device;
|
||||
use crate::{
|
||||
BackendSpecificError, BufferSize, BuildStreamError, Data, InputCallbackInfo,
|
||||
OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, StreamConfig, StreamError,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Stream {
|
||||
playing: Arc<AtomicBool>,
|
||||
// Ensure the `Driver` does not terminate until the last stream is dropped.
|
||||
driver: Arc<sys::Driver>,
|
||||
#[allow(dead_code)]
|
||||
asio_streams: Arc<Mutex<sys::AsioStreams>>,
|
||||
callback_id: sys::CallbackId,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
pub fn play(&self) -> Result<(), PlayStreamError> {
|
||||
self.playing.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
self.playing.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let stream_type = self.driver.input_data_type().map_err(build_stream_err)?;
|
||||
|
||||
// Ensure that the desired sample type is supported.
|
||||
let expected_sample_format = super::device::convert_data_type(&stream_type)
|
||||
.ok_or(BuildStreamError::StreamConfigNotSupported)?;
|
||||
if sample_format != expected_sample_format {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
|
||||
let num_channels = config.channels;
|
||||
let buffer_size = self.get_or_create_input_stream(config, sample_format)?;
|
||||
let cpal_num_samples = buffer_size * num_channels as usize;
|
||||
|
||||
// Create the buffer depending on the size of the data type.
|
||||
let len_bytes = cpal_num_samples * sample_format.sample_size();
|
||||
let mut interleaved = vec![0u8; len_bytes];
|
||||
|
||||
let stream_playing = Arc::new(AtomicBool::new(false));
|
||||
let playing = Arc::clone(&stream_playing);
|
||||
let asio_streams = self.asio_streams.clone();
|
||||
|
||||
// Set the input callback.
|
||||
// This is most performance critical part of the ASIO bindings.
|
||||
let config = config.clone();
|
||||
let callback_id = self.driver.add_callback(move |callback_info| unsafe {
|
||||
// If not playing return early.
|
||||
if !playing.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There is 0% chance of lock contention the host only locks when recreating streams.
|
||||
let stream_lock = asio_streams.lock().unwrap();
|
||||
let asio_stream = match stream_lock.input {
|
||||
Some(ref asio_stream) => asio_stream,
|
||||
None => return,
|
||||
};
|
||||
|
||||
/// 1. Write from the ASIO buffer to the interleaved CPAL buffer.
|
||||
/// 2. Deliver the CPAL buffer to the user callback.
|
||||
unsafe fn process_input_callback<A, D, F>(
|
||||
data_callback: &mut D,
|
||||
interleaved: &mut [u8],
|
||||
asio_stream: &sys::AsioStream,
|
||||
asio_info: &sys::CallbackInfo,
|
||||
sample_rate: crate::SampleRate,
|
||||
format: SampleFormat,
|
||||
from_endianness: F,
|
||||
) where
|
||||
A: Copy,
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
F: Fn(A) -> A,
|
||||
{
|
||||
// 1. Write the ASIO channels to the CPAL buffer.
|
||||
let interleaved: &mut [A] = cast_slice_mut(interleaved);
|
||||
let n_frames = asio_stream.buffer_size as usize;
|
||||
let n_channels = interleaved.len() / n_frames;
|
||||
let buffer_index = asio_info.buffer_index as usize;
|
||||
for ch_ix in 0..n_channels {
|
||||
let asio_channel = asio_channel_slice::<A>(asio_stream, buffer_index, ch_ix);
|
||||
for (frame, s_asio) in interleaved.chunks_mut(n_channels).zip(asio_channel) {
|
||||
frame[ch_ix] = from_endianness(*s_asio);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Deliver the interleaved buffer to the callback.
|
||||
let data = interleaved.as_mut_ptr() as *mut ();
|
||||
let len = interleaved.len();
|
||||
let data = Data::from_parts(data, len, format);
|
||||
let callback = system_time_to_stream_instant(asio_info.system_time);
|
||||
let delay = frames_to_duration(n_frames, sample_rate);
|
||||
let capture = callback
|
||||
.sub(delay)
|
||||
.expect("`capture` occurs before origin of alsa `StreamInstant`");
|
||||
let timestamp = crate::InputStreamTimestamp { callback, capture };
|
||||
let info = InputCallbackInfo { timestamp };
|
||||
data_callback(&data, &info);
|
||||
}
|
||||
|
||||
match (&stream_type, sample_format) {
|
||||
(&sys::AsioSampleType::ASIOSTInt16LSB, SampleFormat::I16) => {
|
||||
process_input_callback::<i16, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I16,
|
||||
from_le,
|
||||
);
|
||||
}
|
||||
(&sys::AsioSampleType::ASIOSTInt16MSB, SampleFormat::I16) => {
|
||||
process_input_callback::<i16, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I16,
|
||||
from_be,
|
||||
);
|
||||
}
|
||||
|
||||
(&sys::AsioSampleType::ASIOSTFloat32LSB, SampleFormat::F32) => {
|
||||
process_input_callback::<u32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F32,
|
||||
from_le,
|
||||
);
|
||||
}
|
||||
(&sys::AsioSampleType::ASIOSTFloat32MSB, SampleFormat::F32) => {
|
||||
process_input_callback::<u32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F32,
|
||||
from_be,
|
||||
);
|
||||
}
|
||||
|
||||
(&sys::AsioSampleType::ASIOSTInt32LSB, SampleFormat::I32) => {
|
||||
process_input_callback::<i32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I32,
|
||||
from_le,
|
||||
);
|
||||
}
|
||||
(&sys::AsioSampleType::ASIOSTInt32MSB, SampleFormat::I32) => {
|
||||
process_input_callback::<i32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I32,
|
||||
from_be,
|
||||
);
|
||||
}
|
||||
|
||||
(&sys::AsioSampleType::ASIOSTFloat64LSB, SampleFormat::F64) => {
|
||||
process_input_callback::<u64, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F64,
|
||||
from_le,
|
||||
);
|
||||
}
|
||||
(&sys::AsioSampleType::ASIOSTFloat64MSB, SampleFormat::F64) => {
|
||||
process_input_callback::<u64, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F64,
|
||||
from_be,
|
||||
);
|
||||
}
|
||||
|
||||
unsupported_format_pair => unreachable!(
|
||||
"`build_input_stream_raw` should have returned with unsupported \
|
||||
format {:?}",
|
||||
unsupported_format_pair
|
||||
),
|
||||
}
|
||||
});
|
||||
|
||||
let driver = self.driver.clone();
|
||||
let asio_streams = self.asio_streams.clone();
|
||||
|
||||
// Immediately start the device?
|
||||
self.driver.start().map_err(build_stream_err)?;
|
||||
|
||||
Ok(Stream {
|
||||
playing: stream_playing,
|
||||
driver,
|
||||
asio_streams,
|
||||
callback_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let stream_type = self.driver.output_data_type().map_err(build_stream_err)?;
|
||||
|
||||
// Ensure that the desired sample type is supported.
|
||||
let expected_sample_format = super::device::convert_data_type(&stream_type)
|
||||
.ok_or(BuildStreamError::StreamConfigNotSupported)?;
|
||||
if sample_format != expected_sample_format {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
|
||||
let num_channels = config.channels;
|
||||
let buffer_size = self.get_or_create_output_stream(config, sample_format)?;
|
||||
let cpal_num_samples = buffer_size * num_channels as usize;
|
||||
|
||||
// Create buffers depending on data type.
|
||||
let len_bytes = cpal_num_samples * sample_format.sample_size();
|
||||
let mut interleaved = vec![0u8; len_bytes];
|
||||
let current_buffer_index = self.current_buffer_index.clone();
|
||||
|
||||
let stream_playing = Arc::new(AtomicBool::new(false));
|
||||
let playing = Arc::clone(&stream_playing);
|
||||
let asio_streams = self.asio_streams.clone();
|
||||
|
||||
let config = config.clone();
|
||||
let callback_id = self.driver.add_callback(move |callback_info| unsafe {
|
||||
// If not playing, return early.
|
||||
if !playing.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There is 0% chance of lock contention the host only locks when recreating streams.
|
||||
let mut stream_lock = asio_streams.lock().unwrap();
|
||||
let asio_stream = match stream_lock.output {
|
||||
Some(ref mut asio_stream) => asio_stream,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Silence the ASIO buffer that is about to be used.
|
||||
//
|
||||
// This checks if any other callbacks have already silenced the buffer associated with
|
||||
// the current `buffer_index`.
|
||||
let silence =
|
||||
current_buffer_index.load(Ordering::Acquire) != callback_info.buffer_index;
|
||||
|
||||
if silence {
|
||||
current_buffer_index.store(callback_info.buffer_index, Ordering::Release);
|
||||
}
|
||||
|
||||
/// 1. Render the given callback to the given buffer of interleaved samples.
|
||||
/// 2. If required, silence the ASIO buffer.
|
||||
/// 3. Finally, write the interleaved data to the non-interleaved ASIO buffer,
|
||||
/// performing endianness conversions as necessary.
|
||||
unsafe fn process_output_callback<A, D, F>(
|
||||
data_callback: &mut D,
|
||||
interleaved: &mut [u8],
|
||||
silence_asio_buffer: bool,
|
||||
asio_stream: &mut sys::AsioStream,
|
||||
asio_info: &sys::CallbackInfo,
|
||||
sample_rate: crate::SampleRate,
|
||||
format: SampleFormat,
|
||||
mix_samples: F,
|
||||
) where
|
||||
A: Copy,
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
F: Fn(A, A) -> A,
|
||||
{
|
||||
// 1. Render interleaved buffer from callback.
|
||||
let interleaved: &mut [A] = cast_slice_mut(interleaved);
|
||||
let data = interleaved.as_mut_ptr() as *mut ();
|
||||
let len = interleaved.len();
|
||||
let mut data = Data::from_parts(data, len, format);
|
||||
let callback = system_time_to_stream_instant(asio_info.system_time);
|
||||
let n_frames = asio_stream.buffer_size as usize;
|
||||
let delay = frames_to_duration(n_frames, sample_rate);
|
||||
let playback = callback
|
||||
.add(delay)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
data_callback(&mut data, &info);
|
||||
|
||||
// 2. Silence ASIO channels if necessary.
|
||||
let n_channels = interleaved.len() / n_frames;
|
||||
let buffer_index = asio_info.buffer_index as usize;
|
||||
if silence_asio_buffer {
|
||||
for ch_ix in 0..n_channels {
|
||||
let asio_channel =
|
||||
asio_channel_slice_mut::<A>(asio_stream, buffer_index, ch_ix);
|
||||
asio_channel.align_to_mut::<u8>().1.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Write interleaved samples to ASIO channels, one channel at a time.
|
||||
for ch_ix in 0..n_channels {
|
||||
let asio_channel =
|
||||
asio_channel_slice_mut::<A>(asio_stream, buffer_index, ch_ix);
|
||||
for (frame, s_asio) in interleaved.chunks(n_channels).zip(asio_channel) {
|
||||
*s_asio = mix_samples(*s_asio, frame[ch_ix]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (sample_format, &stream_type) {
|
||||
(SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16LSB) => {
|
||||
process_output_callback::<i16, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I16,
|
||||
|old_sample, new_sample| {
|
||||
from_le(old_sample).saturating_add(new_sample).to_le()
|
||||
},
|
||||
);
|
||||
}
|
||||
(SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16MSB) => {
|
||||
process_output_callback::<i16, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I16,
|
||||
|old_sample, new_sample| {
|
||||
from_be(old_sample).saturating_add(new_sample).to_be()
|
||||
},
|
||||
);
|
||||
}
|
||||
(SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32LSB) => {
|
||||
process_output_callback::<u32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F32,
|
||||
|old_sample, new_sample| {
|
||||
(f32::from_bits(from_le(old_sample)) + f32::from_bits(new_sample))
|
||||
.to_bits()
|
||||
.to_le()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
(SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32MSB) => {
|
||||
process_output_callback::<u32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F32,
|
||||
|old_sample, new_sample| {
|
||||
(f32::from_bits(from_be(old_sample)) + f32::from_bits(new_sample))
|
||||
.to_bits()
|
||||
.to_be()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
(SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32LSB) => {
|
||||
process_output_callback::<i32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I32,
|
||||
|old_sample, new_sample| {
|
||||
from_le(old_sample).saturating_add(new_sample).to_le()
|
||||
},
|
||||
);
|
||||
}
|
||||
(SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32MSB) => {
|
||||
process_output_callback::<i32, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::I32,
|
||||
|old_sample, new_sample| {
|
||||
from_be(old_sample).saturating_add(new_sample).to_be()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
(SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64LSB) => {
|
||||
process_output_callback::<u64, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F64,
|
||||
|old_sample, new_sample| {
|
||||
(f64::from_bits(from_le(old_sample)) + f64::from_bits(new_sample))
|
||||
.to_bits()
|
||||
.to_le()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
(SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64MSB) => {
|
||||
process_output_callback::<u64, _, _>(
|
||||
&mut data_callback,
|
||||
&mut interleaved,
|
||||
silence,
|
||||
asio_stream,
|
||||
callback_info,
|
||||
config.sample_rate,
|
||||
SampleFormat::F64,
|
||||
|old_sample, new_sample| {
|
||||
(f64::from_bits(from_be(old_sample)) + f64::from_bits(new_sample))
|
||||
.to_bits()
|
||||
.to_be()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
unsupported_format_pair => unreachable!(
|
||||
"`build_output_stream_raw` should have returned with unsupported \
|
||||
format {:?}",
|
||||
unsupported_format_pair
|
||||
),
|
||||
}
|
||||
});
|
||||
|
||||
let driver = self.driver.clone();
|
||||
let asio_streams = self.asio_streams.clone();
|
||||
|
||||
// Immediately start the device?
|
||||
self.driver.start().map_err(build_stream_err)?;
|
||||
|
||||
Ok(Stream {
|
||||
playing: stream_playing,
|
||||
driver,
|
||||
asio_streams,
|
||||
callback_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new CPAL Input Stream.
|
||||
///
|
||||
/// If there is no existing ASIO Input Stream it will be created.
|
||||
///
|
||||
/// On success, the buffer size of the stream is returned.
|
||||
fn get_or_create_input_stream(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
) -> Result<usize, BuildStreamError> {
|
||||
match self.default_input_config() {
|
||||
Ok(f) => {
|
||||
let num_asio_channels = f.channels;
|
||||
check_config(&self.driver, config, sample_format, num_asio_channels)
|
||||
}
|
||||
Err(_) => Err(BuildStreamError::StreamConfigNotSupported),
|
||||
}?;
|
||||
let num_channels = config.channels as usize;
|
||||
let mut streams = self.asio_streams.lock().unwrap();
|
||||
|
||||
let buffer_size = match config.buffer_size {
|
||||
BufferSize::Fixed(v) => Some(v as i32),
|
||||
BufferSize::Default => None,
|
||||
};
|
||||
|
||||
// Either create a stream if thers none or had back the
|
||||
// size of the current one.
|
||||
match streams.input {
|
||||
Some(ref input) => Ok(input.buffer_size as usize),
|
||||
None => {
|
||||
let output = streams.output.take();
|
||||
self.driver
|
||||
.prepare_input_stream(output, num_channels, buffer_size)
|
||||
.map(|new_streams| {
|
||||
let bs = match new_streams.input {
|
||||
Some(ref inp) => inp.buffer_size as usize,
|
||||
None => unreachable!(),
|
||||
};
|
||||
*streams = new_streams;
|
||||
bs
|
||||
})
|
||||
.map_err(|ref e| {
|
||||
println!("Error preparing stream: {}", e);
|
||||
BuildStreamError::DeviceNotAvailable
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new CPAL Output Stream.
|
||||
///
|
||||
/// If there is no existing ASIO Output Stream it will be created.
|
||||
fn get_or_create_output_stream(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
) -> Result<usize, BuildStreamError> {
|
||||
match self.default_output_config() {
|
||||
Ok(f) => {
|
||||
let num_asio_channels = f.channels;
|
||||
check_config(&self.driver, config, sample_format, num_asio_channels)
|
||||
}
|
||||
Err(_) => Err(BuildStreamError::StreamConfigNotSupported),
|
||||
}?;
|
||||
let num_channels = config.channels as usize;
|
||||
let mut streams = self.asio_streams.lock().unwrap();
|
||||
|
||||
let buffer_size = match config.buffer_size {
|
||||
BufferSize::Fixed(v) => Some(v as i32),
|
||||
BufferSize::Default => None,
|
||||
};
|
||||
|
||||
// Either create a stream if thers none or had back the
|
||||
// size of the current one.
|
||||
match streams.output {
|
||||
Some(ref output) => Ok(output.buffer_size as usize),
|
||||
None => {
|
||||
let input = streams.input.take();
|
||||
self.driver
|
||||
.prepare_output_stream(input, num_channels, buffer_size)
|
||||
.map(|new_streams| {
|
||||
let bs = match new_streams.output {
|
||||
Some(ref out) => out.buffer_size as usize,
|
||||
None => unreachable!(),
|
||||
};
|
||||
*streams = new_streams;
|
||||
bs
|
||||
})
|
||||
.map_err(|ref e| {
|
||||
println!("Error preparing stream: {}", e);
|
||||
BuildStreamError::DeviceNotAvailable
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
fn drop(&mut self) {
|
||||
self.driver.remove_callback(self.callback_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn asio_ns_to_double(val: sys::bindings::asio_import::ASIOTimeStamp) -> f64 {
|
||||
let two_raised_to_32 = 4294967296.0;
|
||||
val.lo as f64 + val.hi as f64 * two_raised_to_32
|
||||
}
|
||||
|
||||
/// Asio retrieves system time via `timeGetTime` which returns the time in milliseconds.
|
||||
fn system_time_to_stream_instant(
|
||||
system_time: sys::bindings::asio_import::ASIOTimeStamp,
|
||||
) -> crate::StreamInstant {
|
||||
let systime_ns = asio_ns_to_double(system_time);
|
||||
let secs = systime_ns as i64 / 1_000_000_000;
|
||||
let nanos = (systime_ns as i64 - secs * 1_000_000_000) as u32;
|
||||
crate::StreamInstant::new(secs, nanos)
|
||||
}
|
||||
|
||||
/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
|
||||
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
|
||||
let secsf = frames as f64 / rate.0 as f64;
|
||||
let secs = secsf as u64;
|
||||
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
|
||||
std::time::Duration::new(secs, nanos)
|
||||
}
|
||||
|
||||
/// Check whether or not the desired config is supported by the stream.
|
||||
///
|
||||
/// Checks sample rate, data type and then finally the number of channels.
|
||||
fn check_config(
|
||||
driver: &sys::Driver,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
num_asio_channels: u16,
|
||||
) -> Result<(), BuildStreamError> {
|
||||
let StreamConfig {
|
||||
channels,
|
||||
sample_rate,
|
||||
buffer_size: _,
|
||||
} = config;
|
||||
// Try and set the sample rate to what the user selected.
|
||||
let sample_rate = sample_rate.0.into();
|
||||
if sample_rate != driver.sample_rate().map_err(build_stream_err)? {
|
||||
if driver
|
||||
.can_sample_rate(sample_rate)
|
||||
.map_err(build_stream_err)?
|
||||
{
|
||||
driver
|
||||
.set_sample_rate(sample_rate)
|
||||
.map_err(build_stream_err)?;
|
||||
} else {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
}
|
||||
// unsigned formats are not supported by asio
|
||||
match sample_format {
|
||||
SampleFormat::I16 | SampleFormat::I32 | SampleFormat::F32 => (),
|
||||
_ => return Err(BuildStreamError::StreamConfigNotSupported),
|
||||
}
|
||||
if *channels > num_asio_channels {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cast a byte slice into a mutable slice of desired type.
|
||||
///
|
||||
/// Safety: it's up to the caller to ensure that the input slice has valid bit representations.
|
||||
unsafe fn cast_slice_mut<T>(v: &mut [u8]) -> &mut [T] {
|
||||
debug_assert!(v.len() % std::mem::size_of::<T>() == 0);
|
||||
std::slice::from_raw_parts_mut(v.as_mut_ptr() as *mut T, v.len() / std::mem::size_of::<T>())
|
||||
}
|
||||
|
||||
/// Helper function to convert from little endianness.
|
||||
fn from_le<T: PrimInt>(t: T) -> T {
|
||||
T::from_le(t)
|
||||
}
|
||||
|
||||
/// Helper function to convert from little endianness.
|
||||
fn from_be<T: PrimInt>(t: T) -> T {
|
||||
T::from_be(t)
|
||||
}
|
||||
|
||||
/// Shorthand for retrieving the asio buffer slice associated with a channel.
|
||||
unsafe fn asio_channel_slice<T>(
|
||||
asio_stream: &sys::AsioStream,
|
||||
buffer_index: usize,
|
||||
channel_index: usize,
|
||||
) -> &[T] {
|
||||
let buff_ptr: *const T =
|
||||
asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *const _;
|
||||
std::slice::from_raw_parts(buff_ptr, asio_stream.buffer_size as usize)
|
||||
}
|
||||
|
||||
/// Shorthand for retrieving the asio buffer slice associated with a channel.
|
||||
unsafe fn asio_channel_slice_mut<T>(
|
||||
asio_stream: &mut sys::AsioStream,
|
||||
buffer_index: usize,
|
||||
channel_index: usize,
|
||||
) -> &mut [T] {
|
||||
let buff_ptr: *mut T =
|
||||
asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *mut _;
|
||||
std::slice::from_raw_parts_mut(buff_ptr, asio_stream.buffer_size as usize)
|
||||
}
|
||||
|
||||
fn build_stream_err(e: sys::AsioError) -> BuildStreamError {
|
||||
match e {
|
||||
sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => {
|
||||
BuildStreamError::DeviceNotAvailable
|
||||
}
|
||||
sys::AsioError::InvalidInput | sys::AsioError::BadMode => BuildStreamError::InvalidArgument,
|
||||
err => {
|
||||
let description = format!("{}", err);
|
||||
BackendSpecificError { description }.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::vec::IntoIter as VecIntoIter;
|
||||
|
||||
use crate::DevicesError;
|
||||
use crate::SupportedStreamConfigRange;
|
||||
|
||||
use super::Device;
|
||||
|
||||
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
|
||||
// TODO: Support enumerating earpiece vs headset vs speaker etc?
|
||||
pub struct Devices(VecIntoIter<Device>);
|
||||
|
||||
impl Devices {
|
||||
pub fn new() -> Result<Self, DevicesError> {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Devices {
|
||||
fn default() -> Devices {
|
||||
Devices(vec![Device].into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
self.0.next()
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn default_input_device() -> Option<Device> {
|
||||
Some(Device)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn default_output_device() -> Option<Device> {
|
||||
Some(Device)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//!
|
||||
//! coreaudio on iOS looks a bit different from macOS. A lot of configuration needs to use
|
||||
//! the AVAudioSession objc API which doesn't exist on macOS.
|
||||
//!
|
||||
//! TODO:
|
||||
//! - Use AVAudioSession to enumerate buffer size / sample rate / number of channels and set
|
||||
//! buffer size.
|
||||
//!
|
||||
|
||||
extern crate core_foundation_sys;
|
||||
extern crate coreaudio;
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use self::coreaudio::audio_unit::render_callback::data;
|
||||
use self::coreaudio::audio_unit::{render_callback, AudioUnit, Element, Scope};
|
||||
use self::coreaudio::sys::{
|
||||
kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, AudioBuffer,
|
||||
AudioStreamBasicDescription,
|
||||
};
|
||||
|
||||
use super::{asbd_from_config, frames_to_duration, host_time_to_stream_instant};
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
use crate::{
|
||||
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
|
||||
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
|
||||
PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize,
|
||||
SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
use self::enumerate::{
|
||||
default_input_device, default_output_device, Devices, SupportedInputConfigs,
|
||||
SupportedOutputConfigs,
|
||||
};
|
||||
use std::slice;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod enumerate;
|
||||
|
||||
// These days the default of iOS is now F32 and no longer I16
|
||||
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Device;
|
||||
|
||||
pub struct Host;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
default_input_device()
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
default_output_device()
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok("Default Device".to_owned())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
// TODO: query AVAudioSession for parameters, some values like sample rate and buffer size
|
||||
// probably need to actually be set to see if it works, but channels can be enumerated.
|
||||
|
||||
let asbd: AudioStreamBasicDescription = default_input_asbd()?;
|
||||
let stream_config = stream_config_from_asbd(asbd);
|
||||
Ok(vec![SupportedStreamConfigRange {
|
||||
channels: stream_config.channels,
|
||||
min_sample_rate: stream_config.sample_rate,
|
||||
max_sample_rate: stream_config.sample_rate,
|
||||
buffer_size: stream_config.buffer_size.clone(),
|
||||
sample_format: SUPPORTED_SAMPLE_FORMAT,
|
||||
}]
|
||||
.into_iter())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
// TODO: query AVAudioSession for parameters, some values like sample rate and buffer size
|
||||
// probably need to actually be set to see if it works, but channels can be enumerated.
|
||||
|
||||
let asbd: AudioStreamBasicDescription = default_output_asbd()?;
|
||||
let stream_config = stream_config_from_asbd(asbd);
|
||||
|
||||
let configs: Vec<_> = (1..=asbd.mChannelsPerFrame as u16)
|
||||
.map(|channels| SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: stream_config.sample_rate,
|
||||
max_sample_rate: stream_config.sample_rate,
|
||||
buffer_size: stream_config.buffer_size.clone(),
|
||||
sample_format: SUPPORTED_SAMPLE_FORMAT,
|
||||
})
|
||||
.collect();
|
||||
Ok(configs.into_iter())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let asbd: AudioStreamBasicDescription = default_input_asbd()?;
|
||||
let stream_config = stream_config_from_asbd(asbd);
|
||||
Ok(stream_config)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let asbd: AudioStreamBasicDescription = default_output_asbd()?;
|
||||
let stream_config = stream_config_from_asbd(asbd);
|
||||
Ok(stream_config)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Device::name(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_input_configs(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_output_configs(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_input_config(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_output_config(self)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
mut error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
// The scope and element for working with a device's input stream.
|
||||
let scope = Scope::Output;
|
||||
let element = Element::Input;
|
||||
|
||||
let mut audio_unit = create_audio_unit()?;
|
||||
audio_unit.uninitialize()?;
|
||||
configure_for_recording(&mut audio_unit)?;
|
||||
audio_unit.initialize()?;
|
||||
|
||||
// Set the stream in interleaved mode.
|
||||
let asbd = asbd_from_config(config, sample_format);
|
||||
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
|
||||
|
||||
// Set the buffersize
|
||||
match config.buffer_size {
|
||||
BufferSize::Fixed(_) => {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
BufferSize::Default => (),
|
||||
}
|
||||
|
||||
// Register the callback that is being called by coreaudio whenever it needs data to be
|
||||
// fed to the audio buffer.
|
||||
let bytes_per_channel = sample_format.sample_size();
|
||||
let sample_rate = config.sample_rate;
|
||||
type Args = render_callback::Args<data::Raw>;
|
||||
audio_unit.set_input_callback(move |args: Args| unsafe {
|
||||
let ptr = (*args.data.data).mBuffers.as_ptr() as *const AudioBuffer;
|
||||
let len = (*args.data.data).mNumberBuffers as usize;
|
||||
let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len);
|
||||
|
||||
// There is only 1 buffer when using interleaved channels
|
||||
let AudioBuffer {
|
||||
mNumberChannels: channels,
|
||||
mDataByteSize: data_byte_size,
|
||||
mData: data,
|
||||
} = buffers[0];
|
||||
|
||||
let data = data as *mut ();
|
||||
let len = (data_byte_size as usize / bytes_per_channel) as usize;
|
||||
let data = Data::from_parts(data, len, sample_format);
|
||||
|
||||
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
|
||||
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
|
||||
Err(err) => {
|
||||
error_callback(err.into());
|
||||
return Err(());
|
||||
}
|
||||
Ok(cb) => cb,
|
||||
};
|
||||
let buffer_frames = len / channels as usize;
|
||||
let delay = frames_to_duration(buffer_frames, sample_rate);
|
||||
let capture = callback
|
||||
.sub(delay)
|
||||
.expect("`capture` occurs before origin of alsa `StreamInstant`");
|
||||
let timestamp = crate::InputStreamTimestamp { callback, capture };
|
||||
|
||||
let info = InputCallbackInfo { timestamp };
|
||||
data_callback(&data, &info);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
audio_unit.start()?;
|
||||
|
||||
Ok(Stream::new(StreamInner {
|
||||
playing: true,
|
||||
audio_unit,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create an output stream.
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
mut error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
match config.buffer_size {
|
||||
BufferSize::Fixed(_) => {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
BufferSize::Default => (),
|
||||
};
|
||||
|
||||
let mut audio_unit = create_audio_unit()?;
|
||||
|
||||
// The scope and element for working with a device's output stream.
|
||||
let scope = Scope::Input;
|
||||
let element = Element::Output;
|
||||
|
||||
// Set the stream in interleaved mode.
|
||||
let asbd = asbd_from_config(config, sample_format);
|
||||
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
|
||||
|
||||
// Register the callback that is being called by coreaudio whenever it needs data to be
|
||||
// fed to the audio buffer.
|
||||
let bytes_per_channel = sample_format.sample_size();
|
||||
let sample_rate = config.sample_rate;
|
||||
type Args = render_callback::Args<data::Raw>;
|
||||
audio_unit.set_render_callback(move |args: Args| unsafe {
|
||||
// If `run()` is currently running, then a callback will be available from this list.
|
||||
// Otherwise, we just fill the buffer with zeroes and return.
|
||||
|
||||
let AudioBuffer {
|
||||
mNumberChannels: channels,
|
||||
mDataByteSize: data_byte_size,
|
||||
mData: data,
|
||||
} = (*args.data.data).mBuffers[0];
|
||||
|
||||
let data = data as *mut ();
|
||||
let len = (data_byte_size as usize / bytes_per_channel) as usize;
|
||||
let mut data = Data::from_parts(data, len, sample_format);
|
||||
|
||||
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
|
||||
Err(err) => {
|
||||
error_callback(err.into());
|
||||
return Err(());
|
||||
}
|
||||
Ok(cb) => cb,
|
||||
};
|
||||
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
|
||||
let buffer_frames = len / channels as usize;
|
||||
let delay = frames_to_duration(buffer_frames, sample_rate);
|
||||
let playback = callback
|
||||
.add(delay)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
data_callback(&mut data, &info);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
audio_unit.start()?;
|
||||
|
||||
Ok(Stream::new(StreamInner {
|
||||
playing: true,
|
||||
audio_unit,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stream {
|
||||
inner: RefCell<StreamInner>,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn new(inner: StreamInner) -> Self {
|
||||
Self {
|
||||
inner: RefCell::new(inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
let mut stream = self.inner.borrow_mut();
|
||||
|
||||
if !stream.playing {
|
||||
if let Err(e) = stream.audio_unit.start() {
|
||||
let description = format!("{}", e);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
stream.playing = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
let mut stream = self.inner.borrow_mut();
|
||||
|
||||
if stream.playing {
|
||||
if let Err(e) = stream.audio_unit.stop() {
|
||||
let description = format!("{}", e);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
stream.playing = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamInner {
|
||||
playing: bool,
|
||||
audio_unit: AudioUnit,
|
||||
}
|
||||
|
||||
fn create_audio_unit() -> Result<AudioUnit, coreaudio::Error> {
|
||||
AudioUnit::new(coreaudio::audio_unit::IOType::RemoteIO)
|
||||
}
|
||||
|
||||
fn configure_for_recording(audio_unit: &mut AudioUnit) -> Result<(), coreaudio::Error> {
|
||||
// Enable mic recording
|
||||
let enable_input = 1u32;
|
||||
audio_unit.set_property(
|
||||
kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Input,
|
||||
Element::Input,
|
||||
Some(&enable_input),
|
||||
)?;
|
||||
|
||||
// Disable output
|
||||
let disable_output = 0u32;
|
||||
audio_unit.set_property(
|
||||
kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Output,
|
||||
Element::Output,
|
||||
Some(&disable_output),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_output_asbd() -> Result<AudioStreamBasicDescription, coreaudio::Error> {
|
||||
let audio_unit = create_audio_unit()?;
|
||||
let id = kAudioUnitProperty_StreamFormat;
|
||||
let asbd: AudioStreamBasicDescription =
|
||||
audio_unit.get_property(id, Scope::Output, Element::Output)?;
|
||||
Ok(asbd)
|
||||
}
|
||||
|
||||
fn default_input_asbd() -> Result<AudioStreamBasicDescription, coreaudio::Error> {
|
||||
let mut audio_unit = create_audio_unit()?;
|
||||
audio_unit.uninitialize()?;
|
||||
configure_for_recording(&mut audio_unit)?;
|
||||
audio_unit.initialize()?;
|
||||
|
||||
let id = kAudioUnitProperty_StreamFormat;
|
||||
let asbd: AudioStreamBasicDescription =
|
||||
audio_unit.get_property(id, Scope::Input, Element::Input)?;
|
||||
Ok(asbd)
|
||||
}
|
||||
|
||||
fn stream_config_from_asbd(asbd: AudioStreamBasicDescription) -> SupportedStreamConfig {
|
||||
let buffer_size = SupportedBufferSize::Range { min: 0, max: 0 };
|
||||
SupportedStreamConfig {
|
||||
channels: asbd.mChannelsPerFrame as u16,
|
||||
sample_rate: SampleRate(asbd.mSampleRate as u32),
|
||||
buffer_size: buffer_size.clone(),
|
||||
sample_format: SUPPORTED_SAMPLE_FORMAT,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
extern crate coreaudio;
|
||||
|
||||
use self::coreaudio::sys::{
|
||||
kAudioHardwareNoError, kAudioHardwarePropertyDefaultInputDevice,
|
||||
kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices,
|
||||
kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject,
|
||||
AudioDeviceID, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize,
|
||||
AudioObjectPropertyAddress, OSStatus,
|
||||
};
|
||||
use super::Device;
|
||||
use crate::{BackendSpecificError, DevicesError, SupportedStreamConfigRange};
|
||||
use std::mem;
|
||||
use std::ptr::null;
|
||||
use std::vec::IntoIter as VecIntoIter;
|
||||
|
||||
unsafe fn audio_devices() -> Result<Vec<AudioDeviceID>, OSStatus> {
|
||||
let property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
|
||||
macro_rules! try_status_or_return {
|
||||
($status:expr) => {
|
||||
if $status != kAudioHardwareNoError as i32 {
|
||||
return Err($status);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let data_size = 0u32;
|
||||
let status = AudioObjectGetPropertyDataSize(
|
||||
kAudioObjectSystemObject,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
);
|
||||
try_status_or_return!(status);
|
||||
|
||||
let device_count = data_size / mem::size_of::<AudioDeviceID>() as u32;
|
||||
let mut audio_devices = vec![];
|
||||
audio_devices.reserve_exact(device_count as usize);
|
||||
|
||||
let status = AudioObjectGetPropertyData(
|
||||
kAudioObjectSystemObject,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
audio_devices.as_mut_ptr() as *mut _,
|
||||
);
|
||||
try_status_or_return!(status);
|
||||
|
||||
audio_devices.set_len(device_count as usize);
|
||||
|
||||
Ok(audio_devices)
|
||||
}
|
||||
|
||||
pub struct Devices(VecIntoIter<AudioDeviceID>);
|
||||
|
||||
impl Devices {
|
||||
pub fn new() -> Result<Self, DevicesError> {
|
||||
let devices = unsafe {
|
||||
match audio_devices() {
|
||||
Ok(devices) => devices,
|
||||
Err(os_status) => {
|
||||
let description = format!("{}", os_status);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Devices(devices.into_iter()))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for Devices {}
|
||||
unsafe impl Sync for Devices {}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
self.0.next().map(|id| Device {
|
||||
audio_device_id: id,
|
||||
is_default: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_input_device() -> Option<Device> {
|
||||
let property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
|
||||
let audio_device_id: AudioDeviceID = 0;
|
||||
let data_size = mem::size_of::<AudioDeviceID>();
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
kAudioObjectSystemObject,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&audio_device_id as *const _ as *mut _,
|
||||
)
|
||||
};
|
||||
if status != kAudioHardwareNoError as i32 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device = Device {
|
||||
audio_device_id,
|
||||
is_default: true,
|
||||
};
|
||||
Some(device)
|
||||
}
|
||||
|
||||
pub fn default_output_device() -> Option<Device> {
|
||||
let property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
|
||||
let audio_device_id: AudioDeviceID = 0;
|
||||
let data_size = mem::size_of::<AudioDeviceID>();
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
kAudioObjectSystemObject,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&audio_device_id as *const _ as *mut _,
|
||||
)
|
||||
};
|
||||
if status != kAudioHardwareNoError as i32 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device = Device {
|
||||
audio_device_id,
|
||||
is_default: true,
|
||||
};
|
||||
Some(device)
|
||||
}
|
||||
|
||||
pub type SupportedInputConfigs = VecIntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = VecIntoIter<SupportedStreamConfigRange>;
|
||||
@@ -0,0 +1,948 @@
|
||||
extern crate core_foundation_sys;
|
||||
extern crate coreaudio;
|
||||
|
||||
use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant};
|
||||
|
||||
use self::core_foundation_sys::string::{CFStringGetCString, CFStringGetCStringPtr, CFStringRef};
|
||||
use self::coreaudio::audio_unit::render_callback::{self, data};
|
||||
use self::coreaudio::audio_unit::{AudioUnit, Element, Scope};
|
||||
use self::coreaudio::sys::{
|
||||
kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize,
|
||||
kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive,
|
||||
kAudioDevicePropertyDeviceNameCFString, kAudioDevicePropertyNominalSampleRate,
|
||||
kAudioDevicePropertyScopeOutput, kAudioDevicePropertyStreamConfiguration,
|
||||
kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMaster,
|
||||
kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput,
|
||||
kAudioObjectPropertyScopeOutput, kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, kCFStringEncodingUTF8,
|
||||
AudioBuffer, AudioBufferList, AudioDeviceID, AudioObjectGetPropertyData,
|
||||
AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress,
|
||||
AudioObjectPropertyScope, AudioObjectSetPropertyData, AudioStreamBasicDescription,
|
||||
AudioValueRange, OSStatus,
|
||||
};
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data,
|
||||
DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo,
|
||||
PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError,
|
||||
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
|
||||
SupportedStreamConfigsError,
|
||||
};
|
||||
use std::ffi::CStr;
|
||||
use std::fmt;
|
||||
use std::mem;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr::null;
|
||||
use std::slice;
|
||||
use std::sync::mpsc::{channel, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use self::enumerate::{
|
||||
default_input_device, default_output_device, Devices, SupportedInputConfigs,
|
||||
SupportedOutputConfigs,
|
||||
};
|
||||
|
||||
use property_listener::AudioObjectPropertyListener;
|
||||
|
||||
pub mod enumerate;
|
||||
mod property_listener;
|
||||
|
||||
/// Coreaudio host, the default host on macOS.
|
||||
#[derive(Debug)]
|
||||
pub struct Host;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
// Assume coreaudio is always available
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
default_input_device()
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
default_output_device()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Device::name(self)
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_input_configs(self)
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_output_configs(self)
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_input_config(self)
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_output_config(self)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Device::build_input_stream_raw(
|
||||
self,
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Device::build_output_stream_raw(
|
||||
self,
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Device {
|
||||
pub(crate) audio_device_id: AudioDeviceID,
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
let property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyDeviceNameCFString,
|
||||
mScope: kAudioDevicePropertyScopeOutput,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
let device_name: CFStringRef = null();
|
||||
let data_size = mem::size_of::<CFStringRef>();
|
||||
let c_str = unsafe {
|
||||
let status = AudioObjectGetPropertyData(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&device_name as *const _ as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
|
||||
let c_string: *const c_char = CFStringGetCStringPtr(device_name, kCFStringEncodingUTF8);
|
||||
if c_string.is_null() {
|
||||
let status = AudioObjectGetPropertyData(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&device_name as *const _ as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
let mut buf: [i8; 255] = [0; 255];
|
||||
let result = CFStringGetCString(
|
||||
device_name,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as _,
|
||||
kCFStringEncodingUTF8,
|
||||
);
|
||||
if result == 0 {
|
||||
let description =
|
||||
"core foundation failed to return device name string".to_string();
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
let name: &CStr = CStr::from_ptr(buf.as_ptr());
|
||||
return Ok(name.to_str().unwrap().to_owned());
|
||||
}
|
||||
CStr::from_ptr(c_string as *mut _)
|
||||
};
|
||||
Ok(c_str.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// Logic re-used between `supported_input_configs` and `supported_output_configs`.
|
||||
#[allow(clippy::cast_ptr_alignment)]
|
||||
fn supported_configs(
|
||||
&self,
|
||||
scope: AudioObjectPropertyScope,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
let mut property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
// Retrieve the devices audio buffer list.
|
||||
let data_size = 0u32;
|
||||
let status = AudioObjectGetPropertyDataSize(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
|
||||
let mut audio_buffer_list: Vec<u8> = vec![];
|
||||
audio_buffer_list.reserve_exact(data_size as usize);
|
||||
let status = AudioObjectGetPropertyData(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
audio_buffer_list.as_mut_ptr() as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
|
||||
let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList;
|
||||
|
||||
// If there's no buffers, skip.
|
||||
if (*audio_buffer_list).mNumberBuffers == 0 {
|
||||
return Ok(vec![].into_iter());
|
||||
}
|
||||
|
||||
// Count the number of channels as the sum of all channels in all output buffers.
|
||||
let n_buffers = (*audio_buffer_list).mNumberBuffers as usize;
|
||||
let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr();
|
||||
let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers);
|
||||
let mut n_channels = 0;
|
||||
for buffer in buffers {
|
||||
n_channels += buffer.mNumberChannels as usize;
|
||||
}
|
||||
|
||||
// TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using
|
||||
// I16 but just use F32 for now as it's the default anyway.
|
||||
let sample_format = SampleFormat::F32;
|
||||
|
||||
// Get available sample rate ranges.
|
||||
property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates;
|
||||
let data_size = 0u32;
|
||||
let status = AudioObjectGetPropertyDataSize(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
|
||||
let n_ranges = data_size as usize / mem::size_of::<AudioValueRange>();
|
||||
let mut ranges: Vec<u8> = vec![];
|
||||
ranges.reserve_exact(data_size as usize);
|
||||
let status = AudioObjectGetPropertyData(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
ranges.as_mut_ptr() as *mut _,
|
||||
);
|
||||
check_os_status(status)?;
|
||||
|
||||
let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _;
|
||||
let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges);
|
||||
|
||||
let audio_unit = audio_unit_from_device(self, true)?;
|
||||
let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?;
|
||||
|
||||
// Collect the supported formats for the device.
|
||||
let mut fmts = vec![];
|
||||
for range in ranges {
|
||||
let fmt = SupportedStreamConfigRange {
|
||||
channels: n_channels as ChannelCount,
|
||||
min_sample_rate: SampleRate(range.mMinimum as _),
|
||||
max_sample_rate: SampleRate(range.mMaximum as _),
|
||||
buffer_size,
|
||||
sample_format,
|
||||
};
|
||||
fmts.push(fmt);
|
||||
}
|
||||
|
||||
Ok(fmts.into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
self.supported_configs(kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
self.supported_configs(kAudioObjectPropertyScopeOutput)
|
||||
}
|
||||
|
||||
fn default_config(
|
||||
&self,
|
||||
scope: AudioObjectPropertyScope,
|
||||
) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
fn default_config_error_from_os_status(
|
||||
status: OSStatus,
|
||||
) -> Result<(), DefaultStreamConfigError> {
|
||||
let err = match coreaudio::Error::from_os_status(status) {
|
||||
Err(err) => err,
|
||||
Ok(_) => return Ok(()),
|
||||
};
|
||||
match err {
|
||||
coreaudio::Error::AudioUnit(
|
||||
coreaudio::error::AudioUnitError::FormatNotSupported,
|
||||
)
|
||||
| coreaudio::Error::AudioCodec(_)
|
||||
| coreaudio::Error::AudioFormat(_) => {
|
||||
Err(DefaultStreamConfigError::StreamTypeNotSupported)
|
||||
}
|
||||
coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::NoConnection) => {
|
||||
Err(DefaultStreamConfigError::DeviceNotAvailable)
|
||||
}
|
||||
err => {
|
||||
let description = format!("{}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyStreamFormat,
|
||||
mScope: scope,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let asbd: AudioStreamBasicDescription = mem::zeroed();
|
||||
let data_size = mem::size_of::<AudioStreamBasicDescription>() as u32;
|
||||
let status = AudioObjectGetPropertyData(
|
||||
self.audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&asbd as *const _ as *mut _,
|
||||
);
|
||||
default_config_error_from_os_status(status)?;
|
||||
|
||||
let sample_format = {
|
||||
let audio_format = coreaudio::audio_unit::AudioFormat::from_format_and_flag(
|
||||
asbd.mFormatID,
|
||||
Some(asbd.mFormatFlags),
|
||||
);
|
||||
let flags = match audio_format {
|
||||
Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) => flags,
|
||||
_ => return Err(DefaultStreamConfigError::StreamTypeNotSupported),
|
||||
};
|
||||
let maybe_sample_format =
|
||||
coreaudio::audio_unit::SampleFormat::from_flags_and_bits_per_sample(
|
||||
flags,
|
||||
asbd.mBitsPerChannel,
|
||||
);
|
||||
match maybe_sample_format {
|
||||
Some(coreaudio::audio_unit::SampleFormat::F32) => SampleFormat::F32,
|
||||
Some(coreaudio::audio_unit::SampleFormat::I16) => SampleFormat::I16,
|
||||
_ => return Err(DefaultStreamConfigError::StreamTypeNotSupported),
|
||||
}
|
||||
};
|
||||
|
||||
let audio_unit = audio_unit_from_device(self, true)?;
|
||||
let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?;
|
||||
|
||||
let config = SupportedStreamConfig {
|
||||
sample_rate: SampleRate(asbd.mSampleRate as _),
|
||||
channels: asbd.mChannelsPerFrame as _,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
self.default_config(kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
self.default_config(kAudioObjectPropertyScopeOutput)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Device {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.debug_struct("Device")
|
||||
.field("audio_device_id", &self.audio_device_id)
|
||||
.field("name", &self.name())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamInner {
|
||||
playing: bool,
|
||||
audio_unit: AudioUnit,
|
||||
/// Manage the lifetime of the closure that handles device disconnection.
|
||||
_disconnect_listener: Option<AudioObjectPropertyListener>,
|
||||
// Track the device with which the audio unit was spawned.
|
||||
//
|
||||
// We must do this so that we can avoid changing the device sample rate if there is already
|
||||
// a stream associated with the device.
|
||||
#[allow(dead_code)]
|
||||
device_id: AudioDeviceID,
|
||||
}
|
||||
|
||||
/// Register the on-disconnect callback.
|
||||
/// This will both stop the stream and call the error callback with DeviceNotAvailable.
|
||||
/// This function should only be called once per stream.
|
||||
fn add_disconnect_listener<E>(
|
||||
stream: &Stream,
|
||||
error_callback: Arc<Mutex<E>>,
|
||||
) -> Result<(), BuildStreamError>
|
||||
where
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let stream_copy = stream.clone();
|
||||
let mut stream_inner = stream.inner.lock().unwrap();
|
||||
stream_inner._disconnect_listener = Some(AudioObjectPropertyListener::new(
|
||||
stream_inner.device_id,
|
||||
AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyDeviceIsAlive,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
},
|
||||
move || {
|
||||
let _ = stream_copy.pause();
|
||||
(error_callback.lock().unwrap())(StreamError::DeviceNotAvailable);
|
||||
},
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_unit_from_device(device: &Device, input: bool) -> Result<AudioUnit, coreaudio::Error> {
|
||||
// Psysonic patch (macOS mic-prompt suppression):
|
||||
// Always use DefaultOutput for output streams, regardless of whether
|
||||
// `device` is flagged as the default. HalOutput instantiation triggers
|
||||
// macOS TCC microphone-permission prompts even for playback-only apps.
|
||||
// DefaultOutput never touches input and never prompts for mic access.
|
||||
// Consequence: per-device output selection is a no-op on macOS — the
|
||||
// stream follows the system default. This matches Apple Music/Spotify
|
||||
// behaviour and is surfaced to the user in the Settings UI.
|
||||
let output_type = if !input {
|
||||
coreaudio::audio_unit::IOType::DefaultOutput
|
||||
} else {
|
||||
coreaudio::audio_unit::IOType::HalOutput
|
||||
};
|
||||
let mut audio_unit = AudioUnit::new(output_type)?;
|
||||
|
||||
if input {
|
||||
// Enable input processing.
|
||||
let enable_input = 1u32;
|
||||
audio_unit.set_property(
|
||||
kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Input,
|
||||
Element::Input,
|
||||
Some(&enable_input),
|
||||
)?;
|
||||
|
||||
// Disable output processing.
|
||||
let disable_output = 0u32;
|
||||
audio_unit.set_property(
|
||||
kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Output,
|
||||
Element::Output,
|
||||
Some(&disable_output),
|
||||
)?;
|
||||
}
|
||||
|
||||
audio_unit.set_property(
|
||||
kAudioOutputUnitProperty_CurrentDevice,
|
||||
Scope::Global,
|
||||
Element::Output,
|
||||
Some(&device.audio_device_id),
|
||||
)?;
|
||||
|
||||
Ok(audio_unit)
|
||||
}
|
||||
|
||||
impl Device {
|
||||
#[allow(clippy::cast_ptr_alignment)]
|
||||
#[allow(clippy::while_immutable_condition)]
|
||||
#[allow(clippy::float_cmp)]
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
// The scope and element for working with a device's input stream.
|
||||
let scope = Scope::Output;
|
||||
let element = Element::Input;
|
||||
|
||||
// Potentially change the device sample rate to match the config.
|
||||
set_sample_rate(self.audio_device_id, config.sample_rate)?;
|
||||
|
||||
let mut audio_unit = audio_unit_from_device(self, true)?;
|
||||
|
||||
// Set the stream in interleaved mode.
|
||||
let asbd = asbd_from_config(config, sample_format);
|
||||
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
|
||||
|
||||
// Set the buffersize
|
||||
match config.buffer_size {
|
||||
BufferSize::Fixed(v) => {
|
||||
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
|
||||
match buffer_size_range {
|
||||
SupportedBufferSize::Range { min, max } => {
|
||||
if v >= min && v <= max {
|
||||
audio_unit.set_property(
|
||||
kAudioDevicePropertyBufferFrameSize,
|
||||
scope,
|
||||
element,
|
||||
Some(&v),
|
||||
)?
|
||||
} else {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
}
|
||||
SupportedBufferSize::Unknown => (),
|
||||
}
|
||||
}
|
||||
BufferSize::Default => (),
|
||||
}
|
||||
|
||||
let error_callback = Arc::new(Mutex::new(error_callback));
|
||||
let error_callback_disconnect = error_callback.clone();
|
||||
|
||||
// Register the callback that is being called by coreaudio whenever it needs data to be
|
||||
// fed to the audio buffer.
|
||||
let bytes_per_channel = sample_format.sample_size();
|
||||
let sample_rate = config.sample_rate;
|
||||
type Args = render_callback::Args<data::Raw>;
|
||||
audio_unit.set_input_callback(move |args: Args| unsafe {
|
||||
let ptr = (*args.data.data).mBuffers.as_ptr();
|
||||
let len = (*args.data.data).mNumberBuffers as usize;
|
||||
let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len);
|
||||
|
||||
// TODO: Perhaps loop over all buffers instead?
|
||||
let AudioBuffer {
|
||||
mNumberChannels: channels,
|
||||
mDataByteSize: data_byte_size,
|
||||
mData: data,
|
||||
} = buffers[0];
|
||||
|
||||
let data = data as *mut ();
|
||||
let len = data_byte_size as usize / bytes_per_channel;
|
||||
let data = Data::from_parts(data, len, sample_format);
|
||||
|
||||
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
|
||||
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
|
||||
Err(err) => {
|
||||
(error_callback.lock().unwrap())(err.into());
|
||||
return Err(());
|
||||
}
|
||||
Ok(cb) => cb,
|
||||
};
|
||||
let buffer_frames = len / channels as usize;
|
||||
let delay = frames_to_duration(buffer_frames, sample_rate);
|
||||
let capture = callback
|
||||
.sub(delay)
|
||||
.expect("`capture` occurs before origin of alsa `StreamInstant`");
|
||||
let timestamp = crate::InputStreamTimestamp { callback, capture };
|
||||
|
||||
let info = InputCallbackInfo { timestamp };
|
||||
data_callback(&data, &info);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let stream = Stream::new(StreamInner {
|
||||
playing: true,
|
||||
_disconnect_listener: None,
|
||||
audio_unit,
|
||||
device_id: self.audio_device_id,
|
||||
});
|
||||
|
||||
// If we didn't request the default device, stop the stream if the
|
||||
// device disconnects.
|
||||
if !self.is_default {
|
||||
add_disconnect_listener(&stream, error_callback_disconnect)?;
|
||||
}
|
||||
|
||||
stream.inner.lock().unwrap().audio_unit.start()?;
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
mut data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let mut audio_unit = audio_unit_from_device(self, false)?;
|
||||
|
||||
// The scope and element for working with a device's output stream.
|
||||
let scope = Scope::Input;
|
||||
let element = Element::Output;
|
||||
|
||||
// Set the stream in interleaved mode.
|
||||
let asbd = asbd_from_config(config, sample_format);
|
||||
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
|
||||
|
||||
// Set the buffersize
|
||||
match config.buffer_size {
|
||||
BufferSize::Fixed(v) => {
|
||||
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
|
||||
match buffer_size_range {
|
||||
SupportedBufferSize::Range { min, max } => {
|
||||
if v >= min && v <= max {
|
||||
audio_unit.set_property(
|
||||
kAudioDevicePropertyBufferFrameSize,
|
||||
scope,
|
||||
element,
|
||||
Some(&v),
|
||||
)?
|
||||
} else {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
}
|
||||
SupportedBufferSize::Unknown => (),
|
||||
}
|
||||
}
|
||||
BufferSize::Default => (),
|
||||
}
|
||||
|
||||
let error_callback = Arc::new(Mutex::new(error_callback));
|
||||
let error_callback_disconnect = error_callback.clone();
|
||||
|
||||
// Register the callback that is being called by coreaudio whenever it needs data to be
|
||||
// fed to the audio buffer.
|
||||
let bytes_per_channel = sample_format.sample_size();
|
||||
let sample_rate = config.sample_rate;
|
||||
type Args = render_callback::Args<data::Raw>;
|
||||
audio_unit.set_render_callback(move |args: Args| unsafe {
|
||||
// If `run()` is currently running, then a callback will be available from this list.
|
||||
// Otherwise, we just fill the buffer with zeroes and return.
|
||||
|
||||
let AudioBuffer {
|
||||
mNumberChannels: channels,
|
||||
mDataByteSize: data_byte_size,
|
||||
mData: data,
|
||||
} = (*args.data.data).mBuffers[0];
|
||||
|
||||
let data = data as *mut ();
|
||||
let len = data_byte_size as usize / bytes_per_channel;
|
||||
let mut data = Data::from_parts(data, len, sample_format);
|
||||
|
||||
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
|
||||
Err(err) => {
|
||||
(error_callback.lock().unwrap())(err.into());
|
||||
return Err(());
|
||||
}
|
||||
Ok(cb) => cb,
|
||||
};
|
||||
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
|
||||
let buffer_frames = len / channels as usize;
|
||||
let delay = frames_to_duration(buffer_frames, sample_rate);
|
||||
let playback = callback
|
||||
.add(delay)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
data_callback(&mut data, &info);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let stream = Stream::new(StreamInner {
|
||||
playing: true,
|
||||
_disconnect_listener: None,
|
||||
audio_unit,
|
||||
device_id: self.audio_device_id,
|
||||
});
|
||||
|
||||
// If we didn't request the default device, stop the stream if the
|
||||
// device disconnects.
|
||||
if !self.is_default {
|
||||
add_disconnect_listener(&stream, error_callback_disconnect)?;
|
||||
}
|
||||
|
||||
stream.inner.lock().unwrap().audio_unit.start()?;
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set the device sample rate to the provided rate.
|
||||
/// Return an error if the requested sample rate is not supported by the device.
|
||||
fn set_sample_rate(
|
||||
audio_device_id: AudioObjectID,
|
||||
target_sample_rate: SampleRate,
|
||||
) -> Result<(), BuildStreamError> {
|
||||
// Get the current sample rate.
|
||||
let mut property_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
let sample_rate: f64 = 0.0;
|
||||
let data_size = mem::size_of::<f64>() as u32;
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&sample_rate as *const _ as *mut _,
|
||||
)
|
||||
};
|
||||
coreaudio::Error::from_os_status(status)?;
|
||||
|
||||
// If the requested sample rate is different to the device sample rate, update the device.
|
||||
if sample_rate as u32 != target_sample_rate.0 {
|
||||
// Get available sample rate ranges.
|
||||
property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates;
|
||||
let data_size = 0u32;
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyDataSize(
|
||||
audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
)
|
||||
};
|
||||
coreaudio::Error::from_os_status(status)?;
|
||||
let n_ranges = data_size as usize / mem::size_of::<AudioValueRange>();
|
||||
let mut ranges: Vec<u8> = vec![];
|
||||
ranges.reserve_exact(data_size as usize);
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
ranges.as_mut_ptr() as *mut _,
|
||||
)
|
||||
};
|
||||
coreaudio::Error::from_os_status(status)?;
|
||||
let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _;
|
||||
let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) };
|
||||
|
||||
// Now that we have the available ranges, pick the one matching the desired rate.
|
||||
let sample_rate = target_sample_rate.0;
|
||||
let maybe_index = ranges
|
||||
.iter()
|
||||
.position(|r| r.mMinimum as u32 == sample_rate && r.mMaximum as u32 == sample_rate);
|
||||
let range_index = match maybe_index {
|
||||
None => return Err(BuildStreamError::StreamConfigNotSupported),
|
||||
Some(i) => i,
|
||||
};
|
||||
|
||||
let (send, recv) = channel::<Result<f64, coreaudio::Error>>();
|
||||
let sample_rate_address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMaster,
|
||||
};
|
||||
// Send sample rate updates back on a channel.
|
||||
let sample_rate_handler = move || {
|
||||
let mut rate: f64 = 0.0;
|
||||
let data_size = mem::size_of::<f64>();
|
||||
|
||||
let result = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
audio_device_id,
|
||||
&sample_rate_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
&data_size as *const _ as *mut _,
|
||||
&mut rate as *const _ as *mut _,
|
||||
)
|
||||
};
|
||||
send.send(coreaudio::Error::from_os_status(result).map(|_| rate))
|
||||
.ok();
|
||||
};
|
||||
|
||||
let listener = AudioObjectPropertyListener::new(
|
||||
audio_device_id,
|
||||
sample_rate_address,
|
||||
sample_rate_handler,
|
||||
)?;
|
||||
|
||||
// Finally, set the sample rate.
|
||||
property_address.mSelector = kAudioDevicePropertyNominalSampleRate;
|
||||
let status = unsafe {
|
||||
AudioObjectSetPropertyData(
|
||||
audio_device_id,
|
||||
&property_address as *const _,
|
||||
0,
|
||||
null(),
|
||||
data_size,
|
||||
&ranges[range_index] as *const _ as *const _,
|
||||
)
|
||||
};
|
||||
coreaudio::Error::from_os_status(status)?;
|
||||
|
||||
// Wait for the reported_rate to change.
|
||||
//
|
||||
// This should not take longer than a few ms, but we timeout after 1 sec just in case.
|
||||
// We loop over potentially several events from the channel to ensure
|
||||
// that we catch the expected change in sample rate.
|
||||
let mut timeout = Duration::from_secs(1);
|
||||
let start = Instant::now();
|
||||
|
||||
loop {
|
||||
match recv.recv_timeout(timeout) {
|
||||
Err(err) => {
|
||||
let description = match err {
|
||||
RecvTimeoutError::Disconnected => {
|
||||
"sample rate listener channel disconnected unexpectedly"
|
||||
}
|
||||
RecvTimeoutError::Timeout => {
|
||||
"timeout waiting for sample rate update for device"
|
||||
}
|
||||
}
|
||||
.to_string();
|
||||
return Err(BackendSpecificError { description }.into());
|
||||
}
|
||||
Ok(Ok(reported_sample_rate)) => {
|
||||
if reported_sample_rate == target_sample_rate.0 as f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// TODO: should we consider collecting this error?
|
||||
}
|
||||
};
|
||||
timeout = timeout
|
||||
.checked_sub(start.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
}
|
||||
listener.remove()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Stream {
|
||||
inner: Arc<Mutex<StreamInner>>,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn new(inner: StreamInner) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
let mut stream = self.inner.lock().unwrap();
|
||||
|
||||
if !stream.playing {
|
||||
if let Err(e) = stream.audio_unit.start() {
|
||||
let description = format!("{}", e);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
stream.playing = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
let mut stream = self.inner.lock().unwrap();
|
||||
|
||||
if stream.playing {
|
||||
if let Err(e) = stream.audio_unit.stop() {
|
||||
let description = format!("{}", e);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
stream.playing = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_io_buffer_frame_size_range(
|
||||
audio_unit: &AudioUnit,
|
||||
) -> Result<SupportedBufferSize, coreaudio::Error> {
|
||||
let buffer_size_range: AudioValueRange = audio_unit.get_property(
|
||||
kAudioDevicePropertyBufferFrameSizeRange,
|
||||
Scope::Global,
|
||||
Element::Output,
|
||||
)?;
|
||||
|
||||
Ok(SupportedBufferSize::Range {
|
||||
min: buffer_size_range.mMinimum as u32,
|
||||
max: buffer_size_range.mMaximum as u32,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Helper code for registering audio object property listeners.
|
||||
use super::coreaudio::sys::{
|
||||
AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress,
|
||||
AudioObjectRemovePropertyListener, OSStatus,
|
||||
};
|
||||
|
||||
use crate::BuildStreamError;
|
||||
|
||||
/// A double-indirection to be able to pass a closure (a fat pointer)
|
||||
/// via a single c_void.
|
||||
struct PropertyListenerCallbackWrapper(Box<dyn FnMut()>);
|
||||
|
||||
/// Maintain an audio object property listener.
|
||||
/// The listener will be removed when this type is dropped.
|
||||
pub struct AudioObjectPropertyListener {
|
||||
callback: Box<PropertyListenerCallbackWrapper>,
|
||||
property_address: AudioObjectPropertyAddress,
|
||||
audio_object_id: AudioObjectID,
|
||||
removed: bool,
|
||||
}
|
||||
|
||||
impl AudioObjectPropertyListener {
|
||||
/// Attach the provided callback as a audio object property listener.
|
||||
pub fn new<F: FnMut() + 'static>(
|
||||
audio_object_id: AudioObjectID,
|
||||
property_address: AudioObjectPropertyAddress,
|
||||
callback: F,
|
||||
) -> Result<Self, BuildStreamError> {
|
||||
let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(callback)));
|
||||
unsafe {
|
||||
coreaudio::Error::from_os_status(AudioObjectAddPropertyListener(
|
||||
audio_object_id,
|
||||
&property_address as *const _,
|
||||
Some(property_listener_handler_shim),
|
||||
&*callback as *const _ as *mut _,
|
||||
))?;
|
||||
};
|
||||
Ok(Self {
|
||||
callback,
|
||||
audio_object_id,
|
||||
property_address,
|
||||
removed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicitly remove the property listener.
|
||||
/// Use this method if you need to explicitly handle failure to remove
|
||||
/// the property listener.
|
||||
pub fn remove(mut self) -> Result<(), BuildStreamError> {
|
||||
self.remove_inner()
|
||||
}
|
||||
|
||||
fn remove_inner(&mut self) -> Result<(), BuildStreamError> {
|
||||
unsafe {
|
||||
coreaudio::Error::from_os_status(AudioObjectRemovePropertyListener(
|
||||
self.audio_object_id,
|
||||
&self.property_address as *const _,
|
||||
Some(property_listener_handler_shim),
|
||||
&*self.callback as *const _ as *mut _,
|
||||
))?;
|
||||
}
|
||||
self.removed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioObjectPropertyListener {
|
||||
fn drop(&mut self) {
|
||||
if !self.removed {
|
||||
let _ = self.remove_inner();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback used to call user-provided closure as a property listener.
|
||||
unsafe extern "C" fn property_listener_handler_shim(
|
||||
_: AudioObjectID,
|
||||
_: u32,
|
||||
_: *const AudioObjectPropertyAddress,
|
||||
callback: *mut ::std::os::raw::c_void,
|
||||
) -> OSStatus {
|
||||
let wrapper = callback as *mut PropertyListenerCallbackWrapper;
|
||||
(*wrapper).0();
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
extern crate coreaudio;
|
||||
|
||||
use self::coreaudio::sys::{
|
||||
kAudioFormatFlagIsFloat, kAudioFormatFlagIsPacked, kAudioFormatLinearPCM,
|
||||
AudioStreamBasicDescription, OSStatus,
|
||||
};
|
||||
|
||||
use crate::DefaultStreamConfigError;
|
||||
use crate::{BuildStreamError, SupportedStreamConfigsError};
|
||||
|
||||
use crate::{BackendSpecificError, SampleFormat, StreamConfig};
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod ios;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use self::ios::{
|
||||
enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs},
|
||||
Device, Host, Stream,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use self::macos::{
|
||||
enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs},
|
||||
Device, Host, Stream,
|
||||
};
|
||||
|
||||
/// Common helper methods used by both macOS and iOS
|
||||
|
||||
fn check_os_status(os_status: OSStatus) -> Result<(), BackendSpecificError> {
|
||||
match coreaudio::Error::from_os_status(os_status) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let description = err.to_string();
|
||||
Err(BackendSpecificError { description })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a coreaudio AudioStreamBasicDescription from a CPAL Format.
|
||||
fn asbd_from_config(
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
) -> AudioStreamBasicDescription {
|
||||
let n_channels = config.channels as usize;
|
||||
let sample_rate = config.sample_rate.0;
|
||||
let bytes_per_channel = sample_format.sample_size();
|
||||
let bits_per_channel = bytes_per_channel * 8;
|
||||
let bytes_per_frame = n_channels * bytes_per_channel;
|
||||
let frames_per_packet = 1;
|
||||
let bytes_per_packet = frames_per_packet * bytes_per_frame;
|
||||
let format_flags = match sample_format {
|
||||
SampleFormat::F32 => kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
|
||||
_ => kAudioFormatFlagIsPacked,
|
||||
};
|
||||
AudioStreamBasicDescription {
|
||||
mBitsPerChannel: bits_per_channel as _,
|
||||
mBytesPerFrame: bytes_per_frame as _,
|
||||
mChannelsPerFrame: n_channels as _,
|
||||
mBytesPerPacket: bytes_per_packet as _,
|
||||
mFramesPerPacket: frames_per_packet as _,
|
||||
mFormatFlags: format_flags,
|
||||
mFormatID: kAudioFormatLinearPCM,
|
||||
mSampleRate: sample_rate as _,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn host_time_to_stream_instant(
|
||||
m_host_time: u64,
|
||||
) -> Result<crate::StreamInstant, BackendSpecificError> {
|
||||
let mut info: mach2::mach_time::mach_timebase_info = Default::default();
|
||||
let res = unsafe { mach2::mach_time::mach_timebase_info(&mut info) };
|
||||
check_os_status(res)?;
|
||||
let nanos = m_host_time * info.numer as u64 / info.denom as u64;
|
||||
let secs = nanos / 1_000_000_000;
|
||||
let subsec_nanos = nanos - secs * 1_000_000_000;
|
||||
Ok(crate::StreamInstant::new(secs as i64, subsec_nanos as u32))
|
||||
}
|
||||
|
||||
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
|
||||
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
|
||||
let secsf = frames as f64 / rate.0 as f64;
|
||||
let secs = secsf as u64;
|
||||
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
|
||||
std::time::Duration::new(secs, nanos)
|
||||
}
|
||||
|
||||
// TODO need stronger error identification
|
||||
impl From<coreaudio::Error> for BuildStreamError {
|
||||
fn from(err: coreaudio::Error) -> BuildStreamError {
|
||||
match err {
|
||||
coreaudio::Error::RenderCallbackBufferFormatDoesNotMatchAudioUnitStreamFormat
|
||||
| coreaudio::Error::NoKnownSubtype
|
||||
| coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::FormatNotSupported)
|
||||
| coreaudio::Error::AudioCodec(_)
|
||||
| coreaudio::Error::AudioFormat(_) => BuildStreamError::StreamConfigNotSupported,
|
||||
_ => BuildStreamError::DeviceNotAvailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<coreaudio::Error> for SupportedStreamConfigsError {
|
||||
fn from(err: coreaudio::Error) -> SupportedStreamConfigsError {
|
||||
let description = format!("{}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
// Check for possible DeviceNotAvailable variant
|
||||
SupportedStreamConfigsError::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<coreaudio::Error> for DefaultStreamConfigError {
|
||||
fn from(err: coreaudio::Error) -> DefaultStreamConfigError {
|
||||
let description = format!("{}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
// Check for possible DeviceNotAvailable variant
|
||||
DefaultStreamConfigError::BackendSpecific { err }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
use js_sys::Float32Array;
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
use web_sys::AudioContext;
|
||||
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BufferSize, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
|
||||
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
|
||||
SampleRate, StreamConfig, StreamError, SupportedBufferSize, SupportedStreamConfig,
|
||||
SupportedStreamConfigRange, SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
// The emscripten backend currently works by instantiating an `AudioContext` object per `Stream`.
|
||||
// Creating a stream creates a new `AudioContext`. Destroying a stream destroys it. Creation of a
|
||||
// `Host` instance initializes the `stdweb` context.
|
||||
|
||||
/// The default emscripten host type.
|
||||
#[derive(Debug)]
|
||||
pub struct Host;
|
||||
|
||||
/// Content is false if the iterator is empty.
|
||||
pub struct Devices(bool);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Device;
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct Stream {
|
||||
// A reference to an `AudioContext` object.
|
||||
audio_ctxt: AudioContext,
|
||||
}
|
||||
|
||||
// Index within the `streams` array of the events loop.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct StreamId(usize);
|
||||
|
||||
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
|
||||
const MIN_CHANNELS: u16 = 1;
|
||||
const MAX_CHANNELS: u16 = 32;
|
||||
const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000);
|
||||
const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000);
|
||||
const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100);
|
||||
const MIN_BUFFER_SIZE: u32 = 1;
|
||||
const MAX_BUFFER_SIZE: u32 = u32::MAX;
|
||||
const DEFAULT_BUFFER_SIZE: usize = 2048;
|
||||
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
fn new() -> Result<Self, DevicesError> {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok("Default Device".to_owned())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
let buffer_size = SupportedBufferSize::Range {
|
||||
min: MIN_BUFFER_SIZE,
|
||||
max: MAX_BUFFER_SIZE,
|
||||
};
|
||||
let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS)
|
||||
.map(|channels| SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: MIN_SAMPLE_RATE,
|
||||
max_sample_rate: MAX_SAMPLE_RATE,
|
||||
buffer_size: buffer_size.clone(),
|
||||
sample_format: SUPPORTED_SAMPLE_FORMAT,
|
||||
})
|
||||
.collect();
|
||||
Ok(configs.into_iter())
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
const EXPECT: &str = "expected at least one valid webaudio stream config";
|
||||
let config = self
|
||||
.supported_output_configs()
|
||||
.expect(EXPECT)
|
||||
.max_by(|a, b| a.cmp_default_heuristics(b))
|
||||
.unwrap()
|
||||
.with_sample_rate(DEFAULT_SAMPLE_RATE);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
// Assume this host is always available on emscripten.
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
default_input_device()
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
default_output_device()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Device::name(self)
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_input_configs(self)
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_output_configs(self)
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_input_config(self)
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_output_config(self)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
_config: &StreamConfig,
|
||||
_sample_format: SampleFormat,
|
||||
_data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
if !valid_config(config, sample_format) {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
|
||||
let buffer_size_frames = match config.buffer_size {
|
||||
BufferSize::Fixed(v) => {
|
||||
if v == 0 {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
} else {
|
||||
v as usize
|
||||
}
|
||||
}
|
||||
BufferSize::Default => DEFAULT_BUFFER_SIZE,
|
||||
};
|
||||
|
||||
// Create the stream.
|
||||
let audio_ctxt = AudioContext::new().expect("webaudio is not present on this system");
|
||||
let stream = Stream { audio_ctxt };
|
||||
|
||||
// Use `set_timeout` to invoke a Rust callback repeatedly.
|
||||
//
|
||||
// The job of this callback is to fill the content of the audio buffers.
|
||||
//
|
||||
// See also: The call to `set_timeout` at the end of the `audio_callback_fn` which creates
|
||||
// the loop.
|
||||
set_timeout(
|
||||
10,
|
||||
stream.clone(),
|
||||
data_callback,
|
||||
config,
|
||||
sample_format,
|
||||
buffer_size_frames as u32,
|
||||
);
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
let future = JsFuture::from(
|
||||
self.audio_ctxt
|
||||
.resume()
|
||||
.expect("Could not resume the stream"),
|
||||
);
|
||||
spawn_local(async {
|
||||
match future.await {
|
||||
Ok(value) => assert!(value.is_undefined()),
|
||||
Err(value) => panic!("AudioContext.resume() promise was rejected: {:?}", value),
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
let future = JsFuture::from(
|
||||
self.audio_ctxt
|
||||
.suspend()
|
||||
.expect("Could not suspend the stream"),
|
||||
);
|
||||
spawn_local(async {
|
||||
match future.await {
|
||||
Ok(value) => assert!(value.is_undefined()),
|
||||
Err(value) => panic!("AudioContext.suspend() promise was rejected: {:?}", value),
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_callback_fn<D>(
|
||||
mut data_callback: D,
|
||||
) -> impl FnOnce(Stream, StreamConfig, SampleFormat, u32)
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
{
|
||||
|stream, config, sample_format, buffer_size_frames| {
|
||||
let sample_rate = config.sample_rate.0;
|
||||
let buffer_size_samples = buffer_size_frames * config.channels as u32;
|
||||
let audio_ctxt = &stream.audio_ctxt;
|
||||
|
||||
// TODO: We should be re-using a buffer.
|
||||
let mut temporary_buffer = vec![0f32; buffer_size_samples as usize];
|
||||
|
||||
{
|
||||
let len = temporary_buffer.len();
|
||||
let data = temporary_buffer.as_mut_ptr() as *mut ();
|
||||
let mut data = unsafe { Data::from_parts(data, len, sample_format) };
|
||||
let now_secs: f64 = audio_ctxt.current_time();
|
||||
let callback = crate::StreamInstant::from_secs_f64(now_secs);
|
||||
// TODO: Use proper latency instead. Currently, unsupported on most browsers though, so
|
||||
// we estimate based on buffer size instead. Probably should use this, but it's only
|
||||
// supported by firefox (2020-04-28).
|
||||
// let latency_secs: f64 = audio_ctxt.outputLatency.try_into().unwrap();
|
||||
let buffer_duration = frames_to_duration(len, sample_rate as usize);
|
||||
let playback = callback
|
||||
.add(buffer_duration)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
data_callback(&mut data, &info);
|
||||
}
|
||||
|
||||
let typed_array: Float32Array = temporary_buffer.as_slice().into();
|
||||
|
||||
debug_assert_eq!(temporary_buffer.len() % config.channels as usize, 0);
|
||||
|
||||
let src_buffer = Float32Array::new(typed_array.buffer().as_ref());
|
||||
let context = audio_ctxt;
|
||||
let buffer = context
|
||||
.create_buffer(
|
||||
config.channels as u32,
|
||||
buffer_size_frames as u32,
|
||||
sample_rate as f32,
|
||||
)
|
||||
.expect("Buffer could not be created");
|
||||
for channel in 0..config.channels {
|
||||
let mut buffer_content = buffer
|
||||
.get_channel_data(channel as u32)
|
||||
.expect("Should be impossible");
|
||||
for (i, buffer_content_item) in buffer_content.iter_mut().enumerate() {
|
||||
*buffer_content_item =
|
||||
src_buffer.get_index(i as u32 * config.channels as u32 + channel as u32);
|
||||
}
|
||||
}
|
||||
|
||||
let node = context
|
||||
.create_buffer_source()
|
||||
.expect("The buffer source node could not be created");
|
||||
node.set_buffer(Some(&buffer));
|
||||
context
|
||||
.destination()
|
||||
.connect_with_audio_node(&node)
|
||||
.expect("Could not connect the audio node to the destination");
|
||||
node.start().expect("Could not start the audio node");
|
||||
|
||||
// TODO: handle latency better ; right now we just use setInterval with the amount of sound
|
||||
// data that is in each buffer ; this is obviously bad, and also the schedule is too tight
|
||||
// and there may be underflows
|
||||
set_timeout(
|
||||
1000 * buffer_size_frames as i32 / sample_rate as i32,
|
||||
stream.clone().clone(),
|
||||
data_callback,
|
||||
&config,
|
||||
sample_format,
|
||||
buffer_size_frames as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_timeout<D>(
|
||||
time: i32,
|
||||
stream: Stream,
|
||||
data_callback: D,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
buffer_size_frames: u32,
|
||||
) where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
{
|
||||
let window = web_sys::window().expect("Not in a window somehow?");
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_4(
|
||||
&Closure::once_into_js(audio_callback_fn(data_callback))
|
||||
.dyn_ref::<js_sys::Function>()
|
||||
.expect("The function was somehow not a function"),
|
||||
time,
|
||||
&stream.into(),
|
||||
&((*config).clone()).into(),
|
||||
&Closure::once_into_js(move || sample_format),
|
||||
&buffer_size_frames.into(),
|
||||
)
|
||||
.expect("The timeout could not be set");
|
||||
}
|
||||
|
||||
impl Default for Devices {
|
||||
fn default() -> Devices {
|
||||
// We produce an empty iterator if the WebAudio API isn't available.
|
||||
Devices(is_webaudio_available())
|
||||
}
|
||||
}
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
if self.0 {
|
||||
self.0 = false;
|
||||
Some(Device)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_device() -> Option<Device> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_device() -> Option<Device> {
|
||||
if is_webaudio_available() {
|
||||
Some(Device)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Detects whether the `AudioContext` global variable is available.
|
||||
fn is_webaudio_available() -> bool {
|
||||
AudioContext::new().is_ok()
|
||||
}
|
||||
|
||||
// Whether or not the given stream configuration is valid for building a stream.
|
||||
fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool {
|
||||
conf.channels <= MAX_CHANNELS
|
||||
&& conf.channels >= MIN_CHANNELS
|
||||
&& conf.sample_rate <= MAX_SAMPLE_RATE
|
||||
&& conf.sample_rate >= MIN_SAMPLE_RATE
|
||||
&& sample_format == SUPPORTED_SAMPLE_FORMAT
|
||||
}
|
||||
|
||||
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
|
||||
fn frames_to_duration(frames: usize, rate: usize) -> std::time::Duration {
|
||||
let secsf = frames as f64 / rate as f64;
|
||||
let secs = secsf as u64;
|
||||
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
|
||||
std::time::Duration::new(secs, nanos)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use crate::traits::DeviceTrait;
|
||||
use crate::{
|
||||
BackendSpecificError, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError,
|
||||
InputCallbackInfo, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, StreamError,
|
||||
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
|
||||
SupportedStreamConfigsError,
|
||||
};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::stream::Stream;
|
||||
use super::JACK_SAMPLE_FORMAT;
|
||||
|
||||
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
|
||||
const DEFAULT_NUM_CHANNELS: u16 = 2;
|
||||
const DEFAULT_SUPPORTED_CHANNELS: [u16; 10] = [1, 2, 4, 6, 8, 16, 24, 32, 48, 64];
|
||||
|
||||
/// If a device is for input or output.
|
||||
/// Until we have duplex stream support JACK clients and CPAL devices for JACK will be either input or output.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DeviceType {
|
||||
InputDevice,
|
||||
OutputDevice,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Device {
|
||||
name: String,
|
||||
sample_rate: SampleRate,
|
||||
buffer_size: SupportedBufferSize,
|
||||
device_type: DeviceType,
|
||||
start_server_automatically: bool,
|
||||
connect_ports_automatically: bool,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn new_device(
|
||||
name: String,
|
||||
connect_ports_automatically: bool,
|
||||
start_server_automatically: bool,
|
||||
device_type: DeviceType,
|
||||
) -> Result<Self, String> {
|
||||
// ClientOptions are bit flags that you can set with the constants provided
|
||||
let client_options = super::get_client_options(start_server_automatically);
|
||||
|
||||
// Create a dummy client to find out the sample rate of the server to be able to provide it as a possible config.
|
||||
// This client will be dropped, and a new one will be created when making the stream.
|
||||
// This is a hack due to the fact that the Client must be moved to create the AsyncClient.
|
||||
match super::get_client(&name, client_options) {
|
||||
Ok(client) => Ok(Device {
|
||||
// The name given to the client by JACK, could potentially be different from the name supplied e.g.if there is a name collision
|
||||
name: client.name().to_string(),
|
||||
sample_rate: SampleRate(client.sample_rate() as u32),
|
||||
buffer_size: SupportedBufferSize::Range {
|
||||
min: client.buffer_size(),
|
||||
max: client.buffer_size(),
|
||||
},
|
||||
device_type,
|
||||
start_server_automatically,
|
||||
connect_ports_automatically,
|
||||
}),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_output_device(
|
||||
name: &str,
|
||||
connect_ports_automatically: bool,
|
||||
start_server_automatically: bool,
|
||||
) -> Result<Self, String> {
|
||||
let output_client_name = format!("{}_out", name);
|
||||
Device::new_device(
|
||||
output_client_name,
|
||||
connect_ports_automatically,
|
||||
start_server_automatically,
|
||||
DeviceType::OutputDevice,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn default_input_device(
|
||||
name: &str,
|
||||
connect_ports_automatically: bool,
|
||||
start_server_automatically: bool,
|
||||
) -> Result<Self, String> {
|
||||
let input_client_name = format!("{}_in", name);
|
||||
Device::new_device(
|
||||
input_client_name,
|
||||
connect_ports_automatically,
|
||||
start_server_automatically,
|
||||
DeviceType::InputDevice,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn default_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let channels = DEFAULT_NUM_CHANNELS;
|
||||
let sample_rate = self.sample_rate;
|
||||
let buffer_size = self.buffer_size.clone();
|
||||
// The sample format for JACK audio ports is always "32-bit float mono audio" in the current implementation.
|
||||
// Custom formats are allowed within JACK, but this is of niche interest.
|
||||
// The format can be found programmatically by calling jack::PortSpec::port_type() on a created port.
|
||||
let sample_format = JACK_SAMPLE_FORMAT;
|
||||
Ok(SupportedStreamConfig {
|
||||
channels,
|
||||
sample_rate,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn supported_configs(&self) -> Vec<SupportedStreamConfigRange> {
|
||||
let f = match self.default_config() {
|
||||
Err(_) => return vec![],
|
||||
Ok(f) => f,
|
||||
};
|
||||
|
||||
let mut supported_configs = vec![];
|
||||
|
||||
for &channels in DEFAULT_SUPPORTED_CHANNELS.iter() {
|
||||
supported_configs.push(SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: f.sample_rate,
|
||||
max_sample_rate: f.sample_rate,
|
||||
buffer_size: f.buffer_size.clone(),
|
||||
sample_format: f.sample_format,
|
||||
});
|
||||
}
|
||||
supported_configs
|
||||
}
|
||||
|
||||
pub fn is_input(&self) -> bool {
|
||||
matches!(self.device_type, DeviceType::InputDevice)
|
||||
}
|
||||
|
||||
pub fn is_output(&self) -> bool {
|
||||
matches!(self.device_type, DeviceType::OutputDevice)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok(self.name.clone())
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Ok(self.supported_configs().into_iter())
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Ok(self.supported_configs().into_iter())
|
||||
}
|
||||
|
||||
/// Returns the default input config
|
||||
/// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type.
|
||||
/// The sample rate is set by the JACK server.
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
self.default_config()
|
||||
}
|
||||
|
||||
/// Returns the default output config
|
||||
/// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type.
|
||||
/// The sample rate is set by the JACK server.
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
self.default_config()
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
conf: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
if let DeviceType::OutputDevice = &self.device_type {
|
||||
// Trying to create an input stream from an output device
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
// The settings should be fine, create a Client
|
||||
let client_options = super::get_client_options(self.start_server_automatically);
|
||||
let client;
|
||||
match super::get_client(&self.name, client_options) {
|
||||
Ok(c) => client = c,
|
||||
Err(e) => {
|
||||
return Err(BuildStreamError::BackendSpecific {
|
||||
err: BackendSpecificError { description: e },
|
||||
})
|
||||
}
|
||||
};
|
||||
let mut stream = Stream::new_input(client, conf.channels, data_callback, error_callback);
|
||||
|
||||
if self.connect_ports_automatically {
|
||||
stream.connect_to_system_inputs();
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
conf: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
if let DeviceType::InputDevice = &self.device_type {
|
||||
// Trying to create an output stream from an input device
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
|
||||
// The settings should be fine, create a Client
|
||||
let client_options = super::get_client_options(self.start_server_automatically);
|
||||
let client;
|
||||
match super::get_client(&self.name, client_options) {
|
||||
Ok(c) => client = c,
|
||||
Err(e) => {
|
||||
return Err(BuildStreamError::BackendSpecific {
|
||||
err: BackendSpecificError { description: e },
|
||||
})
|
||||
}
|
||||
};
|
||||
let mut stream = Stream::new_output(client, conf.channels, data_callback, error_callback);
|
||||
|
||||
if self.connect_ports_automatically {
|
||||
stream.connect_to_system_outputs();
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Device {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Device::name() can never fail in this implementation
|
||||
self.name().unwrap() == other.name().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Device {}
|
||||
|
||||
impl Hash for Device {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.name().unwrap().hash(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
extern crate jack;
|
||||
|
||||
use crate::traits::HostTrait;
|
||||
use crate::{DevicesError, SampleFormat, SupportedStreamConfigRange};
|
||||
|
||||
mod device;
|
||||
pub use self::device::Device;
|
||||
pub use self::stream::Stream;
|
||||
mod stream;
|
||||
|
||||
const JACK_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
|
||||
|
||||
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type Devices = std::vec::IntoIter<Device>;
|
||||
|
||||
/// The JACK Host type
|
||||
#[derive(Debug)]
|
||||
pub struct Host {
|
||||
/// The name that the client will have in JACK.
|
||||
/// Until we have duplex streams two clients will be created adding "out" or "in" to the name
|
||||
/// since names have to be unique.
|
||||
name: String,
|
||||
/// If ports are to be connected to the system (soundcard) ports automatically (default is true).
|
||||
connect_ports_automatically: bool,
|
||||
/// If the JACK server should be started automatically if it isn't already when creating a Client (default is false).
|
||||
start_server_automatically: bool,
|
||||
/// A list of the devices that have been created from this Host.
|
||||
devices_created: Vec<Device>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
let mut host = Host {
|
||||
name: "cpal_client".to_owned(),
|
||||
connect_ports_automatically: true,
|
||||
start_server_automatically: false,
|
||||
devices_created: vec![],
|
||||
};
|
||||
// Devices don't exist for JACK, they have to be created
|
||||
host.initialize_default_devices();
|
||||
Ok(host)
|
||||
}
|
||||
/// Set whether the ports should automatically be connected to system
|
||||
/// (default is true)
|
||||
pub fn set_connect_automatically(&mut self, do_connect: bool) {
|
||||
self.connect_ports_automatically = do_connect;
|
||||
}
|
||||
/// Set whether a JACK server should be automatically started if it isn't already.
|
||||
/// (default is false)
|
||||
pub fn set_start_server_automatically(&mut self, do_start_server: bool) {
|
||||
self.start_server_automatically = do_start_server;
|
||||
}
|
||||
|
||||
pub fn input_device_with_name(&mut self, name: &str) -> Option<Device> {
|
||||
self.name = name.to_owned();
|
||||
self.default_input_device()
|
||||
}
|
||||
|
||||
pub fn output_device_with_name(&mut self, name: &str) -> Option<Device> {
|
||||
self.name = name.to_owned();
|
||||
self.default_output_device()
|
||||
}
|
||||
|
||||
fn initialize_default_devices(&mut self) {
|
||||
let in_device_res = Device::default_input_device(
|
||||
&self.name,
|
||||
self.connect_ports_automatically,
|
||||
self.start_server_automatically,
|
||||
);
|
||||
|
||||
match in_device_res {
|
||||
Ok(device) => self.devices_created.push(device),
|
||||
Err(err) => {
|
||||
println!("{}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let out_device_res = Device::default_output_device(
|
||||
&self.name,
|
||||
self.connect_ports_automatically,
|
||||
self.start_server_automatically,
|
||||
);
|
||||
match out_device_res {
|
||||
Ok(device) => self.devices_created.push(device),
|
||||
Err(err) => {
|
||||
println!("{}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
/// JACK is available if
|
||||
/// - the jack feature flag is set
|
||||
/// - libjack is installed (wouldn't compile without it)
|
||||
/// - the JACK server can be started
|
||||
///
|
||||
/// If the code compiles the necessary jack libraries are installed.
|
||||
/// There is no way to know if the user has set up a correct JACK configuration e.g. with qjackctl.
|
||||
/// Users can choose to automatically start the server if it isn't already started when creating a client
|
||||
/// so checking if the server is running could give a false negative in some use cases.
|
||||
/// For these reasons this function should always return true.
|
||||
fn is_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Ok(self.devices_created.clone().into_iter())
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
for device in &self.devices_created {
|
||||
if device.is_input() {
|
||||
return Some(device.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
for device in &self.devices_created {
|
||||
if device.is_output() {
|
||||
return Some(device.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_client_options(start_server_automatically: bool) -> jack::ClientOptions {
|
||||
let mut client_options = jack::ClientOptions::empty();
|
||||
client_options.set(
|
||||
jack::ClientOptions::NO_START_SERVER,
|
||||
!start_server_automatically,
|
||||
);
|
||||
client_options
|
||||
}
|
||||
|
||||
fn get_client(name: &str, client_options: jack::ClientOptions) -> Result<jack::Client, String> {
|
||||
let c_res = jack::Client::new(name, client_options);
|
||||
match c_res {
|
||||
Ok((client, status)) => {
|
||||
// The ClientStatus can tell us many things
|
||||
if status.intersects(jack::ClientStatus::SERVER_ERROR) {
|
||||
return Err(String::from(
|
||||
"There was an error communicating with the JACK server!",
|
||||
));
|
||||
} else if status.intersects(jack::ClientStatus::SERVER_FAILED) {
|
||||
return Err(String::from("Could not connect to the JACK server!"));
|
||||
} else if status.intersects(jack::ClientStatus::VERSION_ERROR) {
|
||||
return Err(String::from(
|
||||
"Error connecting to JACK server: Client's protocol version does not match!",
|
||||
));
|
||||
} else if status.intersects(jack::ClientStatus::INIT_FAILURE) {
|
||||
return Err(String::from(
|
||||
"Error connecting to JACK server: Unable to initialize client!",
|
||||
));
|
||||
} else if status.intersects(jack::ClientStatus::SHM_FAILURE) {
|
||||
return Err(String::from(
|
||||
"Error connecting to JACK server: Unable to access shared memory!",
|
||||
));
|
||||
} else if status.intersects(jack::ClientStatus::NO_SUCH_CLIENT) {
|
||||
return Err(String::from(
|
||||
"Error connecting to JACK server: Requested client does not exist!",
|
||||
));
|
||||
} else if status.intersects(jack::ClientStatus::INVALID_OPTION) {
|
||||
return Err(String::from("Error connecting to JACK server: The operation contained an invalid or unsupported option!"));
|
||||
}
|
||||
|
||||
return Ok(client);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to open client because of error: {:?}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use crate::traits::StreamTrait;
|
||||
use crate::ChannelCount;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
BackendSpecificError, Data, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
|
||||
PlayStreamError, SampleRate, StreamError,
|
||||
};
|
||||
|
||||
use super::JACK_SAMPLE_FORMAT;
|
||||
|
||||
type ErrorCallbackPtr = Arc<Mutex<dyn FnMut(StreamError) + Send + 'static>>;
|
||||
|
||||
pub struct Stream {
|
||||
// TODO: It might be faster to send a message when playing/pausing than to check this every iteration
|
||||
playing: Arc<AtomicBool>,
|
||||
async_client: jack::AsyncClient<JackNotificationHandler, LocalProcessHandler>,
|
||||
// Port names are stored in order to connect them to other ports in jack automatically
|
||||
input_port_names: Vec<String>,
|
||||
output_port_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
// TODO: Return error messages
|
||||
pub fn new_input<D, E>(
|
||||
client: jack::Client,
|
||||
channels: ChannelCount,
|
||||
data_callback: D,
|
||||
mut error_callback: E,
|
||||
) -> Stream
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let mut ports = vec![];
|
||||
let mut port_names: Vec<String> = vec![];
|
||||
// Create ports
|
||||
for i in 0..channels {
|
||||
let port_try = client.register_port(&format!("in_{}", i), jack::AudioIn::default());
|
||||
match port_try {
|
||||
Ok(port) => {
|
||||
// Get the port name in order to later connect it automatically
|
||||
if let Ok(port_name) = port.name() {
|
||||
port_names.push(port_name);
|
||||
}
|
||||
// Store the port into a Vec to move to the ProcessHandler
|
||||
ports.push(port);
|
||||
}
|
||||
Err(e) => {
|
||||
// If port creation failed, send the error back via the error_callback
|
||||
error_callback(
|
||||
BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let playing = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr;
|
||||
|
||||
let input_process_handler = LocalProcessHandler::new(
|
||||
vec![],
|
||||
ports,
|
||||
SampleRate(client.sample_rate() as u32),
|
||||
client.buffer_size() as usize,
|
||||
Some(Box::new(data_callback)),
|
||||
None,
|
||||
playing.clone(),
|
||||
Arc::clone(&error_callback_ptr),
|
||||
);
|
||||
|
||||
let notification_handler = JackNotificationHandler::new(error_callback_ptr);
|
||||
|
||||
let async_client = client
|
||||
.activate_async(notification_handler, input_process_handler)
|
||||
.unwrap();
|
||||
|
||||
Stream {
|
||||
playing,
|
||||
async_client,
|
||||
input_port_names: port_names,
|
||||
output_port_names: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_output<D, E>(
|
||||
client: jack::Client,
|
||||
channels: ChannelCount,
|
||||
data_callback: D,
|
||||
mut error_callback: E,
|
||||
) -> Stream
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let mut ports = vec![];
|
||||
let mut port_names: Vec<String> = vec![];
|
||||
// Create ports
|
||||
for i in 0..channels {
|
||||
let port_try = client.register_port(&format!("out_{}", i), jack::AudioOut::default());
|
||||
match port_try {
|
||||
Ok(port) => {
|
||||
// Get the port name in order to later connect it automatically
|
||||
if let Ok(port_name) = port.name() {
|
||||
port_names.push(port_name);
|
||||
}
|
||||
// Store the port into a Vec to move to the ProcessHandler
|
||||
ports.push(port);
|
||||
}
|
||||
Err(e) => {
|
||||
// If port creation failed, send the error back via the error_callback
|
||||
error_callback(
|
||||
BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let playing = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr;
|
||||
|
||||
let output_process_handler = LocalProcessHandler::new(
|
||||
ports,
|
||||
vec![],
|
||||
SampleRate(client.sample_rate() as u32),
|
||||
client.buffer_size() as usize,
|
||||
None,
|
||||
Some(Box::new(data_callback)),
|
||||
playing.clone(),
|
||||
Arc::clone(&error_callback_ptr),
|
||||
);
|
||||
|
||||
let notification_handler = JackNotificationHandler::new(error_callback_ptr);
|
||||
|
||||
let async_client = client
|
||||
.activate_async(notification_handler, output_process_handler)
|
||||
.unwrap();
|
||||
|
||||
Stream {
|
||||
playing,
|
||||
async_client,
|
||||
input_port_names: vec![],
|
||||
output_port_names: port_names,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the standard system outputs in jack, system:playback_1 and system:playback_2
|
||||
/// This has to be done after the client is activated, doing it just after creating the ports doesn't work.
|
||||
pub fn connect_to_system_outputs(&mut self) {
|
||||
// Get the system ports
|
||||
let system_ports = self.async_client.as_client().ports(
|
||||
Some("system:playback_.*"),
|
||||
None,
|
||||
jack::PortFlags::empty(),
|
||||
);
|
||||
|
||||
// Connect outputs from this client to the system playback inputs
|
||||
for i in 0..self.output_port_names.len() {
|
||||
if i >= system_ports.len() {
|
||||
break;
|
||||
}
|
||||
match self
|
||||
.async_client
|
||||
.as_client()
|
||||
.connect_ports_by_name(&self.output_port_names[i], &system_ports[i])
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => println!("Unable to connect to port with error {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the standard system outputs in jack, system:capture_1 and system:capture_2
|
||||
/// This has to be done after the client is activated, doing it just after creating the ports doesn't work.
|
||||
pub fn connect_to_system_inputs(&mut self) {
|
||||
// Get the system ports
|
||||
let system_ports = self.async_client.as_client().ports(
|
||||
Some("system:capture_.*"),
|
||||
None,
|
||||
jack::PortFlags::empty(),
|
||||
);
|
||||
|
||||
// Connect outputs from this client to the system playback inputs
|
||||
for i in 0..self.input_port_names.len() {
|
||||
if i >= system_ports.len() {
|
||||
break;
|
||||
}
|
||||
match self
|
||||
.async_client
|
||||
.as_client()
|
||||
.connect_ports_by_name(&system_ports[i], &self.input_port_names[i])
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => println!("Unable to connect to port with error {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
self.playing.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
self.playing.store(false, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalProcessHandler {
|
||||
/// No new ports are allowed to be created after the creation of the LocalProcessHandler as that would invalidate the buffer sizes
|
||||
out_ports: Vec<jack::Port<jack::AudioOut>>,
|
||||
in_ports: Vec<jack::Port<jack::AudioIn>>,
|
||||
|
||||
sample_rate: SampleRate,
|
||||
buffer_size: usize,
|
||||
input_data_callback: Option<Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>>,
|
||||
output_data_callback: Option<Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>>,
|
||||
|
||||
// JACK audio samples are 32-bit float (unless you do some custom dark magic)
|
||||
temp_input_buffer: Vec<f32>,
|
||||
temp_output_buffer: Vec<f32>,
|
||||
playing: Arc<AtomicBool>,
|
||||
creation_timestamp: std::time::Instant,
|
||||
/// This should not be called on `process`, only on `buffer_size` because it can block.
|
||||
error_callback_ptr: ErrorCallbackPtr,
|
||||
}
|
||||
|
||||
impl LocalProcessHandler {
|
||||
fn new(
|
||||
out_ports: Vec<jack::Port<jack::AudioOut>>,
|
||||
in_ports: Vec<jack::Port<jack::AudioIn>>,
|
||||
sample_rate: SampleRate,
|
||||
buffer_size: usize,
|
||||
input_data_callback: Option<Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>>,
|
||||
output_data_callback: Option<
|
||||
Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>,
|
||||
>,
|
||||
playing: Arc<AtomicBool>,
|
||||
error_callback_ptr: ErrorCallbackPtr,
|
||||
) -> Self {
|
||||
// These may be reallocated in the `buffer_size` callback.
|
||||
let temp_input_buffer = vec![0.0; in_ports.len() * buffer_size];
|
||||
let temp_output_buffer = vec![0.0; out_ports.len() * buffer_size];
|
||||
|
||||
LocalProcessHandler {
|
||||
out_ports,
|
||||
in_ports,
|
||||
sample_rate,
|
||||
buffer_size,
|
||||
input_data_callback,
|
||||
output_data_callback,
|
||||
temp_input_buffer,
|
||||
temp_output_buffer,
|
||||
playing,
|
||||
creation_timestamp: std::time::Instant::now(),
|
||||
error_callback_ptr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_buffer_to_data(temp_input_buffer: &mut Vec<f32>, total_buffer_size: usize) -> Data {
|
||||
let slice = &temp_input_buffer[0..total_buffer_size];
|
||||
let data = slice.as_ptr() as *mut ();
|
||||
let len = total_buffer_size;
|
||||
let data = unsafe { Data::from_parts(data, len, JACK_SAMPLE_FORMAT) };
|
||||
data
|
||||
}
|
||||
|
||||
impl jack::ProcessHandler for LocalProcessHandler {
|
||||
fn process(&mut self, _: &jack::Client, process_scope: &jack::ProcessScope) -> jack::Control {
|
||||
if !self.playing.load(Ordering::SeqCst) {
|
||||
return jack::Control::Continue;
|
||||
}
|
||||
|
||||
// This should be equal to self.buffer_size, but the implementation will
|
||||
// work even if it is less. Will panic in `temp_buffer_to_data` if greater.
|
||||
let current_frame_count = process_scope.n_frames() as usize;
|
||||
|
||||
// Get timestamp data
|
||||
let cycle_times = process_scope.cycle_times();
|
||||
let current_start_usecs = match cycle_times {
|
||||
Ok(times) => times.current_usecs,
|
||||
Err(_) => {
|
||||
// jack was unable to get the current time information
|
||||
// Fall back to using Instants
|
||||
let now = std::time::Instant::now();
|
||||
let duration = now.duration_since(self.creation_timestamp);
|
||||
duration.as_micros() as u64
|
||||
}
|
||||
};
|
||||
let start_cycle_instant = micros_to_stream_instant(current_start_usecs);
|
||||
let start_callback_instant = start_cycle_instant
|
||||
.add(frames_to_duration(
|
||||
process_scope.frames_since_cycle_start() as usize,
|
||||
self.sample_rate,
|
||||
))
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
|
||||
if let Some(input_callback) = &mut self.input_data_callback {
|
||||
// Let's get the data from the input ports and run the callback
|
||||
|
||||
let num_in_channels = self.in_ports.len();
|
||||
|
||||
// Read the data from the input ports into the temporary buffer
|
||||
// Go through every channel and store its data in the temporary input buffer
|
||||
for ch_ix in 0..num_in_channels {
|
||||
let input_channel = &self.in_ports[ch_ix].as_slice(process_scope);
|
||||
for i in 0..current_frame_count {
|
||||
self.temp_input_buffer[ch_ix + i * num_in_channels] = input_channel[i];
|
||||
}
|
||||
}
|
||||
// Create a slice of exactly current_frame_count frames
|
||||
let data = temp_buffer_to_data(
|
||||
&mut self.temp_input_buffer,
|
||||
current_frame_count * num_in_channels,
|
||||
);
|
||||
// Create timestamp
|
||||
let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize;
|
||||
let duration_since_cycle_start =
|
||||
frames_to_duration(frames_since_cycle_start, self.sample_rate);
|
||||
let callback = start_callback_instant
|
||||
.add(duration_since_cycle_start)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let capture = start_callback_instant;
|
||||
let timestamp = crate::InputStreamTimestamp { callback, capture };
|
||||
let info = crate::InputCallbackInfo { timestamp };
|
||||
input_callback(&data, &info);
|
||||
}
|
||||
|
||||
if let Some(output_callback) = &mut self.output_data_callback {
|
||||
let num_out_channels = self.out_ports.len();
|
||||
|
||||
// Create a slice of exactly current_frame_count frames
|
||||
let mut data = temp_buffer_to_data(
|
||||
&mut self.temp_output_buffer,
|
||||
current_frame_count * num_out_channels,
|
||||
);
|
||||
// Create timestamp
|
||||
let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize;
|
||||
let duration_since_cycle_start =
|
||||
frames_to_duration(frames_since_cycle_start, self.sample_rate);
|
||||
let callback = start_callback_instant
|
||||
.add(duration_since_cycle_start)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let buffer_duration = frames_to_duration(current_frame_count, self.sample_rate);
|
||||
let playback = start_cycle_instant
|
||||
.add(buffer_duration)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
let info = crate::OutputCallbackInfo { timestamp };
|
||||
output_callback(&mut data, &info);
|
||||
|
||||
// Deinterlace
|
||||
for ch_ix in 0..num_out_channels {
|
||||
let output_channel = &mut self.out_ports[ch_ix].as_mut_slice(process_scope);
|
||||
for i in 0..current_frame_count {
|
||||
output_channel[i] = self.temp_output_buffer[ch_ix + i * num_out_channels];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue as normal
|
||||
jack::Control::Continue
|
||||
}
|
||||
|
||||
fn buffer_size(&mut self, _: &jack::Client, size: jack::Frames) -> jack::Control {
|
||||
// The `buffer_size` callback is actually called on the process thread, but
|
||||
// it does not need to be suitable for real-time use. Thus we can simply allocate
|
||||
// new buffers here. It is also fine to call the error callback.
|
||||
// Details: https://github.com/RustAudio/rust-jack/issues/137
|
||||
let new_size = size as usize;
|
||||
if new_size != self.buffer_size {
|
||||
self.buffer_size = new_size;
|
||||
self.temp_input_buffer = vec![0.0; self.in_ports.len() * new_size];
|
||||
self.temp_output_buffer = vec![0.0; self.out_ports.len() * new_size];
|
||||
let description = format!("buffer size changed to: {}", new_size);
|
||||
if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() {
|
||||
let err = &mut *mutex_guard;
|
||||
err(BackendSpecificError { description }.into());
|
||||
}
|
||||
}
|
||||
|
||||
jack::Control::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn micros_to_stream_instant(micros: u64) -> crate::StreamInstant {
|
||||
let nanos = micros * 1000;
|
||||
let secs = micros / 1_000_000;
|
||||
let subsec_nanos = nanos - secs * 1_000_000_000;
|
||||
crate::StreamInstant::new(secs as i64, subsec_nanos as u32)
|
||||
}
|
||||
|
||||
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
|
||||
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
|
||||
let secsf = frames as f64 / rate.0 as f64;
|
||||
let secs = secsf as u64;
|
||||
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
|
||||
std::time::Duration::new(secs, nanos)
|
||||
}
|
||||
|
||||
/// Receives notifications from the JACK server. It is unclear if this may be run concurrent with itself under JACK2 specs
|
||||
/// so it needs to be Sync.
|
||||
struct JackNotificationHandler {
|
||||
error_callback_ptr: ErrorCallbackPtr,
|
||||
init_sample_rate_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl JackNotificationHandler {
|
||||
pub fn new(error_callback_ptr: ErrorCallbackPtr) -> Self {
|
||||
JackNotificationHandler {
|
||||
error_callback_ptr,
|
||||
init_sample_rate_flag: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_error(&mut self, description: String) {
|
||||
// This thread isn't the audio thread, it's fine to block
|
||||
if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() {
|
||||
let err = &mut *mutex_guard;
|
||||
err(BackendSpecificError { description }.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl jack::NotificationHandler for JackNotificationHandler {
|
||||
fn shutdown(&mut self, _status: jack::ClientStatus, reason: &str) {
|
||||
self.send_error(format!("JACK was shut down for reason: {}", reason));
|
||||
}
|
||||
|
||||
fn sample_rate(&mut self, _: &jack::Client, srate: jack::Frames) -> jack::Control {
|
||||
match self.init_sample_rate_flag.load(Ordering::SeqCst) {
|
||||
false => {
|
||||
// One of these notifications is sent every time a client is started.
|
||||
self.init_sample_rate_flag.store(true, Ordering::SeqCst);
|
||||
jack::Control::Continue
|
||||
}
|
||||
true => {
|
||||
self.send_error(format!("sample rate changed to: {}", srate));
|
||||
// Since CPAL currently has no way of signaling a sample rate change in order to make
|
||||
// all necessary changes that would bring we choose to quit.
|
||||
jack::Control::Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xrun(&mut self, _: &jack::Client) -> jack::Control {
|
||||
self.send_error(String::from("xrun (buffer over or under run)"));
|
||||
jack::Control::Continue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
pub(crate) mod alsa;
|
||||
#[cfg(all(windows, feature = "asio"))]
|
||||
pub(crate) mod asio;
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
pub(crate) mod coreaudio;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
pub(crate) mod emscripten;
|
||||
#[cfg(all(
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
),
|
||||
feature = "jack"
|
||||
))]
|
||||
pub(crate) mod jack;
|
||||
pub(crate) mod null;
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) mod oboe;
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod wasapi;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))]
|
||||
pub(crate) mod webaudio;
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
|
||||
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
|
||||
StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigRange,
|
||||
SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Devices;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Device;
|
||||
|
||||
pub struct Host;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Stream;
|
||||
|
||||
pub struct SupportedInputConfigs;
|
||||
pub struct SupportedOutputConfigs;
|
||||
|
||||
impl Host {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
pub fn new() -> Result<Self, DevicesError> {
|
||||
Ok(Devices)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok("null".to_owned())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
_config: &StreamConfig,
|
||||
_sample_format: SampleFormat,
|
||||
_data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// Create an output stream.
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
_config: &StreamConfig,
|
||||
_sample_format: SampleFormat,
|
||||
_data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Device> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Device> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SupportedInputConfigs {
|
||||
type Item = SupportedStreamConfigRange;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<SupportedStreamConfigRange> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SupportedOutputConfigs {
|
||||
type Item = SupportedStreamConfigRange;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<SupportedStreamConfigRange> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
extern crate jni;
|
||||
|
||||
use self::jni::Executor;
|
||||
use self::jni::{errors::Result as JResult, JNIEnv, JavaVM};
|
||||
|
||||
// constants from android.media.AudioFormat
|
||||
pub const ENCODING_PCM_16BIT: i32 = 2;
|
||||
pub const ENCODING_PCM_FLOAT: i32 = 4;
|
||||
pub const CHANNEL_OUT_MONO: i32 = 4;
|
||||
pub const CHANNEL_OUT_STEREO: i32 = 12;
|
||||
|
||||
fn with_attached<F, R>(closure: F) -> JResult<R>
|
||||
where
|
||||
F: FnOnce(&mut JNIEnv) -> JResult<R>,
|
||||
{
|
||||
let android_context = ndk_context::android_context();
|
||||
let vm = Arc::new(unsafe { JavaVM::from_raw(android_context.vm().cast())? });
|
||||
Executor::new(vm).with_attached(|env| closure(env))
|
||||
}
|
||||
|
||||
fn get_min_buffer_size(
|
||||
class: &'static str,
|
||||
sample_rate: i32,
|
||||
channel_mask: i32,
|
||||
format: i32,
|
||||
) -> i32 {
|
||||
// Unwrapping everything because these operations are not expected to fail
|
||||
// or throw exceptions. Android returns negative values for invalid parameters,
|
||||
// which is what we expect.
|
||||
with_attached(|env| {
|
||||
let class = env.find_class(class).unwrap();
|
||||
env.call_static_method(
|
||||
class,
|
||||
"getMinBufferSize",
|
||||
"(III)I",
|
||||
&[sample_rate.into(), channel_mask.into(), format.into()],
|
||||
)
|
||||
.unwrap()
|
||||
.i()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn get_audio_track_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 {
|
||||
get_min_buffer_size(
|
||||
"android/media/AudioTrack",
|
||||
sample_rate,
|
||||
channel_mask,
|
||||
format,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_audio_record_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 {
|
||||
get_min_buffer_size(
|
||||
"android/media/AudioRecord",
|
||||
sample_rate,
|
||||
channel_mask,
|
||||
format,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::convert::TryInto;
|
||||
use std::time::Duration;
|
||||
|
||||
extern crate oboe;
|
||||
|
||||
use crate::{
|
||||
BackendSpecificError, BuildStreamError, PauseStreamError, PlayStreamError, StreamError,
|
||||
StreamInstant,
|
||||
};
|
||||
|
||||
pub fn to_stream_instant(duration: Duration) -> StreamInstant {
|
||||
StreamInstant::new(
|
||||
duration.as_secs().try_into().unwrap(),
|
||||
duration.subsec_nanos(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream_instant<T: oboe::AudioStreamSafe + ?Sized>(stream: &mut T) -> StreamInstant {
|
||||
const CLOCK_MONOTONIC: i32 = 1;
|
||||
let ts = stream
|
||||
.get_timestamp(CLOCK_MONOTONIC)
|
||||
.unwrap_or(oboe::FrameTimestamp {
|
||||
position: 0,
|
||||
timestamp: 0,
|
||||
});
|
||||
to_stream_instant(Duration::from_nanos(ts.timestamp as u64))
|
||||
}
|
||||
|
||||
impl From<oboe::Error> for StreamError {
|
||||
fn from(error: oboe::Error) -> Self {
|
||||
use self::oboe::Error::*;
|
||||
match error {
|
||||
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
|
||||
e => (BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<oboe::Error> for PlayStreamError {
|
||||
fn from(error: oboe::Error) -> Self {
|
||||
use self::oboe::Error::*;
|
||||
match error {
|
||||
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
|
||||
e => (BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<oboe::Error> for PauseStreamError {
|
||||
fn from(error: oboe::Error) -> Self {
|
||||
use self::oboe::Error::*;
|
||||
match error {
|
||||
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
|
||||
e => (BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<oboe::Error> for BuildStreamError {
|
||||
fn from(error: oboe::Error) -> Self {
|
||||
use self::oboe::Error::*;
|
||||
match error {
|
||||
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
|
||||
NoFreeHandles => Self::StreamIdOverflow,
|
||||
InvalidFormat | InvalidRate => Self::StreamConfigNotSupported,
|
||||
IllegalArgument => Self::InvalidArgument,
|
||||
e => (BackendSpecificError {
|
||||
description: e.to_string(),
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Instant;
|
||||
|
||||
extern crate oboe;
|
||||
|
||||
use super::convert::{stream_instant, to_stream_instant};
|
||||
use crate::{Data, InputCallbackInfo, InputStreamTimestamp, SizedSample, StreamError};
|
||||
|
||||
pub struct CpalInputCallback<I, C> {
|
||||
data_cb: Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>,
|
||||
error_cb: Box<dyn FnMut(StreamError) + Send + 'static>,
|
||||
created: Instant,
|
||||
phantom_channel: PhantomData<C>,
|
||||
phantom_input: PhantomData<I>,
|
||||
}
|
||||
|
||||
impl<I, C> CpalInputCallback<I, C> {
|
||||
pub fn new<D, E>(data_cb: D, error_cb: E) -> Self
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
data_cb: Box::new(data_cb),
|
||||
error_cb: Box::new(error_cb),
|
||||
created: Instant::now(),
|
||||
phantom_channel: PhantomData,
|
||||
phantom_input: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_callback_info(
|
||||
&self,
|
||||
audio_stream: &mut dyn oboe::AudioInputStreamSafe,
|
||||
) -> InputCallbackInfo {
|
||||
InputCallbackInfo {
|
||||
timestamp: InputStreamTimestamp {
|
||||
callback: to_stream_instant(self.created.elapsed()),
|
||||
capture: stream_instant(audio_stream),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SizedSample, C: oboe::IsChannelCount> oboe::AudioInputCallback for CpalInputCallback<T, C>
|
||||
where
|
||||
(T, C): oboe::IsFrameType,
|
||||
{
|
||||
type FrameType = (T, C);
|
||||
|
||||
fn on_error_before_close(
|
||||
&mut self,
|
||||
_audio_stream: &mut dyn oboe::AudioInputStreamSafe,
|
||||
error: oboe::Error,
|
||||
) {
|
||||
(self.error_cb)(StreamError::from(error))
|
||||
}
|
||||
|
||||
fn on_error_after_close(
|
||||
&mut self,
|
||||
_audio_stream: &mut dyn oboe::AudioInputStreamSafe,
|
||||
error: oboe::Error,
|
||||
) {
|
||||
(self.error_cb)(StreamError::from(error))
|
||||
}
|
||||
|
||||
fn on_audio_ready(
|
||||
&mut self,
|
||||
audio_stream: &mut dyn oboe::AudioInputStreamSafe,
|
||||
audio_data: &[<<Self as oboe::AudioInputCallback>::FrameType as oboe::IsFrameType>::Type],
|
||||
) -> oboe::DataCallbackResult {
|
||||
let cb_info = self.make_callback_info(audio_stream);
|
||||
let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
(self.data_cb)(
|
||||
&unsafe {
|
||||
Data::from_parts(
|
||||
audio_data.as_ptr() as *mut _,
|
||||
audio_data.len() * channel_count,
|
||||
T::FORMAT,
|
||||
)
|
||||
},
|
||||
&cb_info,
|
||||
);
|
||||
oboe::DataCallbackResult::Continue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
use std::cell::RefCell;
|
||||
use std::cmp;
|
||||
use std::convert::TryInto;
|
||||
use std::time::Duration;
|
||||
use std::vec::IntoIter as VecIntoIter;
|
||||
|
||||
extern crate oboe;
|
||||
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
|
||||
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
|
||||
PlayStreamError, SampleFormat, SampleRate, SizedSample, StreamConfig, StreamError,
|
||||
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
|
||||
SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
mod android_media;
|
||||
mod convert;
|
||||
mod input_callback;
|
||||
mod output_callback;
|
||||
|
||||
use self::android_media::{get_audio_record_min_buffer_size, get_audio_track_min_buffer_size};
|
||||
use self::input_callback::CpalInputCallback;
|
||||
use self::oboe::{AudioInputStream, AudioOutputStream};
|
||||
use self::output_callback::CpalOutputCallback;
|
||||
|
||||
// Android Java API supports up to 8 channels, but oboe API
|
||||
// only exposes mono and stereo.
|
||||
const CHANNEL_MASKS: [i32; 2] = [
|
||||
android_media::CHANNEL_OUT_MONO,
|
||||
android_media::CHANNEL_OUT_STEREO,
|
||||
];
|
||||
|
||||
const SAMPLE_RATES: [i32; 13] = [
|
||||
5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176_400, 192_000,
|
||||
];
|
||||
|
||||
pub struct Host;
|
||||
#[derive(Clone)]
|
||||
pub struct Device(Option<oboe::AudioDeviceInfo>);
|
||||
pub enum Stream {
|
||||
Input(Box<RefCell<dyn AudioInputStream>>),
|
||||
Output(Box<RefCell<dyn AudioOutputStream>>),
|
||||
}
|
||||
pub type SupportedInputConfigs = VecIntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = VecIntoIter<SupportedStreamConfigRange>;
|
||||
pub type Devices = VecIntoIter<Device>;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
if let Ok(devices) = oboe::AudioDeviceInfo::request(oboe::AudioDeviceDirection::InputOutput)
|
||||
{
|
||||
Ok(devices
|
||||
.into_iter()
|
||||
.map(|d| Device(Some(d)))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter())
|
||||
} else {
|
||||
Ok(vec![Device(None)].into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
Some(Device(None))
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
Some(Device(None))
|
||||
}
|
||||
}
|
||||
|
||||
fn buffer_size_range_for_params(
|
||||
is_output: bool,
|
||||
sample_rate: i32,
|
||||
channel_mask: i32,
|
||||
android_format: i32,
|
||||
) -> SupportedBufferSize {
|
||||
let min_buffer_size = if is_output {
|
||||
get_audio_track_min_buffer_size(sample_rate, channel_mask, android_format)
|
||||
} else {
|
||||
get_audio_record_min_buffer_size(sample_rate, channel_mask, android_format)
|
||||
};
|
||||
if min_buffer_size > 0 {
|
||||
SupportedBufferSize::Range {
|
||||
min: min_buffer_size as u32,
|
||||
max: i32::MAX as u32,
|
||||
}
|
||||
} else {
|
||||
SupportedBufferSize::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn default_supported_configs(is_output: bool) -> VecIntoIter<SupportedStreamConfigRange> {
|
||||
// Have to "brute force" the parameter combinations with getMinBufferSize
|
||||
const FORMATS: [SampleFormat; 2] = [SampleFormat::I16, SampleFormat::F32];
|
||||
|
||||
let mut output = Vec::with_capacity(SAMPLE_RATES.len() * CHANNEL_MASKS.len() * FORMATS.len());
|
||||
for sample_format in &FORMATS {
|
||||
let android_format = if *sample_format == SampleFormat::I16 {
|
||||
android_media::ENCODING_PCM_16BIT
|
||||
} else {
|
||||
android_media::ENCODING_PCM_FLOAT
|
||||
};
|
||||
for (mask_idx, channel_mask) in CHANNEL_MASKS.iter().enumerate() {
|
||||
let channel_count = mask_idx + 1;
|
||||
for sample_rate in &SAMPLE_RATES {
|
||||
if let SupportedBufferSize::Range { min, max } = buffer_size_range_for_params(
|
||||
is_output,
|
||||
*sample_rate,
|
||||
*channel_mask,
|
||||
android_format,
|
||||
) {
|
||||
output.push(SupportedStreamConfigRange {
|
||||
channels: channel_count as u16,
|
||||
min_sample_rate: SampleRate(*sample_rate as u32),
|
||||
max_sample_rate: SampleRate(*sample_rate as u32),
|
||||
buffer_size: SupportedBufferSize::Range { min, max },
|
||||
sample_format: *sample_format,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.into_iter()
|
||||
}
|
||||
|
||||
fn device_supported_configs(
|
||||
device: &oboe::AudioDeviceInfo,
|
||||
is_output: bool,
|
||||
) -> VecIntoIter<SupportedStreamConfigRange> {
|
||||
let sample_rates = if !device.sample_rates.is_empty() {
|
||||
device.sample_rates.as_slice()
|
||||
} else {
|
||||
&SAMPLE_RATES
|
||||
};
|
||||
|
||||
const ALL_CHANNELS: [i32; 2] = [1, 2];
|
||||
let channel_counts = if !device.channel_counts.is_empty() {
|
||||
device.channel_counts.as_slice()
|
||||
} else {
|
||||
&ALL_CHANNELS
|
||||
};
|
||||
|
||||
const ALL_FORMATS: [oboe::AudioFormat; 2] = [oboe::AudioFormat::I16, oboe::AudioFormat::F32];
|
||||
let formats = if !device.formats.is_empty() {
|
||||
device.formats.as_slice()
|
||||
} else {
|
||||
&ALL_FORMATS
|
||||
};
|
||||
|
||||
let mut output = Vec::with_capacity(sample_rates.len() * channel_counts.len() * formats.len());
|
||||
for sample_rate in sample_rates {
|
||||
for channel_count in channel_counts {
|
||||
assert!(*channel_count > 0);
|
||||
if *channel_count > 2 {
|
||||
// could be supported by the device, but oboe does not support more than 2 channels
|
||||
continue;
|
||||
}
|
||||
let channel_mask = CHANNEL_MASKS[*channel_count as usize - 1];
|
||||
for format in formats {
|
||||
let (android_format, sample_format) = match format {
|
||||
oboe::AudioFormat::I16 => {
|
||||
(android_media::ENCODING_PCM_16BIT, SampleFormat::I16)
|
||||
}
|
||||
oboe::AudioFormat::F32 => {
|
||||
(android_media::ENCODING_PCM_FLOAT, SampleFormat::F32)
|
||||
}
|
||||
_ => panic!("Unexpected format"),
|
||||
};
|
||||
let buffer_size = buffer_size_range_for_params(
|
||||
is_output,
|
||||
*sample_rate,
|
||||
channel_mask,
|
||||
android_format,
|
||||
);
|
||||
output.push(SupportedStreamConfigRange {
|
||||
channels: cmp::min(*channel_count as u16, 2u16),
|
||||
min_sample_rate: SampleRate(*sample_rate as u32),
|
||||
max_sample_rate: SampleRate(*sample_rate as u32),
|
||||
buffer_size,
|
||||
sample_format,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.into_iter()
|
||||
}
|
||||
|
||||
fn configure_for_device<D, C, I>(
|
||||
builder: oboe::AudioStreamBuilder<D, C, I>,
|
||||
device: &Device,
|
||||
config: &StreamConfig,
|
||||
) -> oboe::AudioStreamBuilder<D, C, I> {
|
||||
let mut builder = if let Some(info) = &device.0 {
|
||||
builder.set_device_id(info.id)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
builder = builder.set_sample_rate(config.sample_rate.0.try_into().unwrap());
|
||||
match &config.buffer_size {
|
||||
BufferSize::Default => builder,
|
||||
BufferSize::Fixed(size) => builder.set_buffer_capacity_in_frames(*size as i32),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_stream<D, E, C, T>(
|
||||
device: &Device,
|
||||
config: &StreamConfig,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
builder: oboe::AudioStreamBuilder<oboe::Input, C, T>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
T: SizedSample + oboe::IsFormat + Send + 'static,
|
||||
C: oboe::IsChannelCount + Send + 'static,
|
||||
(T, C): oboe::IsFrameType,
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let builder = configure_for_device(builder, device, config);
|
||||
let stream = builder
|
||||
.set_callback(CpalInputCallback::<T, C>::new(
|
||||
data_callback,
|
||||
error_callback,
|
||||
))
|
||||
.open_stream()?;
|
||||
Ok(Stream::Input(Box::new(RefCell::new(stream))))
|
||||
}
|
||||
|
||||
fn build_output_stream<D, E, C, T>(
|
||||
device: &Device,
|
||||
config: &StreamConfig,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
builder: oboe::AudioStreamBuilder<oboe::Output, C, T>,
|
||||
) -> Result<Stream, BuildStreamError>
|
||||
where
|
||||
T: SizedSample + oboe::IsFormat + Send + 'static,
|
||||
C: oboe::IsChannelCount + Send + 'static,
|
||||
(T, C): oboe::IsFrameType,
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let builder = configure_for_device(builder, device, config);
|
||||
let stream = builder
|
||||
.set_callback(CpalOutputCallback::<T, C>::new(
|
||||
data_callback,
|
||||
error_callback,
|
||||
))
|
||||
.open_stream()?;
|
||||
Ok(Stream::Output(Box::new(RefCell::new(stream))))
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
match &self.0 {
|
||||
None => Ok("default".to_owned()),
|
||||
Some(info) => Ok(info.product_name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
if let Some(info) = &self.0 {
|
||||
Ok(device_supported_configs(info, false))
|
||||
} else {
|
||||
Ok(default_supported_configs(false))
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
if let Some(info) = &self.0 {
|
||||
Ok(device_supported_configs(info, true))
|
||||
} else {
|
||||
Ok(default_supported_configs(true))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let mut configs: Vec<_> = self.supported_input_configs().unwrap().collect();
|
||||
configs.sort_by(|a, b| b.cmp_default_heuristics(a));
|
||||
let config = configs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?
|
||||
.with_max_sample_rate();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
let mut configs: Vec<_> = self.supported_output_configs().unwrap().collect();
|
||||
configs.sort_by(|a, b| b.cmp_default_heuristics(a));
|
||||
let config = configs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?
|
||||
.with_max_sample_rate();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
match sample_format {
|
||||
SampleFormat::I16 => {
|
||||
let builder = oboe::AudioStreamBuilder::default()
|
||||
.set_input()
|
||||
.set_format::<i16>();
|
||||
if config.channels == 1 {
|
||||
build_input_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_mono(),
|
||||
)
|
||||
} else if config.channels == 2 {
|
||||
build_input_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_stereo(),
|
||||
)
|
||||
} else {
|
||||
Err(BackendSpecificError {
|
||||
description: "More than 2 channels are not supported by Oboe.".to_owned(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
SampleFormat::F32 => {
|
||||
let builder = oboe::AudioStreamBuilder::default()
|
||||
.set_input()
|
||||
.set_format::<f32>();
|
||||
if config.channels == 1 {
|
||||
build_input_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_mono(),
|
||||
)
|
||||
} else if config.channels == 2 {
|
||||
build_input_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_stereo(),
|
||||
)
|
||||
} else {
|
||||
Err(BackendSpecificError {
|
||||
description: "More than 2 channels are not supported by Oboe.".to_owned(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
sample_format => Err(BackendSpecificError {
|
||||
description: format!("{} format is not supported on Android.", sample_format),
|
||||
}
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
match sample_format {
|
||||
SampleFormat::I16 => {
|
||||
let builder = oboe::AudioStreamBuilder::default()
|
||||
.set_output()
|
||||
.set_format::<i16>();
|
||||
if config.channels == 1 {
|
||||
build_output_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_mono(),
|
||||
)
|
||||
} else if config.channels == 2 {
|
||||
build_output_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_stereo(),
|
||||
)
|
||||
} else {
|
||||
Err(BackendSpecificError {
|
||||
description: "More than 2 channels are not supported by Oboe.".to_owned(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
SampleFormat::F32 => {
|
||||
let builder = oboe::AudioStreamBuilder::default()
|
||||
.set_output()
|
||||
.set_format::<f32>();
|
||||
if config.channels == 1 {
|
||||
build_output_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_mono(),
|
||||
)
|
||||
} else if config.channels == 2 {
|
||||
build_output_stream(
|
||||
self,
|
||||
config,
|
||||
data_callback,
|
||||
error_callback,
|
||||
builder.set_stereo(),
|
||||
)
|
||||
} else {
|
||||
Err(BackendSpecificError {
|
||||
description: "More than 2 channels are not supported by Oboe.".to_owned(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
sample_format => Err(BackendSpecificError {
|
||||
description: format!("{} format is not supported on Android.", sample_format),
|
||||
}
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
match self {
|
||||
Self::Input(stream) => stream
|
||||
.borrow_mut()
|
||||
.request_start()
|
||||
.map_err(PlayStreamError::from),
|
||||
Self::Output(stream) => stream
|
||||
.borrow_mut()
|
||||
.request_start()
|
||||
.map_err(PlayStreamError::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
match self {
|
||||
Self::Input(_) => Err(BackendSpecificError {
|
||||
description: "Pause called on the input stream.".to_owned(),
|
||||
}
|
||||
.into()),
|
||||
Self::Output(stream) => stream
|
||||
.borrow_mut()
|
||||
.request_pause()
|
||||
.map_err(PauseStreamError::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Instant;
|
||||
|
||||
extern crate oboe;
|
||||
|
||||
use super::convert::{stream_instant, to_stream_instant};
|
||||
use crate::{Data, OutputCallbackInfo, OutputStreamTimestamp, SizedSample, StreamError};
|
||||
|
||||
pub struct CpalOutputCallback<I, C> {
|
||||
data_cb: Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>,
|
||||
error_cb: Box<dyn FnMut(StreamError) + Send + 'static>,
|
||||
created: Instant,
|
||||
phantom_channel: PhantomData<C>,
|
||||
phantom_input: PhantomData<I>,
|
||||
}
|
||||
|
||||
impl<I, C> CpalOutputCallback<I, C> {
|
||||
pub fn new<D, E>(data_cb: D, error_cb: E) -> Self
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
data_cb: Box::new(data_cb),
|
||||
error_cb: Box::new(error_cb),
|
||||
created: Instant::now(),
|
||||
phantom_channel: PhantomData,
|
||||
phantom_input: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_callback_info(
|
||||
&self,
|
||||
audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
|
||||
) -> OutputCallbackInfo {
|
||||
OutputCallbackInfo {
|
||||
timestamp: OutputStreamTimestamp {
|
||||
callback: to_stream_instant(self.created.elapsed()),
|
||||
playback: stream_instant(audio_stream),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SizedSample, C: oboe::IsChannelCount> oboe::AudioOutputCallback for CpalOutputCallback<T, C>
|
||||
where
|
||||
(T, C): oboe::IsFrameType,
|
||||
{
|
||||
type FrameType = (T, C);
|
||||
|
||||
fn on_error_before_close(
|
||||
&mut self,
|
||||
_audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
|
||||
error: oboe::Error,
|
||||
) {
|
||||
(self.error_cb)(StreamError::from(error))
|
||||
}
|
||||
|
||||
fn on_error_after_close(
|
||||
&mut self,
|
||||
_audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
|
||||
error: oboe::Error,
|
||||
) {
|
||||
(self.error_cb)(StreamError::from(error))
|
||||
}
|
||||
|
||||
fn on_audio_ready(
|
||||
&mut self,
|
||||
audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
|
||||
audio_data: &mut [<<Self as oboe::AudioOutputCallback>::FrameType as oboe::IsFrameType>::Type],
|
||||
) -> oboe::DataCallbackResult {
|
||||
let cb_info = self.make_callback_info(audio_stream);
|
||||
let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
(self.data_cb)(
|
||||
&mut unsafe {
|
||||
Data::from_parts(
|
||||
audio_data.as_mut_ptr() as *mut _,
|
||||
audio_data.len() * channel_count,
|
||||
T::FORMAT,
|
||||
)
|
||||
},
|
||||
&cb_info,
|
||||
);
|
||||
oboe::DataCallbackResult::Continue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Handles COM initialization and cleanup.
|
||||
|
||||
use super::IoError;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
|
||||
|
||||
thread_local!(static COM_INITIALIZED: ComInitialized = {
|
||||
unsafe {
|
||||
// Try to initialize COM with STA by default to avoid compatibility issues with the ASIO
|
||||
// backend (where CoInitialize() is called by the ASIO SDK) or winit (where drag and drop
|
||||
// requires STA).
|
||||
// This call can fail with RPC_E_CHANGED_MODE if another library initialized COM with MTA.
|
||||
// That's OK though since COM ensures thread-safety/compatibility through marshalling when
|
||||
// necessary.
|
||||
let result = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
if result.is_ok() || result == RPC_E_CHANGED_MODE {
|
||||
ComInitialized {
|
||||
result,
|
||||
_ptr: PhantomData,
|
||||
}
|
||||
} else {
|
||||
// COM initialization failed in another way, something is really wrong.
|
||||
panic!(
|
||||
"Failed to initialize COM: {}",
|
||||
IoError::from_raw_os_error(result.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// RAII object that guards the fact that COM is initialized.
|
||||
///
|
||||
// We store a raw pointer because it's the only way at the moment to remove `Send`/`Sync` from the
|
||||
// object.
|
||||
struct ComInitialized {
|
||||
result: windows::core::HRESULT,
|
||||
_ptr: PhantomData<*mut ()>,
|
||||
}
|
||||
|
||||
impl Drop for ComInitialized {
|
||||
#[inline]
|
||||
fn drop(&mut self) {
|
||||
// Need to avoid calling CoUninitialize() if CoInitializeEx failed since it may have
|
||||
// returned RPC_E_MODE_CHANGED - which is OK, see above.
|
||||
if self.result.is_ok() {
|
||||
unsafe { CoUninitialize() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures that COM is initialized in this thread.
|
||||
#[inline]
|
||||
pub fn com_initialized() {
|
||||
COM_INITIALIZED.with(|_| {});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
pub use self::device::{
|
||||
default_input_device, default_output_device, Device, Devices, SupportedInputConfigs,
|
||||
SupportedOutputConfigs,
|
||||
};
|
||||
pub use self::stream::Stream;
|
||||
use crate::traits::HostTrait;
|
||||
use crate::BackendSpecificError;
|
||||
use crate::DevicesError;
|
||||
use std::io::Error as IoError;
|
||||
use windows::Win32::Media::Audio;
|
||||
|
||||
mod com;
|
||||
mod device;
|
||||
mod stream;
|
||||
|
||||
/// The WASAPI host, the default windows host type.
|
||||
///
|
||||
/// Note: If you use a WASAPI output device as an input device it will
|
||||
/// transparently enable loopback mode (see
|
||||
/// https://docs.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording).
|
||||
#[derive(Debug)]
|
||||
pub struct Host;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
// Assume WASAPI is always available on Windows.
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
default_input_device()
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
default_output_device()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<windows::core::Error> for BackendSpecificError {
|
||||
fn from(error: windows::core::Error) -> Self {
|
||||
BackendSpecificError {
|
||||
description: format!("{}", IoError::from(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait ErrDeviceNotAvailable: From<BackendSpecificError> {
|
||||
fn device_not_available() -> Self;
|
||||
}
|
||||
|
||||
impl ErrDeviceNotAvailable for crate::BuildStreamError {
|
||||
fn device_not_available() -> Self {
|
||||
Self::DeviceNotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrDeviceNotAvailable for crate::SupportedStreamConfigsError {
|
||||
fn device_not_available() -> Self {
|
||||
Self::DeviceNotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrDeviceNotAvailable for crate::DefaultStreamConfigError {
|
||||
fn device_not_available() -> Self {
|
||||
Self::DeviceNotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrDeviceNotAvailable for crate::StreamError {
|
||||
fn device_not_available() -> Self {
|
||||
Self::DeviceNotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_err_to_cpal_err<E: ErrDeviceNotAvailable>(e: windows::core::Error) -> E {
|
||||
windows_err_to_cpal_err_message::<E>(e, "")
|
||||
}
|
||||
|
||||
fn windows_err_to_cpal_err_message<E: ErrDeviceNotAvailable>(
|
||||
e: windows::core::Error,
|
||||
message: &str,
|
||||
) -> E {
|
||||
match e.code() {
|
||||
Audio::AUDCLNT_E_DEVICE_INVALIDATED => E::device_not_available(),
|
||||
_ => {
|
||||
let description = format!("{}{}", message, e);
|
||||
let err = BackendSpecificError { description };
|
||||
err.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
use super::windows_err_to_cpal_err;
|
||||
use crate::traits::StreamTrait;
|
||||
use crate::{
|
||||
BackendSpecificError, Data, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
|
||||
PlayStreamError, SampleFormat, StreamError,
|
||||
};
|
||||
use std::mem;
|
||||
use std::ptr;
|
||||
use std::sync::mpsc::{channel, Receiver, SendError, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use windows::Win32::Foundation;
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Foundation::WAIT_OBJECT_0;
|
||||
use windows::Win32::Media::Audio;
|
||||
use windows::Win32::System::SystemServices;
|
||||
use windows::Win32::System::Threading;
|
||||
|
||||
pub struct Stream {
|
||||
/// The high-priority audio processing thread calling callbacks.
|
||||
/// Option used for moving out in destructor.
|
||||
///
|
||||
/// TODO: Actually set the thread priority.
|
||||
thread: Option<JoinHandle<()>>,
|
||||
|
||||
// Commands processed by the `run()` method that is currently running.
|
||||
// `pending_scheduled_event` must be signalled whenever a command is added here, so that it
|
||||
// will get picked up.
|
||||
commands: Sender<Command>,
|
||||
|
||||
// This event is signalled after a new entry is added to `commands`, so that the `run()`
|
||||
// method can be notified.
|
||||
pending_scheduled_event: Foundation::HANDLE,
|
||||
}
|
||||
|
||||
struct RunContext {
|
||||
// Streams that have been created in this event loop.
|
||||
stream: StreamInner,
|
||||
|
||||
// Handles corresponding to the `event` field of each element of `voices`. Must always be in
|
||||
// sync with `voices`, except that the first element is always `pending_scheduled_event`.
|
||||
handles: Vec<Foundation::HANDLE>,
|
||||
|
||||
commands: Receiver<Command>,
|
||||
}
|
||||
|
||||
// Once we start running the eventloop, the RunContext will not be moved.
|
||||
unsafe impl Send for RunContext {}
|
||||
|
||||
pub enum Command {
|
||||
PlayStream,
|
||||
PauseStream,
|
||||
Terminate,
|
||||
}
|
||||
|
||||
pub enum AudioClientFlow {
|
||||
Render {
|
||||
render_client: Audio::IAudioRenderClient,
|
||||
},
|
||||
Capture {
|
||||
capture_client: Audio::IAudioCaptureClient,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct StreamInner {
|
||||
pub audio_client: Audio::IAudioClient,
|
||||
pub audio_clock: Audio::IAudioClock,
|
||||
pub client_flow: AudioClientFlow,
|
||||
// Event that is signalled by WASAPI whenever audio data must be written.
|
||||
pub event: Foundation::HANDLE,
|
||||
// True if the stream is currently playing. False if paused.
|
||||
pub playing: bool,
|
||||
// Number of frames of audio data in the underlying buffer allocated by WASAPI.
|
||||
pub max_frames_in_buffer: u32,
|
||||
// Number of bytes that each frame occupies.
|
||||
pub bytes_per_frame: u16,
|
||||
// The configuration with which the stream was created.
|
||||
pub config: crate::StreamConfig,
|
||||
// The sample format with which the stream was created.
|
||||
pub sample_format: SampleFormat,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
pub(crate) fn new_input<D, E>(
|
||||
stream_inner: StreamInner,
|
||||
mut data_callback: D,
|
||||
mut error_callback: E,
|
||||
) -> Stream
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let pending_scheduled_event = unsafe {
|
||||
Threading::CreateEventA(None, false, false, windows::core::PCSTR(ptr::null()))
|
||||
}
|
||||
.expect("cpal: could not create input stream event");
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let run_context = RunContext {
|
||||
handles: vec![pending_scheduled_event, stream_inner.event],
|
||||
stream: stream_inner,
|
||||
commands: rx,
|
||||
};
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("cpal_wasapi_in".to_owned())
|
||||
.spawn(move || run_input(run_context, &mut data_callback, &mut error_callback))
|
||||
.unwrap();
|
||||
|
||||
Stream {
|
||||
thread: Some(thread),
|
||||
commands: tx,
|
||||
pending_scheduled_event,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_output<D, E>(
|
||||
stream_inner: StreamInner,
|
||||
mut data_callback: D,
|
||||
mut error_callback: E,
|
||||
) -> Stream
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
let pending_scheduled_event = unsafe {
|
||||
Threading::CreateEventA(None, false, false, windows::core::PCSTR(ptr::null()))
|
||||
}
|
||||
.expect("cpal: could not create output stream event");
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let run_context = RunContext {
|
||||
handles: vec![pending_scheduled_event, stream_inner.event],
|
||||
stream: stream_inner,
|
||||
commands: rx,
|
||||
};
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("cpal_wasapi_out".to_owned())
|
||||
.spawn(move || run_output(run_context, &mut data_callback, &mut error_callback))
|
||||
.unwrap();
|
||||
|
||||
Stream {
|
||||
thread: Some(thread),
|
||||
commands: tx,
|
||||
pending_scheduled_event,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push_command(&self, command: Command) -> Result<(), SendError<Command>> {
|
||||
self.commands.send(command)?;
|
||||
unsafe {
|
||||
Threading::SetEvent(self.pending_scheduled_event).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
#[inline]
|
||||
fn drop(&mut self) {
|
||||
if self.push_command(Command::Terminate).is_ok() {
|
||||
self.thread.take().unwrap().join().unwrap();
|
||||
unsafe {
|
||||
let _ = Foundation::CloseHandle(self.pending_scheduled_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
self.push_command(Command::PlayStream)
|
||||
.map_err(|_| crate::error::PlayStreamError::DeviceNotAvailable)?;
|
||||
Ok(())
|
||||
}
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
self.push_command(Command::PauseStream)
|
||||
.map_err(|_| crate::error::PauseStreamError::DeviceNotAvailable)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamInner {
|
||||
#[inline]
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = Foundation::CloseHandle(self.event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any pending commands that are queued within the `RunContext`.
|
||||
// Returns `true` if the loop should continue running, `false` if it should terminate.
|
||||
fn process_commands(run_context: &mut RunContext) -> Result<bool, StreamError> {
|
||||
// Process the pending commands.
|
||||
for command in run_context.commands.try_iter() {
|
||||
match command {
|
||||
Command::PlayStream => unsafe {
|
||||
if !run_context.stream.playing {
|
||||
run_context
|
||||
.stream
|
||||
.audio_client
|
||||
.Start()
|
||||
.map_err(windows_err_to_cpal_err::<StreamError>)?;
|
||||
run_context.stream.playing = true;
|
||||
}
|
||||
},
|
||||
Command::PauseStream => unsafe {
|
||||
if run_context.stream.playing {
|
||||
run_context
|
||||
.stream
|
||||
.audio_client
|
||||
.Stop()
|
||||
.map_err(windows_err_to_cpal_err::<StreamError>)?;
|
||||
run_context.stream.playing = false;
|
||||
}
|
||||
},
|
||||
Command::Terminate => {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
// Wait for any of the given handles to be signalled.
|
||||
//
|
||||
// Returns the index of the `handle` that was signalled, or an `Err` if
|
||||
// `WaitForMultipleObjectsEx` fails.
|
||||
//
|
||||
// This is called when the `run` thread is ready to wait for the next event. The
|
||||
// next event might be some command submitted by the user (the first handle) or
|
||||
// might indicate that one of the streams is ready to deliver or receive audio.
|
||||
fn wait_for_handle_signal(handles: &[Foundation::HANDLE]) -> Result<usize, BackendSpecificError> {
|
||||
debug_assert!(handles.len() <= SystemServices::MAXIMUM_WAIT_OBJECTS as usize);
|
||||
let result = unsafe {
|
||||
Threading::WaitForMultipleObjectsEx(
|
||||
handles,
|
||||
false, // Don't wait for all, just wait for the first
|
||||
Threading::INFINITE, // TODO: allow setting a timeout
|
||||
false, // irrelevant parameter here
|
||||
)
|
||||
};
|
||||
if result == Foundation::WAIT_FAILED {
|
||||
let err = unsafe { Foundation::GetLastError() };
|
||||
let description = format!("`WaitForMultipleObjectsEx failed: {:?}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
return Err(err);
|
||||
}
|
||||
// Notifying the corresponding task handler.
|
||||
let handle_idx = (result.0 - WAIT_OBJECT_0.0) as usize;
|
||||
Ok(handle_idx)
|
||||
}
|
||||
|
||||
// Get the number of available frames that are available for writing/reading.
|
||||
fn get_available_frames(stream: &StreamInner) -> Result<u32, StreamError> {
|
||||
unsafe {
|
||||
let padding = stream
|
||||
.audio_client
|
||||
.GetCurrentPadding()
|
||||
.map_err(windows_err_to_cpal_err::<StreamError>)?;
|
||||
Ok(stream.max_frames_in_buffer - padding)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_input(
|
||||
mut run_ctxt: RunContext,
|
||||
data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo),
|
||||
error_callback: &mut dyn FnMut(StreamError),
|
||||
) {
|
||||
boost_current_thread_priority();
|
||||
|
||||
loop {
|
||||
match process_commands_and_await_signal(&mut run_ctxt, error_callback) {
|
||||
Some(ControlFlow::Break) => break,
|
||||
Some(ControlFlow::Continue) => continue,
|
||||
None => (),
|
||||
}
|
||||
let capture_client = match run_ctxt.stream.client_flow {
|
||||
AudioClientFlow::Capture { ref capture_client } => capture_client.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
match process_input(
|
||||
&run_ctxt.stream,
|
||||
capture_client,
|
||||
data_callback,
|
||||
error_callback,
|
||||
) {
|
||||
ControlFlow::Break => break,
|
||||
ControlFlow::Continue => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_output(
|
||||
mut run_ctxt: RunContext,
|
||||
data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo),
|
||||
error_callback: &mut dyn FnMut(StreamError),
|
||||
) {
|
||||
boost_current_thread_priority();
|
||||
|
||||
loop {
|
||||
match process_commands_and_await_signal(&mut run_ctxt, error_callback) {
|
||||
Some(ControlFlow::Break) => break,
|
||||
Some(ControlFlow::Continue) => continue,
|
||||
None => (),
|
||||
}
|
||||
let render_client = match run_ctxt.stream.client_flow {
|
||||
AudioClientFlow::Render { ref render_client } => render_client.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
match process_output(
|
||||
&run_ctxt.stream,
|
||||
render_client,
|
||||
data_callback,
|
||||
error_callback,
|
||||
) {
|
||||
ControlFlow::Break => break,
|
||||
ControlFlow::Continue => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boost_current_thread_priority() {
|
||||
unsafe {
|
||||
let thread_id = Threading::GetCurrentThreadId();
|
||||
|
||||
let _ = Threading::SetThreadPriority(
|
||||
HANDLE(thread_id as isize),
|
||||
Threading::THREAD_PRIORITY_TIME_CRITICAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ControlFlow {
|
||||
Break,
|
||||
Continue,
|
||||
}
|
||||
|
||||
fn process_commands_and_await_signal(
|
||||
run_context: &mut RunContext,
|
||||
error_callback: &mut dyn FnMut(StreamError),
|
||||
) -> Option<ControlFlow> {
|
||||
// Process queued commands.
|
||||
match process_commands(run_context) {
|
||||
Ok(true) => (),
|
||||
Ok(false) => return Some(ControlFlow::Break),
|
||||
Err(err) => {
|
||||
error_callback(err);
|
||||
return Some(ControlFlow::Break);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for any of the handles to be signalled.
|
||||
let handle_idx = match wait_for_handle_signal(&run_context.handles) {
|
||||
Ok(idx) => idx,
|
||||
Err(err) => {
|
||||
error_callback(err.into());
|
||||
return Some(ControlFlow::Break);
|
||||
}
|
||||
};
|
||||
|
||||
// If `handle_idx` is 0, then it's `pending_scheduled_event` that was signalled in
|
||||
// order for us to pick up the pending commands. Otherwise, a stream needs data.
|
||||
if handle_idx == 0 {
|
||||
return Some(ControlFlow::Continue);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// The loop for processing pending input data.
|
||||
fn process_input(
|
||||
stream: &StreamInner,
|
||||
capture_client: Audio::IAudioCaptureClient,
|
||||
data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo),
|
||||
error_callback: &mut dyn FnMut(StreamError),
|
||||
) -> ControlFlow {
|
||||
unsafe {
|
||||
// Get the available data in the shared buffer.
|
||||
let mut buffer: *mut u8 = ptr::null_mut();
|
||||
let mut flags = mem::MaybeUninit::uninit();
|
||||
loop {
|
||||
let mut frames_available = match capture_client.GetNextPacketSize() {
|
||||
Ok(0) => return ControlFlow::Continue,
|
||||
Ok(f) => f,
|
||||
Err(err) => {
|
||||
error_callback(windows_err_to_cpal_err(err));
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
};
|
||||
let mut qpc_position: u64 = 0;
|
||||
let result = capture_client.GetBuffer(
|
||||
&mut buffer,
|
||||
&mut frames_available,
|
||||
flags.as_mut_ptr(),
|
||||
None,
|
||||
Some(&mut qpc_position),
|
||||
);
|
||||
|
||||
match result {
|
||||
// TODO: Can this happen?
|
||||
Err(e) if e.code() == Audio::AUDCLNT_S_BUFFER_EMPTY => continue,
|
||||
Err(e) => {
|
||||
error_callback(windows_err_to_cpal_err(e));
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
Ok(_) => (),
|
||||
}
|
||||
|
||||
debug_assert!(!buffer.is_null());
|
||||
|
||||
let data = buffer as *mut ();
|
||||
let len = frames_available as usize * stream.bytes_per_frame as usize
|
||||
/ stream.sample_format.sample_size();
|
||||
let data = Data::from_parts(data, len, stream.sample_format);
|
||||
|
||||
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
|
||||
let timestamp = match input_timestamp(stream, qpc_position) {
|
||||
Ok(ts) => ts,
|
||||
Err(err) => {
|
||||
error_callback(err);
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
};
|
||||
let info = InputCallbackInfo { timestamp };
|
||||
data_callback(&data, &info);
|
||||
|
||||
// Release the buffer.
|
||||
let result = capture_client
|
||||
.ReleaseBuffer(frames_available)
|
||||
.map_err(windows_err_to_cpal_err);
|
||||
if let Err(err) = result {
|
||||
error_callback(err);
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The loop for writing output data.
|
||||
fn process_output(
|
||||
stream: &StreamInner,
|
||||
render_client: Audio::IAudioRenderClient,
|
||||
data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo),
|
||||
error_callback: &mut dyn FnMut(StreamError),
|
||||
) -> ControlFlow {
|
||||
// The number of frames available for writing.
|
||||
let frames_available = match get_available_frames(stream) {
|
||||
Ok(0) => return ControlFlow::Continue, // TODO: Can this happen?
|
||||
Ok(n) => n,
|
||||
Err(err) => {
|
||||
error_callback(err);
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let buffer = match render_client.GetBuffer(frames_available) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error_callback(windows_err_to_cpal_err(e));
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
};
|
||||
|
||||
debug_assert!(!buffer.is_null());
|
||||
|
||||
let data = buffer as *mut ();
|
||||
let len = frames_available as usize * stream.bytes_per_frame as usize
|
||||
/ stream.sample_format.sample_size();
|
||||
let mut data = Data::from_parts(data, len, stream.sample_format);
|
||||
let sample_rate = stream.config.sample_rate;
|
||||
let timestamp = match output_timestamp(stream, frames_available, sample_rate) {
|
||||
Ok(ts) => ts,
|
||||
Err(err) => {
|
||||
error_callback(err);
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
};
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
data_callback(&mut data, &info);
|
||||
|
||||
if let Err(err) = render_client.ReleaseBuffer(frames_available, 0) {
|
||||
error_callback(windows_err_to_cpal_err(err));
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
}
|
||||
|
||||
ControlFlow::Continue
|
||||
}
|
||||
|
||||
/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
|
||||
fn frames_to_duration(frames: u32, rate: crate::SampleRate) -> std::time::Duration {
|
||||
let secsf = frames as f64 / rate.0 as f64;
|
||||
let secs = secsf as u64;
|
||||
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
|
||||
std::time::Duration::new(secs, nanos)
|
||||
}
|
||||
|
||||
/// Use the stream's `IAudioClock` to produce the current stream instant.
|
||||
///
|
||||
/// Uses the QPC position produced via the `GetPosition` method.
|
||||
fn stream_instant(stream: &StreamInner) -> Result<crate::StreamInstant, StreamError> {
|
||||
let mut position: u64 = 0;
|
||||
let mut qpc_position: u64 = 0;
|
||||
unsafe {
|
||||
stream
|
||||
.audio_clock
|
||||
.GetPosition(&mut position, Some(&mut qpc_position))
|
||||
.map_err(windows_err_to_cpal_err::<StreamError>)?;
|
||||
};
|
||||
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
|
||||
let qpc_nanos = qpc_position as i128 * 100;
|
||||
let instant = crate::StreamInstant::from_nanos_i128(qpc_nanos)
|
||||
.expect("performance counter out of range of `StreamInstant` representation");
|
||||
Ok(instant)
|
||||
}
|
||||
|
||||
/// Produce the input stream timestamp.
|
||||
///
|
||||
/// `buffer_qpc_position` is the `qpc_position` returned via the `GetBuffer` call on the capture
|
||||
/// client. It represents the instant at which the first sample of the retrieved buffer was
|
||||
/// captured.
|
||||
fn input_timestamp(
|
||||
stream: &StreamInner,
|
||||
buffer_qpc_position: u64,
|
||||
) -> Result<crate::InputStreamTimestamp, StreamError> {
|
||||
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
|
||||
let qpc_nanos = buffer_qpc_position as i128 * 100;
|
||||
let capture = crate::StreamInstant::from_nanos_i128(qpc_nanos)
|
||||
.expect("performance counter out of range of `StreamInstant` representation");
|
||||
let callback = stream_instant(stream)?;
|
||||
Ok(crate::InputStreamTimestamp { capture, callback })
|
||||
}
|
||||
|
||||
/// Produce the output stream timestamp.
|
||||
///
|
||||
/// `frames_available` is the number of frames available for writing as reported by subtracting the
|
||||
/// result of `GetCurrentPadding` from the maximum buffer size.
|
||||
///
|
||||
/// `sample_rate` is the rate at which audio frames are processed by the device.
|
||||
///
|
||||
/// TODO: The returned `playback` is an estimate that assumes audio is delivered immediately after
|
||||
/// `frames_available` are consumed. The reality is that there is likely a tiny amount of latency
|
||||
/// after this, but not sure how to determine this.
|
||||
fn output_timestamp(
|
||||
stream: &StreamInner,
|
||||
frames_available: u32,
|
||||
sample_rate: crate::SampleRate,
|
||||
) -> Result<crate::OutputStreamTimestamp, StreamError> {
|
||||
let callback = stream_instant(stream)?;
|
||||
let buffer_duration = frames_to_duration(frames_available, sample_rate);
|
||||
let playback = callback
|
||||
.add(buffer_duration)
|
||||
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
|
||||
Ok(crate::OutputStreamTimestamp { callback, playback })
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
extern crate js_sys;
|
||||
extern crate wasm_bindgen;
|
||||
extern crate web_sys;
|
||||
|
||||
use self::js_sys::eval;
|
||||
use self::wasm_bindgen::prelude::*;
|
||||
use self::wasm_bindgen::JsCast;
|
||||
use self::web_sys::{AudioContext, AudioContextOptions};
|
||||
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use crate::{
|
||||
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
|
||||
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
|
||||
PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize,
|
||||
SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError,
|
||||
};
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Content is false if the iterator is empty.
|
||||
pub struct Devices(bool);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Device;
|
||||
|
||||
pub struct Host;
|
||||
|
||||
pub struct Stream {
|
||||
ctx: Arc<AudioContext>,
|
||||
on_ended_closures: Vec<Arc<RwLock<Option<Closure<dyn FnMut()>>>>>,
|
||||
config: StreamConfig,
|
||||
buffer_size_frames: usize,
|
||||
}
|
||||
|
||||
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
|
||||
|
||||
const MIN_CHANNELS: u16 = 1;
|
||||
const MAX_CHANNELS: u16 = 32;
|
||||
const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000);
|
||||
const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000);
|
||||
const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100);
|
||||
const MIN_BUFFER_SIZE: u32 = 1;
|
||||
const MAX_BUFFER_SIZE: u32 = u32::MAX;
|
||||
const DEFAULT_BUFFER_SIZE: usize = 2048;
|
||||
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Self, crate::HostUnavailable> {
|
||||
Ok(Host)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
// Assume this host is always available on webaudio.
|
||||
true
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError> {
|
||||
Devices::new()
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
default_input_device()
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
default_output_device()
|
||||
}
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
fn new() -> Result<Self, DevicesError> {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Ok("Default Device".to_owned())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
// TODO
|
||||
Ok(Vec::new().into_iter())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
let buffer_size = SupportedBufferSize::Range {
|
||||
min: MIN_BUFFER_SIZE,
|
||||
max: MAX_BUFFER_SIZE,
|
||||
};
|
||||
let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS)
|
||||
.map(|channels| SupportedStreamConfigRange {
|
||||
channels,
|
||||
min_sample_rate: MIN_SAMPLE_RATE,
|
||||
max_sample_rate: MAX_SAMPLE_RATE,
|
||||
buffer_size: buffer_size.clone(),
|
||||
sample_format: SUPPORTED_SAMPLE_FORMAT,
|
||||
})
|
||||
.collect();
|
||||
Ok(configs.into_iter())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
// TODO
|
||||
Err(DefaultStreamConfigError::StreamTypeNotSupported)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
const EXPECT: &str = "expected at least one valid webaudio stream config";
|
||||
let config = self
|
||||
.supported_output_configs()
|
||||
.expect(EXPECT)
|
||||
.max_by(|a, b| a.cmp_default_heuristics(b))
|
||||
.unwrap()
|
||||
.with_sample_rate(DEFAULT_SAMPLE_RATE);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
#[inline]
|
||||
fn name(&self) -> Result<String, DeviceNameError> {
|
||||
Device::name(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_input_configs(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
|
||||
Device::supported_output_configs(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_input_config(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
||||
Device::default_output_config(self)
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
_config: &StreamConfig,
|
||||
_sample_format: SampleFormat,
|
||||
_data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
// TODO
|
||||
Err(BuildStreamError::StreamConfigNotSupported)
|
||||
}
|
||||
|
||||
/// Create an output stream.
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
_error_callback: E,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
if !valid_config(config, sample_format) {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
}
|
||||
|
||||
let n_channels = config.channels as usize;
|
||||
|
||||
let buffer_size_frames = match config.buffer_size {
|
||||
BufferSize::Fixed(v) => {
|
||||
if v == 0 {
|
||||
return Err(BuildStreamError::StreamConfigNotSupported);
|
||||
} else {
|
||||
v as usize
|
||||
}
|
||||
}
|
||||
BufferSize::Default => DEFAULT_BUFFER_SIZE,
|
||||
};
|
||||
let buffer_size_samples = buffer_size_frames * n_channels;
|
||||
let buffer_time_step_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate);
|
||||
|
||||
let data_callback = Arc::new(Mutex::new(Box::new(data_callback)));
|
||||
|
||||
// Create the WebAudio stream.
|
||||
let mut stream_opts = AudioContextOptions::new();
|
||||
stream_opts.sample_rate(config.sample_rate.0 as f32);
|
||||
let ctx = AudioContext::new_with_context_options(&stream_opts).map_err(
|
||||
|err| -> BuildStreamError {
|
||||
let description = format!("{:?}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
err.into()
|
||||
},
|
||||
)?;
|
||||
|
||||
let destination = ctx.destination();
|
||||
|
||||
// If possible, set the destination's channel_count to the given config.channel.
|
||||
// If not, fallback on the default destination channel_count to keep previous behavior
|
||||
// and do not return an error.
|
||||
if config.channels as u32 <= destination.max_channel_count() {
|
||||
destination.set_channel_count(config.channels as u32);
|
||||
}
|
||||
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
// A container for managing the lifecycle of the audio callbacks.
|
||||
let mut on_ended_closures: Vec<Arc<RwLock<Option<Closure<dyn FnMut()>>>>> = Vec::new();
|
||||
|
||||
// A cursor keeping track of the current time at which new frames should be scheduled.
|
||||
let time = Arc::new(RwLock::new(0f64));
|
||||
|
||||
// Create a set of closures / callbacks which will continuously fetch and schedule sample
|
||||
// playback. Starting with two workers, e.g. a front and back buffer so that audio frames
|
||||
// can be fetched in the background.
|
||||
for _i in 0..2 {
|
||||
let data_callback_handle = data_callback.clone();
|
||||
let ctx_handle = ctx.clone();
|
||||
let time_handle = time.clone();
|
||||
|
||||
// A set of temporary buffers to be used for intermediate sample transformation steps.
|
||||
let mut temporary_buffer = vec![0f32; buffer_size_samples];
|
||||
let mut temporary_channel_buffer = vec![0f32; buffer_size_frames];
|
||||
|
||||
#[cfg(target_feature = "atomics")]
|
||||
let temporary_channel_array_view: js_sys::Float32Array;
|
||||
#[cfg(target_feature = "atomics")]
|
||||
{
|
||||
let temporary_channel_array = js_sys::ArrayBuffer::new(
|
||||
(std::mem::size_of::<f32>() * buffer_size_frames) as u32,
|
||||
);
|
||||
temporary_channel_array_view = js_sys::Float32Array::new(&temporary_channel_array);
|
||||
}
|
||||
|
||||
// Create a webaudio buffer which will be reused to avoid allocations.
|
||||
let ctx_buffer = ctx
|
||||
.create_buffer(
|
||||
config.channels as u32,
|
||||
buffer_size_frames as u32,
|
||||
config.sample_rate.0 as f32,
|
||||
)
|
||||
.map_err(|err| -> BuildStreamError {
|
||||
let description = format!("{:?}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
err.into()
|
||||
})?;
|
||||
|
||||
// A self reference to this closure for passing to future audio event calls.
|
||||
let on_ended_closure: Arc<RwLock<Option<Closure<dyn FnMut()>>>> =
|
||||
Arc::new(RwLock::new(None));
|
||||
let on_ended_closure_handle = on_ended_closure.clone();
|
||||
|
||||
on_ended_closure
|
||||
.write()
|
||||
.unwrap()
|
||||
.replace(Closure::wrap(Box::new(move || {
|
||||
let now = ctx_handle.current_time();
|
||||
let time_at_start_of_buffer = {
|
||||
let time_at_start_of_buffer = time_handle
|
||||
.read()
|
||||
.expect("Unable to get a read lock on the time cursor");
|
||||
// Synchronise first buffer as necessary (eg. keep the time value
|
||||
// referenced to the context clock).
|
||||
if *time_at_start_of_buffer > 0.001 {
|
||||
*time_at_start_of_buffer
|
||||
} else {
|
||||
// 25ms of time to fetch the first sample data, increase to avoid
|
||||
// initial underruns.
|
||||
now + 0.025
|
||||
}
|
||||
};
|
||||
|
||||
// Populate the sample data into an interleaved temporary buffer.
|
||||
{
|
||||
let len = temporary_buffer.len();
|
||||
let data = temporary_buffer.as_mut_ptr() as *mut ();
|
||||
let mut data = unsafe { Data::from_parts(data, len, sample_format) };
|
||||
let mut data_callback = data_callback_handle.lock().unwrap();
|
||||
let callback = crate::StreamInstant::from_secs_f64(now);
|
||||
let playback = crate::StreamInstant::from_secs_f64(time_at_start_of_buffer);
|
||||
let timestamp = crate::OutputStreamTimestamp { callback, playback };
|
||||
let info = OutputCallbackInfo { timestamp };
|
||||
(data_callback.deref_mut())(&mut data, &info);
|
||||
}
|
||||
|
||||
// Deinterleave the sample data and copy into the audio context buffer.
|
||||
// We do not reference the audio context buffer directly e.g. getChannelData.
|
||||
// As wasm-bindgen only gives us a copy, not a direct reference.
|
||||
for channel in 0..n_channels {
|
||||
for i in 0..buffer_size_frames {
|
||||
temporary_channel_buffer[i] =
|
||||
temporary_buffer[n_channels * i + channel];
|
||||
}
|
||||
|
||||
#[cfg(not(target_feature = "atomics"))]
|
||||
{
|
||||
ctx_buffer
|
||||
.copy_to_channel(&mut temporary_channel_buffer, channel as i32)
|
||||
.expect(
|
||||
"Unable to write sample data into the audio context buffer",
|
||||
);
|
||||
}
|
||||
|
||||
// copyToChannel cannot be directly copied into from a SharedArrayBuffer,
|
||||
// which WASM memory is backed by if the 'atomics' flag is enabled.
|
||||
// This workaround copies the data into an intermediary buffer first.
|
||||
// There's a chance browsers may eventually relax that requirement.
|
||||
// See this issue: https://github.com/WebAudio/web-audio-api/issues/2565
|
||||
#[cfg(target_feature = "atomics")]
|
||||
{
|
||||
temporary_channel_array_view.copy_from(&mut temporary_channel_buffer);
|
||||
ctx_buffer
|
||||
.unchecked_ref::<ExternalArrayAudioBuffer>()
|
||||
.copy_to_channel(&temporary_channel_array_view, channel as i32)
|
||||
.expect(
|
||||
"Unable to write sample data into the audio context buffer",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create an AudioBufferSourceNode, schedule it to playback the reused buffer
|
||||
// in the future.
|
||||
let source = ctx_handle
|
||||
.create_buffer_source()
|
||||
.expect("Unable to create a webaudio buffer source");
|
||||
source.set_buffer(Some(&ctx_buffer));
|
||||
source
|
||||
.connect_with_audio_node(&ctx_handle.destination())
|
||||
.expect(
|
||||
"Unable to connect the web audio buffer source to the context destination",
|
||||
);
|
||||
source.set_onended(Some(
|
||||
on_ended_closure_handle
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unchecked_ref(),
|
||||
));
|
||||
|
||||
source
|
||||
.start_with_when(time_at_start_of_buffer)
|
||||
.expect("Unable to start the webaudio buffer source");
|
||||
|
||||
// Keep track of when the next buffer worth of samples should be played.
|
||||
*time_handle.write().unwrap() = time_at_start_of_buffer + buffer_time_step_secs;
|
||||
}) as Box<dyn FnMut()>));
|
||||
|
||||
on_ended_closures.push(on_ended_closure);
|
||||
}
|
||||
|
||||
Ok(Stream {
|
||||
ctx,
|
||||
on_ended_closures,
|
||||
config: config.clone(),
|
||||
buffer_size_frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Return the [`AudioContext`](https://developer.mozilla.org/docs/Web/API/AudioContext) used
|
||||
/// by this stream.
|
||||
pub fn audio_context(&self) -> &AudioContext {
|
||||
&*self.ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), PlayStreamError> {
|
||||
let window = web_sys::window().unwrap();
|
||||
match self.ctx.resume() {
|
||||
Ok(_) => {
|
||||
// Begin webaudio playback, initially scheduling the closures to fire on a timeout
|
||||
// event.
|
||||
let mut offset_ms = 10;
|
||||
let time_step_secs =
|
||||
buffer_time_step_secs(self.buffer_size_frames, self.config.sample_rate);
|
||||
let time_step_ms = (time_step_secs * 1_000.0) as i32;
|
||||
for on_ended_closure in self.on_ended_closures.iter() {
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
on_ended_closure
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unchecked_ref(),
|
||||
offset_ms,
|
||||
)
|
||||
.unwrap();
|
||||
offset_ms += time_step_ms;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
let description = format!("{:?}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), PauseStreamError> {
|
||||
match self.ctx.suspend() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
let description = format!("{:?}", err);
|
||||
let err = BackendSpecificError { description };
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Devices {
|
||||
fn default() -> Devices {
|
||||
// We produce an empty iterator if the WebAudio API isn't available.
|
||||
Devices(is_webaudio_available())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Device> {
|
||||
if self.0 {
|
||||
self.0 = false;
|
||||
Some(Device)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_input_device() -> Option<Device> {
|
||||
// TODO
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn default_output_device() -> Option<Device> {
|
||||
if is_webaudio_available() {
|
||||
Some(Device)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Detects whether the `AudioContext` global variable is available.
|
||||
fn is_webaudio_available() -> bool {
|
||||
if let Ok(audio_context_is_defined) = eval("typeof AudioContext !== 'undefined'") {
|
||||
audio_context_is_defined.as_bool().unwrap()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Whether or not the given stream configuration is valid for building a stream.
|
||||
fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool {
|
||||
conf.channels <= MAX_CHANNELS
|
||||
&& conf.channels >= MIN_CHANNELS
|
||||
&& conf.sample_rate <= MAX_SAMPLE_RATE
|
||||
&& conf.sample_rate >= MIN_SAMPLE_RATE
|
||||
&& sample_format == SUPPORTED_SAMPLE_FORMAT
|
||||
}
|
||||
|
||||
fn buffer_time_step_secs(buffer_size_frames: usize, sample_rate: SampleRate) -> f64 {
|
||||
buffer_size_frames as f64 / sample_rate.0 as f64
|
||||
}
|
||||
|
||||
#[cfg(target_feature = "atomics")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = AudioBuffer)]
|
||||
type ExternalArrayAudioBuffer;
|
||||
|
||||
# [wasm_bindgen(catch, method, structural, js_class = "AudioBuffer", js_name = copyToChannel)]
|
||||
pub fn copy_to_channel(
|
||||
this: &ExternalArrayAudioBuffer,
|
||||
source: &js_sys::Float32Array,
|
||||
channel_number: i32,
|
||||
) -> Result<(), JsValue>;
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
//! # How to use cpal
|
||||
//!
|
||||
//! Here are some concepts cpal exposes:
|
||||
//!
|
||||
//! - A [`Host`] provides access to the available audio devices on the system.
|
||||
//! Some platforms have more than one host available, but every platform supported by CPAL has at
|
||||
//! least one [default_host] that is guaranteed to be available.
|
||||
//! - A [`Device`] is an audio device that may have any number of input and
|
||||
//! output streams.
|
||||
//! - A [`Stream`] is an open flow of audio data. Input streams allow you to
|
||||
//! receive audio data, output streams allow you to play audio data. You must choose which
|
||||
//! [Device] will run your stream before you can create one. Often, a default device can be
|
||||
//! retrieved via the [Host].
|
||||
//!
|
||||
//! The first step is to initialise the [`Host`]:
|
||||
//!
|
||||
//! ```
|
||||
//! use cpal::traits::HostTrait;
|
||||
//! let host = cpal::default_host();
|
||||
//! ```
|
||||
//!
|
||||
//! Then choose an available [`Device`]. The easiest way is to use the default input or output
|
||||
//! `Device` via the [`default_input_device()`] or [`default_output_device()`] methods on `host`.
|
||||
//!
|
||||
//! Alternatively, you can enumerate all the available devices with the [`devices()`] method.
|
||||
//! Beware that the `default_*_device()` functions return an `Option<Device>` in case no device
|
||||
//! is available for that stream type on the system.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use cpal::traits::HostTrait;
|
||||
//! # let host = cpal::default_host();
|
||||
//! let device = host.default_output_device().expect("no output device available");
|
||||
//! ```
|
||||
//!
|
||||
//! Before we can create a stream, we must decide what the configuration of the audio stream is
|
||||
//! going to be.
|
||||
//! You can query all the supported configurations with the
|
||||
//! [`supported_input_configs()`] and [`supported_output_configs()`] methods.
|
||||
//! These produce a list of [`SupportedStreamConfigRange`] structs which can later be turned into
|
||||
//! actual [`SupportedStreamConfig`] structs.
|
||||
//!
|
||||
//! If you don't want to query the list of configs,
|
||||
//! you can also build your own [`StreamConfig`] manually, but doing so could lead to an error when
|
||||
//! building the stream if the config is not supported by the device.
|
||||
//!
|
||||
//! > **Note**: the `supported_input/output_configs()` methods
|
||||
//! > could return an error for example if the device has been disconnected.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use cpal::traits::{DeviceTrait, HostTrait};
|
||||
//! # let host = cpal::default_host();
|
||||
//! # let device = host.default_output_device().unwrap();
|
||||
//! let mut supported_configs_range = device.supported_output_configs()
|
||||
//! .expect("error while querying configs");
|
||||
//! let supported_config = supported_configs_range.next()
|
||||
//! .expect("no supported config?!")
|
||||
//! .with_max_sample_rate();
|
||||
//! ```
|
||||
//!
|
||||
//! Now that we have everything for the stream, we are ready to create it from our selected device:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use cpal::Data;
|
||||
//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
//! # let host = cpal::default_host();
|
||||
//! # let device = host.default_output_device().unwrap();
|
||||
//! # let config = device.default_output_config().unwrap().into();
|
||||
//! let stream = device.build_output_stream(
|
||||
//! &config,
|
||||
//! move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
//! // react to stream events and read or write stream data here.
|
||||
//! },
|
||||
//! move |err| {
|
||||
//! // react to errors here.
|
||||
//! },
|
||||
//! None // None=blocking, Some(Duration)=timeout
|
||||
//! );
|
||||
//! ```
|
||||
//!
|
||||
//! While the stream is running, the selected audio device will periodically call the data callback
|
||||
//! that was passed to the function. The callback is passed an instance of either [`&Data` or
|
||||
//! `&mut Data`](Data) depending on whether the stream is an input stream or output stream respectively.
|
||||
//!
|
||||
//! > **Note**: Creating and running a stream will *not* block the thread. On modern platforms, the
|
||||
//! > given callback is called by a dedicated, high-priority thread responsible for delivering
|
||||
//! > audio data to the system's audio device in a timely manner. On older platforms that only
|
||||
//! > provide a blocking API (e.g. ALSA), CPAL will create a thread in order to consistently
|
||||
//! > provide non-blocking behaviour (currently this is a thread per stream, but this may change to
|
||||
//! > use a single thread for all streams). *If this is an issue for your platform or design,
|
||||
//! > please share your issue and use-case with the CPAL team on the GitHub issue tracker for
|
||||
//! > consideration.*
|
||||
//!
|
||||
//! In this example, we simply fill the given output buffer with silence.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use cpal::{Data, Sample, SampleFormat, FromSample};
|
||||
//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
//! # let host = cpal::default_host();
|
||||
//! # let device = host.default_output_device().unwrap();
|
||||
//! # let supported_config = device.default_output_config().unwrap();
|
||||
//! let err_fn = |err| eprintln!("an error occurred on the output audio stream: {}", err);
|
||||
//! let sample_format = supported_config.sample_format();
|
||||
//! let config = supported_config.into();
|
||||
//! let stream = match sample_format {
|
||||
//! SampleFormat::F32 => device.build_output_stream(&config, write_silence::<f32>, err_fn, None),
|
||||
//! SampleFormat::I16 => device.build_output_stream(&config, write_silence::<i16>, err_fn, None),
|
||||
//! SampleFormat::U16 => device.build_output_stream(&config, write_silence::<u16>, err_fn, None),
|
||||
//! sample_format => panic!("Unsupported sample format '{sample_format}'")
|
||||
//! }.unwrap();
|
||||
//!
|
||||
//! fn write_silence<T: Sample>(data: &mut [T], _: &cpal::OutputCallbackInfo) {
|
||||
//! for sample in data.iter_mut() {
|
||||
//! *sample = Sample::EQUILIBRIUM;
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Not all platforms automatically run the stream upon creation. To ensure the stream has started,
|
||||
//! we can use [`Stream::play`](traits::StreamTrait::play).
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
//! # let host = cpal::default_host();
|
||||
//! # let device = host.default_output_device().unwrap();
|
||||
//! # let supported_config = device.default_output_config().unwrap();
|
||||
//! # let sample_format = supported_config.sample_format();
|
||||
//! # let config = supported_config.into();
|
||||
//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {};
|
||||
//! # let err_fn = move |_err| {};
|
||||
//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap();
|
||||
//! stream.play().unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! Some devices support pausing the audio stream. This can be useful for saving energy in moments
|
||||
//! of silence.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
//! # let host = cpal::default_host();
|
||||
//! # let device = host.default_output_device().unwrap();
|
||||
//! # let supported_config = device.default_output_config().unwrap();
|
||||
//! # let sample_format = supported_config.sample_format();
|
||||
//! # let config = supported_config.into();
|
||||
//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {};
|
||||
//! # let err_fn = move |_err| {};
|
||||
//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap();
|
||||
//! stream.pause().unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! [`default_input_device()`]: traits::HostTrait::default_input_device
|
||||
//! [`default_output_device()`]: traits::HostTrait::default_output_device
|
||||
//! [`devices()`]: traits::HostTrait::devices
|
||||
//! [`supported_input_configs()`]: traits::DeviceTrait::supported_input_configs
|
||||
//! [`supported_output_configs()`]: traits::DeviceTrait::supported_output_configs
|
||||
|
||||
#![recursion_limit = "2048"]
|
||||
|
||||
// Extern crate declarations with `#[macro_use]` must unfortunately be at crate root.
|
||||
#[cfg(target_os = "emscripten")]
|
||||
#[macro_use]
|
||||
extern crate wasm_bindgen;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
extern crate js_sys;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
extern crate web_sys;
|
||||
|
||||
pub use error::*;
|
||||
pub use platform::{
|
||||
available_hosts, default_host, host_from_id, Device, Devices, Host, HostId, Stream,
|
||||
SupportedInputConfigs, SupportedOutputConfigs, ALL_HOSTS,
|
||||
};
|
||||
pub use samples_formats::{FromSample, Sample, SampleFormat, SizedSample, I24, I48, U24, U48};
|
||||
use std::convert::TryInto;
|
||||
use std::ops::{Div, Mul};
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
mod error;
|
||||
mod host;
|
||||
pub mod platform;
|
||||
mod samples_formats;
|
||||
pub mod traits;
|
||||
|
||||
/// A host's device iterator yielding only *input* devices.
|
||||
pub type InputDevices<I> = std::iter::Filter<I, fn(&<I as Iterator>::Item) -> bool>;
|
||||
|
||||
/// A host's device iterator yielding only *output* devices.
|
||||
pub type OutputDevices<I> = std::iter::Filter<I, fn(&<I as Iterator>::Item) -> bool>;
|
||||
|
||||
/// Number of channels.
|
||||
pub type ChannelCount = u16;
|
||||
|
||||
/// The number of samples processed per second for a single channel of audio.
|
||||
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SampleRate(pub u32);
|
||||
|
||||
impl<T> Mul<T> for SampleRate
|
||||
where
|
||||
u32: Mul<T, Output = u32>,
|
||||
{
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: T) -> Self {
|
||||
SampleRate(self.0 * rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Div<T> for SampleRate
|
||||
where
|
||||
u32: Div<T, Output = u32>,
|
||||
{
|
||||
type Output = Self;
|
||||
fn div(self, rhs: T) -> Self {
|
||||
SampleRate(self.0 / rhs)
|
||||
}
|
||||
}
|
||||
|
||||
/// The desired number of frames for the hardware buffer.
|
||||
pub type FrameCount = u32;
|
||||
|
||||
/// The buffer size used by the device.
|
||||
///
|
||||
/// [`Default`] is used when no specific buffer size is set and uses the default
|
||||
/// behavior of the given host. Note, the default buffer size may be surprisingly
|
||||
/// large, leading to latency issues. If low latency is desired, [`Fixed(FrameCount)`]
|
||||
/// should be used in accordance with the [`SupportedBufferSize`] range produced by
|
||||
/// the [`SupportedStreamConfig`] API.
|
||||
///
|
||||
/// [`Default`]: BufferSize::Default
|
||||
/// [`Fixed(FrameCount)`]: BufferSize::Fixed
|
||||
/// [`SupportedStreamConfig`]: SupportedStreamConfig::buffer_size
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BufferSize {
|
||||
Default,
|
||||
Fixed(FrameCount),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
impl wasm_bindgen::describe::WasmDescribe for BufferSize {
|
||||
fn describe() {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
impl wasm_bindgen::convert::IntoWasmAbi for BufferSize {
|
||||
type Abi = Option<u32>;
|
||||
fn into_abi(self) -> Self::Abi {
|
||||
match self {
|
||||
Self::Default => None,
|
||||
Self::Fixed(fc) => Some(fc),
|
||||
}
|
||||
.into_abi()
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of parameters used to describe how to open a stream.
|
||||
///
|
||||
/// The sample format is omitted in favour of using a sample type.
|
||||
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StreamConfig {
|
||||
pub channels: ChannelCount,
|
||||
pub sample_rate: SampleRate,
|
||||
pub buffer_size: BufferSize,
|
||||
}
|
||||
|
||||
/// Describes the minimum and maximum supported buffer size for the device
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SupportedBufferSize {
|
||||
Range {
|
||||
min: FrameCount,
|
||||
max: FrameCount,
|
||||
},
|
||||
/// In the case that the platform provides no way of getting the default
|
||||
/// buffersize before starting a stream.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Describes a range of supported stream configurations, retrieved via the
|
||||
/// [`Device::supported_input/output_configs`](traits::DeviceTrait#required-methods) method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SupportedStreamConfigRange {
|
||||
pub(crate) channels: ChannelCount,
|
||||
/// Minimum value for the samples rate of the supported formats.
|
||||
pub(crate) min_sample_rate: SampleRate,
|
||||
/// Maximum value for the samples rate of the supported formats.
|
||||
pub(crate) max_sample_rate: SampleRate,
|
||||
/// Buffersize ranges supported by the device
|
||||
pub(crate) buffer_size: SupportedBufferSize,
|
||||
/// Type of data expected by the device.
|
||||
pub(crate) sample_format: SampleFormat,
|
||||
}
|
||||
|
||||
/// Describes a single supported stream configuration, retrieved via either a
|
||||
/// [`SupportedStreamConfigRange`] instance or one of the
|
||||
/// [`Device::default_input/output_config`](traits::DeviceTrait#required-methods) methods.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SupportedStreamConfig {
|
||||
channels: ChannelCount,
|
||||
sample_rate: SampleRate,
|
||||
buffer_size: SupportedBufferSize,
|
||||
sample_format: SampleFormat,
|
||||
}
|
||||
|
||||
/// A buffer of dynamically typed audio data, passed to raw stream callbacks.
|
||||
///
|
||||
/// Raw input stream callbacks receive `&Data`, while raw output stream callbacks expect `&mut
|
||||
/// Data`.
|
||||
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
|
||||
#[derive(Debug)]
|
||||
pub struct Data {
|
||||
data: *mut (),
|
||||
len: usize,
|
||||
sample_format: SampleFormat,
|
||||
}
|
||||
|
||||
/// A monotonic time instance associated with a stream, retrieved from either:
|
||||
///
|
||||
/// 1. A timestamp provided to the stream's underlying audio data callback or
|
||||
/// 2. The same time source used to generate timestamps for a stream's underlying audio data
|
||||
/// callback.
|
||||
///
|
||||
/// `StreamInstant` represents a duration since some unspecified origin occurring either before
|
||||
/// or equal to the moment the stream from which it was created begins.
|
||||
///
|
||||
/// ## Host `StreamInstant` Sources
|
||||
///
|
||||
/// | Host | Source |
|
||||
/// | ---- | ------ |
|
||||
/// | alsa | `snd_pcm_status_get_htstamp` |
|
||||
/// | coreaudio | `mach_absolute_time` |
|
||||
/// | wasapi | `QueryPerformanceCounter` |
|
||||
/// | asio | `timeGetTime` |
|
||||
/// | emscripten | `AudioContext.getOutputTimestamp` |
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
||||
pub struct StreamInstant {
|
||||
secs: i64,
|
||||
nanos: u32,
|
||||
}
|
||||
|
||||
/// A timestamp associated with a call to an input stream's data callback.
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct InputStreamTimestamp {
|
||||
/// The instant the stream's data callback was invoked.
|
||||
pub callback: StreamInstant,
|
||||
/// The instant that data was captured from the device.
|
||||
///
|
||||
/// E.g. The instant data was read from an ADC.
|
||||
pub capture: StreamInstant,
|
||||
}
|
||||
|
||||
/// A timestamp associated with a call to an output stream's data callback.
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OutputStreamTimestamp {
|
||||
/// The instant the stream's data callback was invoked.
|
||||
pub callback: StreamInstant,
|
||||
/// The predicted instant that data written will be delivered to the device for playback.
|
||||
///
|
||||
/// E.g. The instant data will be played by a DAC.
|
||||
pub playback: StreamInstant,
|
||||
}
|
||||
|
||||
/// Information relevant to a single call to the user's input stream data callback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InputCallbackInfo {
|
||||
timestamp: InputStreamTimestamp,
|
||||
}
|
||||
|
||||
/// Information relevant to a single call to the user's output stream data callback.
|
||||
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutputCallbackInfo {
|
||||
timestamp: OutputStreamTimestamp,
|
||||
}
|
||||
|
||||
impl SupportedStreamConfig {
|
||||
pub fn new(
|
||||
channels: ChannelCount,
|
||||
sample_rate: SampleRate,
|
||||
buffer_size: SupportedBufferSize,
|
||||
sample_format: SampleFormat,
|
||||
) -> Self {
|
||||
Self {
|
||||
channels,
|
||||
sample_rate,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> ChannelCount {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> SampleRate {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
pub fn buffer_size(&self) -> &SupportedBufferSize {
|
||||
&self.buffer_size
|
||||
}
|
||||
|
||||
pub fn sample_format(&self) -> SampleFormat {
|
||||
self.sample_format
|
||||
}
|
||||
|
||||
pub fn config(&self) -> StreamConfig {
|
||||
StreamConfig {
|
||||
channels: self.channels,
|
||||
sample_rate: self.sample_rate,
|
||||
buffer_size: BufferSize::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamInstant {
|
||||
/// The amount of time elapsed from another instant to this one.
|
||||
///
|
||||
/// Returns `None` if `earlier` is later than self.
|
||||
pub fn duration_since(&self, earlier: &Self) -> Option<Duration> {
|
||||
if self < earlier {
|
||||
None
|
||||
} else {
|
||||
(self.as_nanos() - earlier.as_nanos())
|
||||
.try_into()
|
||||
.ok()
|
||||
.map(Duration::from_nanos)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the instant in time after the given duration has passed.
|
||||
///
|
||||
/// Returns `None` if the resulting instant would exceed the bounds of the underlying data
|
||||
/// structure.
|
||||
pub fn add(&self, duration: Duration) -> Option<Self> {
|
||||
self.as_nanos()
|
||||
.checked_add(duration.as_nanos() as i128)
|
||||
.and_then(Self::from_nanos_i128)
|
||||
}
|
||||
|
||||
/// Returns the instant in time one `duration` ago.
|
||||
///
|
||||
/// Returns `None` if the resulting instant would underflow. As a result, it is important to
|
||||
/// consider that on some platforms the [`StreamInstant`] may begin at `0` from the moment the
|
||||
/// source stream is created.
|
||||
pub fn sub(&self, duration: Duration) -> Option<Self> {
|
||||
self.as_nanos()
|
||||
.checked_sub(duration.as_nanos() as i128)
|
||||
.and_then(Self::from_nanos_i128)
|
||||
}
|
||||
|
||||
fn as_nanos(&self) -> i128 {
|
||||
(self.secs as i128 * 1_000_000_000) + self.nanos as i128
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn from_nanos(nanos: i64) -> Self {
|
||||
let secs = nanos / 1_000_000_000;
|
||||
let subsec_nanos = nanos - secs * 1_000_000_000;
|
||||
Self::new(secs, subsec_nanos as u32)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn from_nanos_i128(nanos: i128) -> Option<Self> {
|
||||
let secs = nanos / 1_000_000_000;
|
||||
if secs > i64::MAX as i128 || secs < i64::MIN as i128 {
|
||||
None
|
||||
} else {
|
||||
let subsec_nanos = nanos - secs * 1_000_000_000;
|
||||
debug_assert!(subsec_nanos < u32::MAX as i128);
|
||||
Some(Self::new(secs as i64, subsec_nanos as u32))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn from_secs_f64(secs: f64) -> crate::StreamInstant {
|
||||
let s = secs.floor() as i64;
|
||||
let ns = ((secs - s as f64) * 1_000_000_000.0) as u32;
|
||||
Self::new(s, ns)
|
||||
}
|
||||
|
||||
fn new(secs: i64, nanos: u32) -> Self {
|
||||
StreamInstant { secs, nanos }
|
||||
}
|
||||
}
|
||||
|
||||
impl InputCallbackInfo {
|
||||
/// The timestamp associated with the call to an input stream's data callback.
|
||||
pub fn timestamp(&self) -> InputStreamTimestamp {
|
||||
self.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputCallbackInfo {
|
||||
/// The timestamp associated with the call to an output stream's data callback.
|
||||
pub fn timestamp(&self) -> OutputStreamTimestamp {
|
||||
self.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
impl Data {
|
||||
// Internal constructor for host implementations to use.
|
||||
//
|
||||
// The following requirements must be met in order for the safety of `Data`'s public API.
|
||||
//
|
||||
// - The `data` pointer must point to the first sample in the slice containing all samples.
|
||||
// - The `len` must describe the length of the buffer as a number of samples in the expected
|
||||
// format specified via the `sample_format` argument.
|
||||
// - The `sample_format` must correctly represent the underlying sample data delivered/expected
|
||||
// by the stream.
|
||||
pub(crate) unsafe fn from_parts(
|
||||
data: *mut (),
|
||||
len: usize,
|
||||
sample_format: SampleFormat,
|
||||
) -> Self {
|
||||
Data {
|
||||
data,
|
||||
len,
|
||||
sample_format,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample format of the internal audio data.
|
||||
pub fn sample_format(&self) -> SampleFormat {
|
||||
self.sample_format
|
||||
}
|
||||
|
||||
/// The full length of the buffer in samples.
|
||||
///
|
||||
/// The returned length is the same length as the slice of type `T` that would be returned via
|
||||
/// [`as_slice`](Self::as_slice) given a sample type that matches the inner sample format.
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// The raw slice of memory representing the underlying audio data as a slice of bytes.
|
||||
///
|
||||
/// It is up to the user to interpret the slice of memory based on [`Data::sample_format`].
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
let len = self.len * self.sample_format.sample_size();
|
||||
// The safety of this block relies on correct construction of the `Data` instance.
|
||||
// See the unsafe `from_parts` constructor for these requirements.
|
||||
unsafe { std::slice::from_raw_parts(self.data as *const u8, len) }
|
||||
}
|
||||
|
||||
/// The raw slice of memory representing the underlying audio data as a slice of bytes.
|
||||
///
|
||||
/// It is up to the user to interpret the slice of memory based on [`Data::sample_format`].
|
||||
pub fn bytes_mut(&mut self) -> &mut [u8] {
|
||||
let len = self.len * self.sample_format.sample_size();
|
||||
// The safety of this block relies on correct construction of the `Data` instance. See
|
||||
// the unsafe `from_parts` constructor for these requirements.
|
||||
unsafe { std::slice::from_raw_parts_mut(self.data as *mut u8, len) }
|
||||
}
|
||||
|
||||
/// Access the data as a slice of sample type `T`.
|
||||
///
|
||||
/// Returns `None` if the sample type does not match the expected sample format.
|
||||
pub fn as_slice<T>(&self) -> Option<&[T]>
|
||||
where
|
||||
T: SizedSample,
|
||||
{
|
||||
if T::FORMAT == self.sample_format {
|
||||
// The safety of this block relies on correct construction of the `Data` instance. See
|
||||
// the unsafe `from_parts` constructor for these requirements.
|
||||
unsafe { Some(std::slice::from_raw_parts(self.data as *const T, self.len)) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the data as a slice of sample type `T`.
|
||||
///
|
||||
/// Returns `None` if the sample type does not match the expected sample format.
|
||||
pub fn as_slice_mut<T>(&mut self) -> Option<&mut [T]>
|
||||
where
|
||||
T: SizedSample,
|
||||
{
|
||||
if T::FORMAT == self.sample_format {
|
||||
// The safety of this block relies on correct construction of the `Data` instance. See
|
||||
// the unsafe `from_parts` constructor for these requirements.
|
||||
unsafe {
|
||||
Some(std::slice::from_raw_parts_mut(
|
||||
self.data as *mut T,
|
||||
self.len,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SupportedStreamConfigRange {
|
||||
pub fn new(
|
||||
channels: ChannelCount,
|
||||
min_sample_rate: SampleRate,
|
||||
max_sample_rate: SampleRate,
|
||||
buffer_size: SupportedBufferSize,
|
||||
sample_format: SampleFormat,
|
||||
) -> Self {
|
||||
Self {
|
||||
channels,
|
||||
min_sample_rate,
|
||||
max_sample_rate,
|
||||
buffer_size,
|
||||
sample_format,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> ChannelCount {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub fn min_sample_rate(&self) -> SampleRate {
|
||||
self.min_sample_rate
|
||||
}
|
||||
|
||||
pub fn max_sample_rate(&self) -> SampleRate {
|
||||
self.max_sample_rate
|
||||
}
|
||||
|
||||
pub fn buffer_size(&self) -> &SupportedBufferSize {
|
||||
&self.buffer_size
|
||||
}
|
||||
|
||||
pub fn sample_format(&self) -> SampleFormat {
|
||||
self.sample_format
|
||||
}
|
||||
|
||||
/// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the given `sample_rate` is outside the range specified within
|
||||
/// this [`SupportedStreamConfigRange`] instance. For a non-panicking
|
||||
/// variant, use [`try_with_sample_rate`](#method.try_with_sample_rate).
|
||||
pub fn with_sample_rate(self, sample_rate: SampleRate) -> SupportedStreamConfig {
|
||||
self.try_with_sample_rate(sample_rate)
|
||||
.expect("sample rate out of range")
|
||||
}
|
||||
|
||||
/// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size.
|
||||
///
|
||||
/// Returns `None` if the given sample rate is outside the range specified
|
||||
/// within this [`SupportedStreamConfigRange`] instance.
|
||||
pub fn try_with_sample_rate(self, sample_rate: SampleRate) -> Option<SupportedStreamConfig> {
|
||||
if self.min_sample_rate <= sample_rate && sample_rate <= self.max_sample_rate {
|
||||
Some(SupportedStreamConfig {
|
||||
channels: self.channels,
|
||||
sample_rate,
|
||||
sample_format: self.sample_format,
|
||||
buffer_size: self.buffer_size,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns this [`SupportedStreamConfigRange`] into a [`SupportedStreamConfig`] corresponding to the maximum samples rate.
|
||||
#[inline]
|
||||
pub fn with_max_sample_rate(self) -> SupportedStreamConfig {
|
||||
SupportedStreamConfig {
|
||||
channels: self.channels,
|
||||
sample_rate: self.max_sample_rate,
|
||||
sample_format: self.sample_format,
|
||||
buffer_size: self.buffer_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison function which compares two [`SupportedStreamConfigRange`]s in terms of their priority of
|
||||
/// use as a default stream format.
|
||||
///
|
||||
/// Some backends do not provide a default stream format for their audio devices. In these
|
||||
/// cases, CPAL attempts to decide on a reasonable default format for the user. To do this we
|
||||
/// use the "greatest" of all supported stream formats when compared with this method.
|
||||
///
|
||||
/// SupportedStreamConfigs are prioritised by the following heuristics:
|
||||
///
|
||||
/// **Channels**:
|
||||
///
|
||||
/// - Stereo
|
||||
/// - Mono
|
||||
/// - Max available channels
|
||||
///
|
||||
/// **Sample format**:
|
||||
/// - f32
|
||||
/// - i16
|
||||
/// - u16
|
||||
///
|
||||
/// **Sample rate**:
|
||||
///
|
||||
/// - 44100 (cd quality)
|
||||
/// - Max sample rate
|
||||
pub fn cmp_default_heuristics(&self, other: &Self) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering::Equal;
|
||||
use SampleFormat::{F32, I16, U16};
|
||||
|
||||
let cmp_stereo = (self.channels == 2).cmp(&(other.channels == 2));
|
||||
if cmp_stereo != Equal {
|
||||
return cmp_stereo;
|
||||
}
|
||||
|
||||
let cmp_mono = (self.channels == 1).cmp(&(other.channels == 1));
|
||||
if cmp_mono != Equal {
|
||||
return cmp_mono;
|
||||
}
|
||||
|
||||
let cmp_channels = self.channels.cmp(&other.channels);
|
||||
if cmp_channels != Equal {
|
||||
return cmp_channels;
|
||||
}
|
||||
|
||||
let cmp_f32 = (self.sample_format == F32).cmp(&(other.sample_format == F32));
|
||||
if cmp_f32 != Equal {
|
||||
return cmp_f32;
|
||||
}
|
||||
|
||||
let cmp_i16 = (self.sample_format == I16).cmp(&(other.sample_format == I16));
|
||||
if cmp_i16 != Equal {
|
||||
return cmp_i16;
|
||||
}
|
||||
|
||||
let cmp_u16 = (self.sample_format == U16).cmp(&(other.sample_format == U16));
|
||||
if cmp_u16 != Equal {
|
||||
return cmp_u16;
|
||||
}
|
||||
|
||||
const HZ_44100: SampleRate = SampleRate(44_100);
|
||||
let r44100_in_self = self.min_sample_rate <= HZ_44100 && HZ_44100 <= self.max_sample_rate;
|
||||
let r44100_in_other =
|
||||
other.min_sample_rate <= HZ_44100 && HZ_44100 <= other.max_sample_rate;
|
||||
let cmp_r44100 = r44100_in_self.cmp(&r44100_in_other);
|
||||
if cmp_r44100 != Equal {
|
||||
return cmp_r44100;
|
||||
}
|
||||
|
||||
self.max_sample_rate.cmp(&other.max_sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmp_default_heuristics() {
|
||||
let mut formats = [
|
||||
SupportedStreamConfigRange {
|
||||
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
|
||||
channels: 2,
|
||||
min_sample_rate: SampleRate(1),
|
||||
max_sample_rate: SampleRate(96000),
|
||||
sample_format: SampleFormat::F32,
|
||||
},
|
||||
SupportedStreamConfigRange {
|
||||
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
|
||||
channels: 1,
|
||||
min_sample_rate: SampleRate(1),
|
||||
max_sample_rate: SampleRate(96000),
|
||||
sample_format: SampleFormat::F32,
|
||||
},
|
||||
SupportedStreamConfigRange {
|
||||
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
|
||||
channels: 2,
|
||||
min_sample_rate: SampleRate(1),
|
||||
max_sample_rate: SampleRate(96000),
|
||||
sample_format: SampleFormat::I16,
|
||||
},
|
||||
SupportedStreamConfigRange {
|
||||
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
|
||||
channels: 2,
|
||||
min_sample_rate: SampleRate(1),
|
||||
max_sample_rate: SampleRate(96000),
|
||||
sample_format: SampleFormat::U16,
|
||||
},
|
||||
SupportedStreamConfigRange {
|
||||
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
|
||||
channels: 2,
|
||||
min_sample_rate: SampleRate(1),
|
||||
max_sample_rate: SampleRate(22050),
|
||||
sample_format: SampleFormat::F32,
|
||||
},
|
||||
];
|
||||
|
||||
formats.sort_by(|a, b| a.cmp_default_heuristics(b));
|
||||
|
||||
// lowest-priority first:
|
||||
assert_eq!(formats[0].sample_format(), SampleFormat::F32);
|
||||
assert_eq!(formats[0].min_sample_rate(), SampleRate(1));
|
||||
assert_eq!(formats[0].max_sample_rate(), SampleRate(96000));
|
||||
assert_eq!(formats[0].channels(), 1);
|
||||
|
||||
assert_eq!(formats[1].sample_format(), SampleFormat::U16);
|
||||
assert_eq!(formats[1].min_sample_rate(), SampleRate(1));
|
||||
assert_eq!(formats[1].max_sample_rate(), SampleRate(96000));
|
||||
assert_eq!(formats[1].channels(), 2);
|
||||
|
||||
assert_eq!(formats[2].sample_format(), SampleFormat::I16);
|
||||
assert_eq!(formats[2].min_sample_rate(), SampleRate(1));
|
||||
assert_eq!(formats[2].max_sample_rate(), SampleRate(96000));
|
||||
assert_eq!(formats[2].channels(), 2);
|
||||
|
||||
assert_eq!(formats[3].sample_format(), SampleFormat::F32);
|
||||
assert_eq!(formats[3].min_sample_rate(), SampleRate(1));
|
||||
assert_eq!(formats[3].max_sample_rate(), SampleRate(22050));
|
||||
assert_eq!(formats[3].channels(), 2);
|
||||
|
||||
assert_eq!(formats[4].sample_format(), SampleFormat::F32);
|
||||
assert_eq!(formats[4].min_sample_rate(), SampleRate(1));
|
||||
assert_eq!(formats[4].max_sample_rate(), SampleRate(96000));
|
||||
assert_eq!(formats[4].channels(), 2);
|
||||
}
|
||||
|
||||
impl From<SupportedStreamConfig> for StreamConfig {
|
||||
fn from(conf: SupportedStreamConfig) -> Self {
|
||||
conf.config()
|
||||
}
|
||||
}
|
||||
|
||||
// If a backend does not provide an API for retrieving supported formats, we query it with a bunch
|
||||
// of commonly used rates. This is always the case for wasapi and is sometimes the case for alsa.
|
||||
//
|
||||
// If a rate you desire is missing from this list, feel free to add it!
|
||||
#[cfg(target_os = "windows")]
|
||||
const COMMON_SAMPLE_RATES: &[SampleRate] = &[
|
||||
SampleRate(5512),
|
||||
SampleRate(8000),
|
||||
SampleRate(11025),
|
||||
SampleRate(16000),
|
||||
SampleRate(22050),
|
||||
SampleRate(32000),
|
||||
SampleRate(44100),
|
||||
SampleRate(48000),
|
||||
SampleRate(64000),
|
||||
SampleRate(88200),
|
||||
SampleRate(96000),
|
||||
SampleRate(176400),
|
||||
SampleRate(192000),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_stream_instant() {
|
||||
let a = StreamInstant::new(2, 0);
|
||||
let b = StreamInstant::new(-2, 0);
|
||||
let min = StreamInstant::new(i64::MIN, 0);
|
||||
let max = StreamInstant::new(i64::MAX, 0);
|
||||
assert_eq!(
|
||||
a.sub(Duration::from_secs(1)),
|
||||
Some(StreamInstant::new(1, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
a.sub(Duration::from_secs(2)),
|
||||
Some(StreamInstant::new(0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
a.sub(Duration::from_secs(3)),
|
||||
Some(StreamInstant::new(-1, 0))
|
||||
);
|
||||
assert_eq!(min.sub(Duration::from_secs(1)), None);
|
||||
assert_eq!(
|
||||
b.add(Duration::from_secs(1)),
|
||||
Some(StreamInstant::new(-1, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
b.add(Duration::from_secs(2)),
|
||||
Some(StreamInstant::new(0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
b.add(Duration::from_secs(3)),
|
||||
Some(StreamInstant::new(1, 0))
|
||||
);
|
||||
assert_eq!(max.add(Duration::from_secs(1)), None);
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Platform-specific items.
|
||||
//!
|
||||
//! This module also contains the implementation of the platform's dynamically dispatched [`Host`]
|
||||
//! type and its associated [`Device`], [`Stream`] and other associated types. These
|
||||
//! types are useful in the case that users require switching between audio host APIs at runtime.
|
||||
|
||||
#[doc(inline)]
|
||||
pub use self::platform_impl::*;
|
||||
|
||||
/// A macro to assist with implementing a platform's dynamically dispatched [`Host`] type.
|
||||
///
|
||||
/// These dynamically dispatched types are necessary to allow for users to switch between hosts at
|
||||
/// runtime.
|
||||
///
|
||||
/// For example the invocation `impl_platform_host(Wasapi wasapi "WASAPI", Asio asio "ASIO")`,
|
||||
/// this macro should expand to:
|
||||
///
|
||||
// This sample code block is marked as text because it's not a valid test,
|
||||
// it's just illustrative. (see rust issue #96573)
|
||||
/// ```text
|
||||
/// pub enum HostId {
|
||||
/// Wasapi,
|
||||
/// Asio,
|
||||
/// }
|
||||
///
|
||||
/// pub enum Host {
|
||||
/// Wasapi(crate::host::wasapi::Host),
|
||||
/// Asio(crate::host::asio::Host),
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// And so on for Device, Devices, Host, Stream, SupportedInputConfigs,
|
||||
/// SupportedOutputConfigs and all their necessary trait implementations.
|
||||
///
|
||||
macro_rules! impl_platform_host {
|
||||
($($(#[cfg($feat: meta)])? $HostVariant:ident $host_mod:ident $host_name:literal),*) => {
|
||||
/// All hosts supported by CPAL on this platform.
|
||||
pub const ALL_HOSTS: &'static [HostId] = &[
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostId::$HostVariant,
|
||||
)*
|
||||
];
|
||||
|
||||
/// The platform's dynamically dispatched `Host` type.
|
||||
///
|
||||
/// An instance of this `Host` type may represent one of the `Host`s available
|
||||
/// on the platform.
|
||||
///
|
||||
/// Use this type if you require switching between available hosts at runtime.
|
||||
///
|
||||
/// This type may be constructed via the [`host_from_id`] function. [`HostId`]s may
|
||||
/// be acquired via the [`ALL_HOSTS`] const, and the [`available_hosts`] function.
|
||||
pub struct Host(HostInner);
|
||||
|
||||
/// The `Device` implementation associated with the platform's dynamically dispatched
|
||||
/// [`Host`] type.
|
||||
#[derive(Clone)]
|
||||
pub struct Device(DeviceInner);
|
||||
|
||||
/// The `Devices` iterator associated with the platform's dynamically dispatched [`Host`]
|
||||
/// type.
|
||||
pub struct Devices(DevicesInner);
|
||||
|
||||
/// The `Stream` implementation associated with the platform's dynamically dispatched
|
||||
/// [`Host`] type.
|
||||
// Streams cannot be `Send` or `Sync` if we plan to support Android's AAudio API. This is
|
||||
// because the stream API is not thread-safe, and the API prohibits calling certain
|
||||
// functions within the callback.
|
||||
//
|
||||
// TODO: Confirm this and add more specific detail and references.
|
||||
#[must_use = "If the stream is not stored it will not play."]
|
||||
pub struct Stream(StreamInner, crate::platform::NotSendSyncAcrossAllPlatforms);
|
||||
|
||||
/// The `SupportedInputConfigs` iterator associated with the platform's dynamically
|
||||
/// dispatched [`Host`] type.
|
||||
pub struct SupportedInputConfigs(SupportedInputConfigsInner);
|
||||
|
||||
/// The `SupportedOutputConfigs` iterator associated with the platform's dynamically
|
||||
/// dispatched [`Host`] type.
|
||||
pub struct SupportedOutputConfigs(SupportedOutputConfigsInner);
|
||||
|
||||
/// Unique identifier for available hosts on the platform.
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum HostId {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant,
|
||||
)*
|
||||
}
|
||||
|
||||
/// Contains a platform specific [`Device`] implementation.
|
||||
#[derive(Clone)]
|
||||
pub enum DeviceInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::Device),
|
||||
)*
|
||||
}
|
||||
|
||||
/// Contains a platform specific [`Devices`] implementation.
|
||||
pub enum DevicesInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::Devices),
|
||||
)*
|
||||
}
|
||||
|
||||
/// Contains a platform specific [`Host`] implementation.
|
||||
pub enum HostInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::Host),
|
||||
)*
|
||||
}
|
||||
|
||||
/// Contains a platform specific [`Stream`] implementation.
|
||||
pub enum StreamInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::Stream),
|
||||
)*
|
||||
}
|
||||
|
||||
enum SupportedInputConfigsInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::SupportedInputConfigs),
|
||||
)*
|
||||
}
|
||||
|
||||
enum SupportedOutputConfigsInner {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
$HostVariant(crate::host::$host_mod::SupportedOutputConfigs),
|
||||
)*
|
||||
}
|
||||
|
||||
impl HostId {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostId::$HostVariant => $host_name,
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Devices {
|
||||
/// Returns a reference to the underlying platform specific implementation of this
|
||||
/// `Devices`.
|
||||
pub fn as_inner(&self) -> &DevicesInner {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying platform specific implementation of
|
||||
/// this `Devices`.
|
||||
pub fn as_inner_mut(&mut self) -> &mut DevicesInner {
|
||||
&mut self.0
|
||||
}
|
||||
|
||||
/// Returns the underlying platform specific implementation of this `Devices`.
|
||||
pub fn into_inner(self) -> DevicesInner {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// Returns a reference to the underlying platform specific implementation of this
|
||||
/// `Device`.
|
||||
pub fn as_inner(&self) -> &DeviceInner {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying platform specific implementation of
|
||||
/// this `Device`.
|
||||
pub fn as_inner_mut(&mut self) -> &mut DeviceInner {
|
||||
&mut self.0
|
||||
}
|
||||
|
||||
/// Returns the underlying platform specific implementation of this `Device`.
|
||||
pub fn into_inner(self) -> DeviceInner {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// The unique identifier associated with this `Host`.
|
||||
pub fn id(&self) -> HostId {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostInner::$HostVariant(_) => HostId::$HostVariant,
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a reference to the underlying platform specific implementation of this
|
||||
/// `Host`.
|
||||
pub fn as_inner(&self) -> &HostInner {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying platform specific implementation of
|
||||
/// this `Host`.
|
||||
pub fn as_inner_mut(&mut self) -> &mut HostInner {
|
||||
&mut self.0
|
||||
}
|
||||
|
||||
/// Returns the underlying platform specific implementation of this `Host`.
|
||||
pub fn into_inner(self) -> HostInner {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Returns a reference to the underlying platform specific implementation of this
|
||||
/// `Stream`.
|
||||
pub fn as_inner(&self) -> &StreamInner {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying platform specific implementation of
|
||||
/// this `Stream`.
|
||||
pub fn as_inner_mut(&mut self) -> &mut StreamInner {
|
||||
&mut self.0
|
||||
}
|
||||
|
||||
/// Returns the underlying platform specific implementation of this `Stream`.
|
||||
pub fn into_inner(self) -> StreamInner {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Devices {
|
||||
type Item = Device;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DevicesInner::$HostVariant(ref mut d) => {
|
||||
d.next().map(DeviceInner::$HostVariant).map(Device::from)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DevicesInner::$HostVariant(ref d) => d.size_hint(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SupportedInputConfigs {
|
||||
type Item = crate::SupportedStreamConfigRange;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
SupportedInputConfigsInner::$HostVariant(ref mut s) => s.next(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
SupportedInputConfigsInner::$HostVariant(ref d) => d.size_hint(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SupportedOutputConfigs {
|
||||
type Item = crate::SupportedStreamConfigRange;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
SupportedOutputConfigsInner::$HostVariant(ref mut s) => s.next(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
SupportedOutputConfigsInner::$HostVariant(ref d) => d.size_hint(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::traits::DeviceTrait for Device {
|
||||
type SupportedInputConfigs = SupportedInputConfigs;
|
||||
type SupportedOutputConfigs = SupportedOutputConfigs;
|
||||
type Stream = Stream;
|
||||
|
||||
fn name(&self) -> Result<String, crate::DeviceNameError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => d.name(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_input_configs(&self) -> Result<Self::SupportedInputConfigs, crate::SupportedStreamConfigsError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => {
|
||||
d.supported_input_configs()
|
||||
.map(SupportedInputConfigsInner::$HostVariant)
|
||||
.map(SupportedInputConfigs)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_output_configs(&self) -> Result<Self::SupportedOutputConfigs, crate::SupportedStreamConfigsError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => {
|
||||
d.supported_output_configs()
|
||||
.map(SupportedOutputConfigsInner::$HostVariant)
|
||||
.map(SupportedOutputConfigs)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn default_input_config(&self) -> Result<crate::SupportedStreamConfig, crate::DefaultStreamConfigError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => d.default_input_config(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_config(&self) -> Result<crate::SupportedStreamConfig, crate::DefaultStreamConfigError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => d.default_output_config(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &crate::StreamConfig,
|
||||
sample_format: crate::SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<std::time::Duration>,
|
||||
) -> Result<Self::Stream, crate::BuildStreamError>
|
||||
where
|
||||
D: FnMut(&crate::Data, &crate::InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(crate::StreamError) + Send + 'static,
|
||||
{
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => d
|
||||
.build_input_stream_raw(
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
.map(StreamInner::$HostVariant)
|
||||
.map(Stream::from),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &crate::StreamConfig,
|
||||
sample_format: crate::SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<std::time::Duration>,
|
||||
) -> Result<Self::Stream, crate::BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut crate::Data, &crate::OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(crate::StreamError) + Send + 'static,
|
||||
{
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
DeviceInner::$HostVariant(ref d) => d
|
||||
.build_output_stream_raw(
|
||||
config,
|
||||
sample_format,
|
||||
data_callback,
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
.map(StreamInner::$HostVariant)
|
||||
.map(Stream::from),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::traits::HostTrait for Host {
|
||||
type Devices = Devices;
|
||||
type Device = Device;
|
||||
|
||||
fn is_available() -> bool {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
if crate::host::$host_mod::Host::is_available() { return true; }
|
||||
)*
|
||||
false
|
||||
}
|
||||
|
||||
fn devices(&self) -> Result<Self::Devices, crate::DevicesError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostInner::$HostVariant(ref h) => {
|
||||
h.devices().map(DevicesInner::$HostVariant).map(Devices::from)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn default_input_device(&self) -> Option<Self::Device> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostInner::$HostVariant(ref h) => {
|
||||
h.default_input_device().map(DeviceInner::$HostVariant).map(Device::from)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_device(&self) -> Option<Self::Device> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostInner::$HostVariant(ref h) => {
|
||||
h.default_output_device().map(DeviceInner::$HostVariant).map(Device::from)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::traits::StreamTrait for Stream {
|
||||
fn play(&self) -> Result<(), crate::PlayStreamError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
StreamInner::$HostVariant(ref s) => {
|
||||
s.play()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), crate::PauseStreamError> {
|
||||
match self.0 {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
StreamInner::$HostVariant(ref s) => {
|
||||
s.pause()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeviceInner> for Device {
|
||||
fn from(d: DeviceInner) -> Self {
|
||||
Device(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DevicesInner> for Devices {
|
||||
fn from(d: DevicesInner) -> Self {
|
||||
Devices(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HostInner> for Host {
|
||||
fn from(h: HostInner) -> Self {
|
||||
Host(h)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StreamInner> for Stream {
|
||||
fn from(s: StreamInner) -> Self {
|
||||
Stream(s, Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
impl From<crate::host::$host_mod::Device> for Device {
|
||||
fn from(h: crate::host::$host_mod::Device) -> Self {
|
||||
DeviceInner::$HostVariant(h).into()
|
||||
}
|
||||
}
|
||||
|
||||
$(#[cfg($feat)])?
|
||||
impl From<crate::host::$host_mod::Devices> for Devices {
|
||||
fn from(h: crate::host::$host_mod::Devices) -> Self {
|
||||
DevicesInner::$HostVariant(h).into()
|
||||
}
|
||||
}
|
||||
|
||||
$(#[cfg($feat)])?
|
||||
impl From<crate::host::$host_mod::Host> for Host {
|
||||
fn from(h: crate::host::$host_mod::Host) -> Self {
|
||||
HostInner::$HostVariant(h).into()
|
||||
}
|
||||
}
|
||||
|
||||
$(#[cfg($feat)])?
|
||||
impl From<crate::host::$host_mod::Stream> for Stream {
|
||||
fn from(h: crate::host::$host_mod::Stream) -> Self {
|
||||
StreamInner::$HostVariant(h).into()
|
||||
}
|
||||
}
|
||||
)*
|
||||
|
||||
/// Produces a list of hosts that are currently available on the system.
|
||||
pub fn available_hosts() -> Vec<HostId> {
|
||||
let mut host_ids = vec![];
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
if <crate::host::$host_mod::Host as crate::traits::HostTrait>::is_available() {
|
||||
host_ids.push(HostId::$HostVariant);
|
||||
}
|
||||
)*
|
||||
host_ids
|
||||
}
|
||||
|
||||
/// Given a unique host identifier, initialise and produce the host if it is available.
|
||||
pub fn host_from_id(id: HostId) -> Result<Host, crate::HostUnavailable> {
|
||||
match id {
|
||||
$(
|
||||
$(#[cfg($feat)])?
|
||||
HostId::$HostVariant => {
|
||||
crate::host::$host_mod::Host::new()
|
||||
.map(HostInner::$HostVariant)
|
||||
.map(Host::from)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Add pulseaudio and jack here eventually.
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
mod platform_impl {
|
||||
pub use crate::host::alsa::{
|
||||
Device as AlsaDevice, Devices as AlsaDevices, Host as AlsaHost, Stream as AlsaStream,
|
||||
SupportedInputConfigs as AlsaSupportedInputConfigs,
|
||||
SupportedOutputConfigs as AlsaSupportedOutputConfigs,
|
||||
};
|
||||
#[cfg(feature = "jack")]
|
||||
pub use crate::host::jack::{
|
||||
Device as JackDevice, Devices as JackDevices, Host as JackHost, Stream as JackStream,
|
||||
SupportedInputConfigs as JackSupportedInputConfigs,
|
||||
SupportedOutputConfigs as JackSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(#[cfg(feature = "jack")] Jack jack "JACK", Alsa alsa "ALSA");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
AlsaHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
mod platform_impl {
|
||||
pub use crate::host::coreaudio::{
|
||||
Device as CoreAudioDevice, Devices as CoreAudioDevices, Host as CoreAudioHost,
|
||||
Stream as CoreAudioStream, SupportedInputConfigs as CoreAudioSupportedInputConfigs,
|
||||
SupportedOutputConfigs as CoreAudioSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(CoreAudio coreaudio "CoreAudio");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
CoreAudioHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
mod platform_impl {
|
||||
pub use crate::host::emscripten::{
|
||||
Device as EmscriptenDevice, Devices as EmscriptenDevices, Host as EmscriptenHost,
|
||||
Stream as EmscriptenStream, SupportedInputConfigs as EmscriptenSupportedInputConfigs,
|
||||
SupportedOutputConfigs as EmscriptenSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(Emscripten emscripten "Emscripten");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
EmscriptenHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))]
|
||||
mod platform_impl {
|
||||
pub use crate::host::webaudio::{
|
||||
Device as WebAudioDevice, Devices as WebAudioDevices, Host as WebAudioHost,
|
||||
Stream as WebAudioStream, SupportedInputConfigs as WebAudioSupportedInputConfigs,
|
||||
SupportedOutputConfigs as WebAudioSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(WebAudio webaudio "WebAudio");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
WebAudioHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod platform_impl {
|
||||
#[cfg(feature = "asio")]
|
||||
pub use crate::host::asio::{
|
||||
Device as AsioDevice, Devices as AsioDevices, Host as AsioHost, Stream as AsioStream,
|
||||
SupportedInputConfigs as AsioSupportedInputConfigs,
|
||||
SupportedOutputConfigs as AsioSupportedOutputConfigs,
|
||||
};
|
||||
pub use crate::host::wasapi::{
|
||||
Device as WasapiDevice, Devices as WasapiDevices, Host as WasapiHost,
|
||||
Stream as WasapiStream, SupportedInputConfigs as WasapiSupportedInputConfigs,
|
||||
SupportedOutputConfigs as WasapiSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(#[cfg(feature = "asio")] Asio asio "ASIO", Wasapi wasapi "WASAPI");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
WasapiHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod platform_impl {
|
||||
pub use crate::host::oboe::{
|
||||
Device as OboeDevice, Devices as OboeDevices, Host as OboeHost, Stream as OboeStream,
|
||||
SupportedInputConfigs as OboeSupportedInputConfigs,
|
||||
SupportedOutputConfigs as OboeSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(Oboe oboe "Oboe");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
OboeHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
windows,
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "emscripten",
|
||||
target_os = "android",
|
||||
all(target_arch = "wasm32", feature = "wasm-bindgen"),
|
||||
)))]
|
||||
mod platform_impl {
|
||||
pub use crate::host::null::{
|
||||
Device as NullDevice, Devices as NullDevices, Host as NullHost,
|
||||
SupportedInputConfigs as NullSupportedInputConfigs,
|
||||
SupportedOutputConfigs as NullSupportedOutputConfigs,
|
||||
};
|
||||
|
||||
impl_platform_host!(Null null "Null");
|
||||
|
||||
/// The default host for the current compilation target platform.
|
||||
pub fn default_host() -> Host {
|
||||
NullHost::new()
|
||||
.expect("the default host should always be available")
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
// The following zero-sized types are for applying Send/Sync restrictions to ensure
|
||||
// consistent behaviour across different platforms. These verbosely named types are used
|
||||
// (rather than using the markers directly) in the hope of making the compile errors
|
||||
// slightly more helpful.
|
||||
//
|
||||
// TODO: Remove these in favour of using negative trait bounds if they stabilise.
|
||||
|
||||
// A marker used to remove the `Send` and `Sync` traits.
|
||||
struct NotSendSyncAcrossAllPlatforms(std::marker::PhantomData<*mut ()>);
|
||||
|
||||
impl Default for NotSendSyncAcrossAllPlatforms {
|
||||
fn default() -> Self {
|
||||
NotSendSyncAcrossAllPlatforms(std::marker::PhantomData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::{fmt::Display, mem};
|
||||
#[cfg(target_os = "emscripten")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub use dasp_sample::{FromSample, Sample, I24, I48, U24, U48};
|
||||
|
||||
/// Format that each sample has.
|
||||
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum SampleFormat {
|
||||
/// `i8` with a valid range of 'u8::MIN..=u8::MAX' with `0` being the origin
|
||||
I8,
|
||||
|
||||
/// `i16` with a valid range of 'u16::MIN..=u16::MAX' with `0` being the origin
|
||||
I16,
|
||||
|
||||
// /// `I24` with a valid range of '-(1 << 23)..(1 << 23)' with `0` being the origin
|
||||
// I24,
|
||||
/// `i32` with a valid range of 'u32::MIN..=u32::MAX' with `0` being the origin
|
||||
I32,
|
||||
|
||||
// /// `I24` with a valid range of '-(1 << 47)..(1 << 47)' with `0` being the origin
|
||||
// I48,
|
||||
/// `i64` with a valid range of 'u64::MIN..=u64::MAX' with `0` being the origin
|
||||
I64,
|
||||
|
||||
/// `u8` with a valid range of 'u8::MIN..=u8::MAX' with `1 << 7 == 128` being the origin
|
||||
U8,
|
||||
|
||||
/// `u16` with a valid range of 'u16::MIN..=u16::MAX' with `1 << 15 == 32768` being the origin
|
||||
U16,
|
||||
|
||||
// /// `U24` with a valid range of '0..16777216' with `1 << 23 == 8388608` being the origin
|
||||
// U24,
|
||||
/// `u32` with a valid range of 'u32::MIN..=u32::MAX' with `1 << 31` being the origin
|
||||
U32,
|
||||
|
||||
// /// `U48` with a valid range of '0..(1 << 48)' with `1 << 47` being the origin
|
||||
// U48,
|
||||
/// `u64` with a valid range of 'u64::MIN..=u64::MAX' with `1 << 63` being the origin
|
||||
U64,
|
||||
|
||||
/// `f32` with a valid range of `-1.0..1.0` with `0.0` being the origin
|
||||
F32,
|
||||
|
||||
/// `f64` with a valid range of -1.0..1.0 with 0.0 being the origin
|
||||
F64,
|
||||
}
|
||||
|
||||
impl SampleFormat {
|
||||
/// Returns the size in bytes of a sample of this format.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn sample_size(&self) -> usize {
|
||||
match *self {
|
||||
SampleFormat::I8 | SampleFormat::U8 => mem::size_of::<i8>(),
|
||||
SampleFormat::I16 | SampleFormat::U16 => mem::size_of::<i16>(),
|
||||
// SampleFormat::I24 | SampleFormat::U24 => 3,
|
||||
SampleFormat::I32 | SampleFormat::U32 => mem::size_of::<i32>(),
|
||||
// SampleFormat::I48 | SampleFormat::U48 => 6,
|
||||
SampleFormat::I64 | SampleFormat::U64 => mem::size_of::<i64>(),
|
||||
SampleFormat::F32 => mem::size_of::<f32>(),
|
||||
SampleFormat::F64 => mem::size_of::<f64>(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn is_int(&self) -> bool {
|
||||
//matches!(*self, SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I24 | SampleFormat::I32 | SampleFormat::I48 | SampleFormat::I64)
|
||||
matches!(
|
||||
*self,
|
||||
SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I32 | SampleFormat::I64
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn is_uint(&self) -> bool {
|
||||
//matches!(*self, SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U24 | SampleFormat::U32 | SampleFormat::U48 | SampleFormat::U64)
|
||||
matches!(
|
||||
*self,
|
||||
SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U32 | SampleFormat::U64
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn is_float(&self) -> bool {
|
||||
matches!(*self, SampleFormat::F32 | SampleFormat::F64)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SampleFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
SampleFormat::I8 => "i8",
|
||||
SampleFormat::I16 => "i16",
|
||||
// SampleFormat::I24 => "i24",
|
||||
SampleFormat::I32 => "i32",
|
||||
// SampleFormat::I48 => "i48",
|
||||
SampleFormat::I64 => "i64",
|
||||
SampleFormat::U8 => "u8",
|
||||
SampleFormat::U16 => "u16",
|
||||
// SampleFormat::U24 => "u24",
|
||||
SampleFormat::U32 => "u32",
|
||||
// SampleFormat::U48 => "u48",
|
||||
SampleFormat::U64 => "u64",
|
||||
SampleFormat::F32 => "f32",
|
||||
SampleFormat::F64 => "f64",
|
||||
}
|
||||
.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SizedSample: Sample {
|
||||
const FORMAT: SampleFormat;
|
||||
}
|
||||
|
||||
impl SizedSample for i8 {
|
||||
const FORMAT: SampleFormat = SampleFormat::I8;
|
||||
}
|
||||
|
||||
impl SizedSample for i16 {
|
||||
const FORMAT: SampleFormat = SampleFormat::I16;
|
||||
}
|
||||
|
||||
// impl SizedSample for I24 { const FORMAT: SampleFormat = SampleFormat::I24; }
|
||||
|
||||
impl SizedSample for i32 {
|
||||
const FORMAT: SampleFormat = SampleFormat::I32;
|
||||
}
|
||||
|
||||
// impl SizedSample for I48 { const FORMAT: SampleFormat = SampleFormat::I48; }
|
||||
|
||||
impl SizedSample for i64 {
|
||||
const FORMAT: SampleFormat = SampleFormat::I64;
|
||||
}
|
||||
|
||||
impl SizedSample for u8 {
|
||||
const FORMAT: SampleFormat = SampleFormat::U8;
|
||||
}
|
||||
|
||||
impl SizedSample for u16 {
|
||||
const FORMAT: SampleFormat = SampleFormat::U16;
|
||||
}
|
||||
|
||||
// impl SizedSample for U24 { const FORMAT: SampleFormat = SampleFormat::U24; }
|
||||
|
||||
impl SizedSample for u32 {
|
||||
const FORMAT: SampleFormat = SampleFormat::U32;
|
||||
}
|
||||
|
||||
// impl SizedSample for U48 { const FORMAT: SampleFormat = SampleFormat::U48; }
|
||||
|
||||
impl SizedSample for u64 {
|
||||
const FORMAT: SampleFormat = SampleFormat::U64;
|
||||
}
|
||||
|
||||
impl SizedSample for f32 {
|
||||
const FORMAT: SampleFormat = SampleFormat::F32;
|
||||
}
|
||||
|
||||
impl SizedSample for f64 {
|
||||
const FORMAT: SampleFormat = SampleFormat::F64;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! The suite of traits allowing CPAL to abstract over hosts, devices, event loops and stream IDs.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
|
||||
InputCallbackInfo, InputDevices, OutputCallbackInfo, OutputDevices, PauseStreamError,
|
||||
PlayStreamError, SampleFormat, SizedSample, StreamConfig, StreamError, SupportedStreamConfig,
|
||||
SupportedStreamConfigRange, SupportedStreamConfigsError,
|
||||
};
|
||||
|
||||
/// A [`Host`] provides access to the available audio devices on the system.
|
||||
///
|
||||
/// Each platform may have a number of available hosts depending on the system, each with their own
|
||||
/// pros and cons.
|
||||
///
|
||||
/// For example, WASAPI is the standard audio host API that ships with the Windows operating
|
||||
/// system. However, due to historical limitations with respect to performance and flexibility,
|
||||
/// Steinberg created the ASIO API providing better audio device support for pro audio and
|
||||
/// low-latency applications. As a result, it is common for some devices and device capabilities to
|
||||
/// only be available via ASIO, while others are only available via WASAPI.
|
||||
///
|
||||
/// Another great example is the Linux platform. While the ALSA host API is the lowest-level API
|
||||
/// available to almost all distributions of Linux, its flexibility is limited as it requires that
|
||||
/// each process have exclusive access to the devices with which they establish streams. PulseAudio
|
||||
/// is another popular host API that aims to solve this issue by providing user-space mixing,
|
||||
/// however it has its own limitations w.r.t. low-latency and high-performance audio applications.
|
||||
/// JACK is yet another host API that is more suitable to pro-audio applications, however it is
|
||||
/// less readily available by default in many Linux distributions and is known to be tricky to
|
||||
/// set up.
|
||||
///
|
||||
/// [`Host`]: crate::Host
|
||||
pub trait HostTrait {
|
||||
/// The type used for enumerating available devices by the host.
|
||||
type Devices: Iterator<Item = Self::Device>;
|
||||
/// The `Device` type yielded by the host.
|
||||
type Device: DeviceTrait;
|
||||
|
||||
/// Whether or not the host is available on the system.
|
||||
fn is_available() -> bool;
|
||||
|
||||
/// An iterator yielding all [`Device`](DeviceTrait)s currently available to the host on the system.
|
||||
///
|
||||
/// Can be empty if the system does not support audio in general.
|
||||
fn devices(&self) -> Result<Self::Devices, DevicesError>;
|
||||
|
||||
/// The default input audio device on the system.
|
||||
///
|
||||
/// Returns `None` if no input device is available.
|
||||
fn default_input_device(&self) -> Option<Self::Device>;
|
||||
|
||||
/// The default output audio device on the system.
|
||||
///
|
||||
/// Returns `None` if no output device is available.
|
||||
fn default_output_device(&self) -> Option<Self::Device>;
|
||||
|
||||
/// An iterator yielding all `Device`s currently available to the system that support one or more
|
||||
/// input stream formats.
|
||||
///
|
||||
/// Can be empty if the system does not support audio input.
|
||||
fn input_devices(&self) -> Result<InputDevices<Self::Devices>, DevicesError> {
|
||||
fn supports_input<D: DeviceTrait>(device: &D) -> bool {
|
||||
device
|
||||
.supported_input_configs()
|
||||
.map(|mut iter| iter.next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Ok(self.devices()?.filter(supports_input::<Self::Device>))
|
||||
}
|
||||
|
||||
/// An iterator yielding all `Device`s currently available to the system that support one or more
|
||||
/// output stream formats.
|
||||
///
|
||||
/// Can be empty if the system does not support audio output.
|
||||
fn output_devices(&self) -> Result<OutputDevices<Self::Devices>, DevicesError> {
|
||||
fn supports_output<D: DeviceTrait>(device: &D) -> bool {
|
||||
device
|
||||
.supported_output_configs()
|
||||
.map(|mut iter| iter.next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Ok(self.devices()?.filter(supports_output::<Self::Device>))
|
||||
}
|
||||
}
|
||||
|
||||
/// A device that is capable of audio input and/or output.
|
||||
///
|
||||
/// Please note that `Device`s may become invalid if they get disconnected. Therefore, all the
|
||||
/// methods that involve a device return a `Result` allowing the user to handle this case.
|
||||
pub trait DeviceTrait {
|
||||
/// The iterator type yielding supported input stream formats.
|
||||
type SupportedInputConfigs: Iterator<Item = SupportedStreamConfigRange>;
|
||||
/// The iterator type yielding supported output stream formats.
|
||||
type SupportedOutputConfigs: Iterator<Item = SupportedStreamConfigRange>;
|
||||
/// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`].
|
||||
///
|
||||
/// [`build_input_stream_raw`]: Self::build_input_stream_raw
|
||||
/// [`build_output_stream_raw`]: Self::build_output_stream_raw
|
||||
type Stream: StreamTrait;
|
||||
|
||||
/// The human-readable name of the device.
|
||||
fn name(&self) -> Result<String, DeviceNameError>;
|
||||
|
||||
/// An iterator yielding formats that are supported by the backend.
|
||||
///
|
||||
/// Can return an error if the device is no longer valid (e.g. it has been disconnected).
|
||||
fn supported_input_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError>;
|
||||
|
||||
/// An iterator yielding output stream formats that are supported by the device.
|
||||
///
|
||||
/// Can return an error if the device is no longer valid (e.g. it has been disconnected).
|
||||
fn supported_output_configs(
|
||||
&self,
|
||||
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError>;
|
||||
|
||||
/// The default input stream format for the device.
|
||||
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError>;
|
||||
|
||||
/// The default output stream format for the device.
|
||||
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError>;
|
||||
|
||||
/// Create an input stream.
|
||||
fn build_input_stream<T, D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
mut data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
T: SizedSample,
|
||||
D: FnMut(&[T], &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
self.build_input_stream_raw(
|
||||
config,
|
||||
T::FORMAT,
|
||||
move |data, info| {
|
||||
data_callback(
|
||||
data.as_slice()
|
||||
.expect("host supplied incorrect sample type"),
|
||||
info,
|
||||
)
|
||||
},
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an output stream.
|
||||
fn build_output_stream<T, D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
mut data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
T: SizedSample,
|
||||
D: FnMut(&mut [T], &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static,
|
||||
{
|
||||
self.build_output_stream_raw(
|
||||
config,
|
||||
T::FORMAT,
|
||||
move |data, info| {
|
||||
data_callback(
|
||||
data.as_slice_mut()
|
||||
.expect("host supplied incorrect sample type"),
|
||||
info,
|
||||
)
|
||||
},
|
||||
error_callback,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a dynamically typed input stream.
|
||||
fn build_input_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static;
|
||||
|
||||
/// Create a dynamically typed output stream.
|
||||
fn build_output_stream_raw<D, E>(
|
||||
&self,
|
||||
config: &StreamConfig,
|
||||
sample_format: SampleFormat,
|
||||
data_callback: D,
|
||||
error_callback: E,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self::Stream, BuildStreamError>
|
||||
where
|
||||
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
|
||||
E: FnMut(StreamError) + Send + 'static;
|
||||
}
|
||||
|
||||
/// A stream created from [`Device`](DeviceTrait), with methods to control playback.
|
||||
pub trait StreamTrait {
|
||||
/// Run the stream.
|
||||
///
|
||||
/// Note: Not all platforms automatically run the stream upon creation, so it is important to
|
||||
/// call `play` after creation if it is expected that the stream should run immediately.
|
||||
fn play(&self) -> Result<(), PlayStreamError>;
|
||||
|
||||
/// Some devices support pausing the audio stream. This can be useful for saving energy in
|
||||
/// moments of silence.
|
||||
///
|
||||
/// Note: Not all devices support suspending the stream at the hardware level. This method may
|
||||
/// fail in these cases.
|
||||
fn pause(&self) -> Result<(), PauseStreamError>;
|
||||
}
|
||||
+1334
-81
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+48
-37
@@ -216,6 +216,16 @@ fn try_connect() -> Option<DiscordIpcClient> {
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Apply a template string, replacing placeholders with actual values.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String {
|
||||
let album_text = album.unwrap_or("");
|
||||
template
|
||||
.replace("{title}", title)
|
||||
.replace("{artist}", artist)
|
||||
.replace("{album}", album_text)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
|
||||
@@ -225,6 +235,12 @@ fn try_connect() -> Option<DiscordIpcClient> {
|
||||
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
|
||||
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
|
||||
/// without making any external request — required for privacy opt-in.
|
||||
/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `state_template`: template string for the "state" field. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
#[tauri::command]
|
||||
pub async fn discord_update_presence(
|
||||
state: tauri::State<'_, DiscordState>,
|
||||
@@ -235,6 +251,9 @@ pub async fn discord_update_presence(
|
||||
elapsed_secs: Option<f64>,
|
||||
cover_art_url: Option<String>,
|
||||
fetch_itunes_covers: bool,
|
||||
details_template: Option<String>,
|
||||
state_template: Option<String>,
|
||||
large_text_template: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
|
||||
// run on the Tokio async executor directly.
|
||||
@@ -273,62 +292,54 @@ pub async fn discord_update_presence(
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
// Discord RPC only exposes two visible text rows (details + state).
|
||||
// The application name "Psysonic" is shown automatically by Discord as the
|
||||
// header line. Album goes into large_text — visible as a hover tooltip on
|
||||
// the cover art icon.
|
||||
let large_text = album.as_deref().unwrap_or("Psysonic");
|
||||
// Apply templates for the three configurable text fields.
|
||||
let details_str = details_template.as_deref().unwrap_or("{artist} - {title}");
|
||||
let details_text = apply_template(details_str, &title, &artist, album.as_deref());
|
||||
|
||||
let state_str = state_template.as_deref().unwrap_or("{album}");
|
||||
let state_text = apply_template(state_str, &title, &artist, album.as_deref());
|
||||
|
||||
let large_text_str = large_text_template.as_deref().unwrap_or("{album}");
|
||||
let large_text = apply_template(large_text_str, &title, &artist, album.as_deref());
|
||||
|
||||
let assets = if let Some(ref url) = artwork_url {
|
||||
Assets::new()
|
||||
.large_image(url.as_str())
|
||||
.large_text(large_text)
|
||||
.large_text(&large_text)
|
||||
} else {
|
||||
// Fallback to default Psysonic icon
|
||||
Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text)
|
||||
.large_text(&large_text)
|
||||
};
|
||||
|
||||
// When paused, show "Paused" as the state text (replaces artist name).
|
||||
let state_text: String = if is_playing {
|
||||
artist.clone()
|
||||
} else {
|
||||
"Paused".to_string()
|
||||
};
|
||||
// When paused: clear activity completely to avoid any timer issues
|
||||
// When playing: show full activity with timer
|
||||
if !is_playing {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ActivityType::Listening causes the Discord client to auto-start a running
|
||||
// timer from "now" even when no timestamps are provided. Switch to Playing
|
||||
// when paused — Playing only shows a timer when timestamps are explicitly set.
|
||||
let activity_type = if is_playing {
|
||||
ActivityType::Listening
|
||||
} else {
|
||||
ActivityType::Playing
|
||||
};
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.activity_type(activity_type)
|
||||
.details(&title)
|
||||
// Only reach here when playing
|
||||
let activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.details(&details_text)
|
||||
.state(&state_text)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
// Only set when playing — Playing type without timestamps shows no timer.
|
||||
if is_playing {
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
.assets(assets)
|
||||
.timestamps(if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
}
|
||||
Timestamps::new().start(start)
|
||||
} else {
|
||||
Timestamps::new()
|
||||
});
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
|
||||
+2235
-18
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,90 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum GpuVendor {
|
||||
Nvidia,
|
||||
Intel,
|
||||
Amd,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_gpu_vendor() -> Option<GpuVendor> {
|
||||
use std::fs;
|
||||
|
||||
if fs::metadata("/proc/driver/nvidia/version").is_ok() {
|
||||
return Some(GpuVendor::Nvidia);
|
||||
}
|
||||
|
||||
// Iterate every `/sys/class/drm/card*` — hybrid laptops expose multiple
|
||||
// cards, and some systems have no `card0` at all.
|
||||
let entries = fs::read_dir("/sys/class/drm").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if !name.starts_with("card") || name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let Ok(vendor_id) = fs::read_to_string(entry.path().join("device/vendor")) else {
|
||||
continue;
|
||||
};
|
||||
match vendor_id.trim() {
|
||||
"0x10de" => return Some(GpuVendor::Nvidia),
|
||||
"0x8086" => return Some(GpuVendor::Intel),
|
||||
"0x1002" => return Some(GpuVendor::Amd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
|
||||
// Users can still override by setting these vars before launch.
|
||||
//
|
||||
// Safety: set_var modifies global process state. These calls are safe here
|
||||
// because we're in main() before the Tauri runtime starts — no other threads
|
||||
// exist yet. If this code moves to lazy init or a plugin context, it would
|
||||
// need synchronization or marking as unsafe (Rust 2024+).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("GDK_BACKEND").is_err() {
|
||||
std::env::set_var("GDK_BACKEND", "x11");
|
||||
}
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
|
||||
// NVIDIA proprietary adds a small but reproducible overhead on the
|
||||
// DMA-BUF renderer path (blind A/B confirmed on NVIDIA + proprietary).
|
||||
// Unknown GPUs keep the WebKitGTK default — VMs, ARM SBCs and anything
|
||||
// exotic should not be regressed by a guess.
|
||||
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
|
||||
&& matches!(detect_gpu_vendor(), Some(GpuVendor::Nvidia))
|
||||
{
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if psysonic_lib::cli::wants_version(&args) {
|
||||
psysonic_lib::cli::print_version();
|
||||
return;
|
||||
}
|
||||
if psysonic_lib::cli::wants_help(&args) {
|
||||
psysonic_lib::cli::print_help(
|
||||
args.first().map(|s| s.as_str()).unwrap_or("psysonic"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(code) = psysonic_lib::cli::try_completions_dispatch(&args) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
if psysonic_lib::cli::wants_info(&args) {
|
||||
psysonic_lib::cli::run_info_and_exit(&args);
|
||||
}
|
||||
|
||||
psysonic_lib::run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Windows Taskbar Thumbnail Toolbar (ITaskbarList3::ThumbBarAddButtons).
|
||||
//!
|
||||
//! Adds Prev / Play-Pause / Next buttons to the taskbar thumbnail preview.
|
||||
//! Button clicks are intercepted via SetWindowSubclass and routed to the same
|
||||
//! `media:prev`, `media:play-pause`, `media:next` events as souvlaki / tray.
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
DefSubclassProc, ITaskbarList3, RemoveWindowSubclass, SetWindowSubclass,
|
||||
TaskbarList, THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBN_CLICKED,
|
||||
THB_FLAGS, THB_ICON, THB_TOOLTIP,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||
WM_COMMAND, WM_NCDESTROY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Embedded ICO assets ──────────────────────────────────────────────────────
|
||||
|
||||
static PREV_ICO: &[u8] = include_bytes!("../icons/windows/prev.ico");
|
||||
static PLAY_ICO: &[u8] = include_bytes!("../icons/windows/play.ico");
|
||||
static PAUSE_ICO: &[u8] = include_bytes!("../icons/windows/pause.ico");
|
||||
static NEXT_ICO: &[u8] = include_bytes!("../icons/windows/next.ico");
|
||||
|
||||
// Button IDs — arbitrary u32 values, must fit in WPARAM low-word.
|
||||
const BTN_PREV: u32 = 0xE001;
|
||||
const BTN_PLAY: u32 = 0xE002;
|
||||
const BTN_NEXT: u32 = 0xE003;
|
||||
|
||||
// Unique subclass ID.
|
||||
const SUBCLASS_ID: usize = 0xC0DE_7A8B;
|
||||
|
||||
// Raw pointers kept as atomics so `update_taskbar_icon` can reach the
|
||||
// COM object and icons without managed state.
|
||||
static TASKBAR_PTR: AtomicIsize = AtomicIsize::new(0);
|
||||
static HWND_VAL: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// All four HICONs stored for WM_NCDESTROY cleanup and play/pause swapping.
|
||||
static HICON_PREV: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// ── ICO resource loader ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
||||
///
|
||||
/// Parses the ICO directory to pick the entry with the highest bit depth
|
||||
/// (32 bpp = true-colour + alpha), then passes the image bits directly to
|
||||
/// `CreateIconFromResourceEx`.
|
||||
///
|
||||
/// Note: `LookupIconIdFromDirectoryEx` operates on Win32 *resource* group-icon
|
||||
/// format (GRPICONDIR), not raw `.ico` files, so we parse the ICO header ourselves.
|
||||
unsafe fn load_icon_from_memory(bytes: &[u8]) -> HICON {
|
||||
// ICO file layout:
|
||||
// ICONDIR : reserved(2) + type(2) + count(2)
|
||||
// ICONDIRENTRY[] : width(1) height(1) color_count(1) reserved(1)
|
||||
// planes(2) bit_count(2) bytes_in_res(4) image_offset(4)
|
||||
if bytes.len() < 6 {
|
||||
return HICON::default();
|
||||
}
|
||||
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
|
||||
if count == 0 || bytes.len() < 6 + count * 16 {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
// Pick the entry with the highest bit depth; 32 bpp carries alpha.
|
||||
let mut best_idx = 0usize;
|
||||
let mut best_bpp = 0u16;
|
||||
for i in 0..count {
|
||||
let base = 6 + i * 16;
|
||||
let bpp = u16::from_le_bytes([bytes[base + 6], bytes[base + 7]]);
|
||||
if bpp >= best_bpp {
|
||||
best_bpp = bpp;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = &bytes[6 + best_idx * 16..];
|
||||
let img_size = u32::from_le_bytes(entry[8..12].try_into().unwrap_or([0; 4]));
|
||||
let img_offset = u32::from_le_bytes(entry[12..16].try_into().unwrap_or([0; 4])) as usize;
|
||||
|
||||
if img_size == 0 || img_offset + img_size as usize > bytes.len() {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
CreateIconFromResourceEx(
|
||||
&bytes[img_offset..img_offset + img_size as usize],
|
||||
true, // fIcon = TRUE
|
||||
0x0003_0000, // dwVer = 3.0 (required by the API)
|
||||
0, 0, // cxDesired / cyDesired — 0 lets the system choose
|
||||
LR_DEFAULTCOLOR,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Button descriptors ───────────────────────────────────────────────────────
|
||||
|
||||
fn copy_tip(dest: &mut [u16], src: &str) {
|
||||
let wide: Vec<u16> = src.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let len = wide.len().min(dest.len());
|
||||
dest[..len].copy_from_slice(&wide[..len]);
|
||||
}
|
||||
|
||||
unsafe fn make_buttons(
|
||||
h_prev: HICON,
|
||||
h_play: HICON,
|
||||
h_next: HICON,
|
||||
) -> [THUMBBUTTON; 3] {
|
||||
let mask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
let flags = THUMBBUTTONFLAGS(0); // THBF_ENABLED
|
||||
|
||||
let mut prev = THUMBBUTTON::default();
|
||||
prev.dwMask = mask; prev.iId = BTN_PREV;
|
||||
prev.hIcon = h_prev; prev.dwFlags = flags;
|
||||
copy_tip(&mut prev.szTip, "Previous");
|
||||
|
||||
let mut play = THUMBBUTTON::default();
|
||||
play.dwMask = mask; play.iId = BTN_PLAY;
|
||||
play.hIcon = h_play; play.dwFlags = flags;
|
||||
copy_tip(&mut play.szTip, "Play");
|
||||
|
||||
let mut next = THUMBBUTTON::default();
|
||||
next.dwMask = mask; next.iId = BTN_NEXT;
|
||||
next.hIcon = h_next; next.dwFlags = flags;
|
||||
copy_tip(&mut next.szTip, "Next");
|
||||
|
||||
[prev, play, next]
|
||||
}
|
||||
|
||||
// ── WndProc subclass ─────────────────────────────────────────────────────────
|
||||
|
||||
struct SubclassData {
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_uid: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
if msg == WM_COMMAND {
|
||||
let hi = (wparam.0 >> 16) as u32;
|
||||
let lo = (wparam.0 & 0xFFFF) as u32;
|
||||
if hi == THBN_CLICKED as u32 {
|
||||
if data != 0 {
|
||||
let state = &*(data as *const SubclassData);
|
||||
let _ = match lo {
|
||||
x if x == BTN_PREV => state.app.emit("media:prev", ()),
|
||||
x if x == BTN_PLAY => state.app.emit("media:play-pause", ()),
|
||||
x if x == BTN_NEXT => state.app.emit("media:next", ()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
return LRESULT(0);
|
||||
}
|
||||
}
|
||||
|
||||
if msg == WM_NCDESTROY {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
|
||||
if data != 0 {
|
||||
drop(Box::from_raw(data as *mut SubclassData));
|
||||
}
|
||||
let raw = TASKBAR_PTR.swap(0, Ordering::SeqCst);
|
||||
if raw != 0 {
|
||||
drop(Box::from_raw(raw as *mut ITaskbarList3));
|
||||
}
|
||||
HWND_VAL.store(0, Ordering::SeqCst);
|
||||
// Destroy all stored HICONs.
|
||||
for cell in [&HICON_PREV, &HICON_PLAY, &HICON_PAUSE, &HICON_NEXT] {
|
||||
let h = cell.swap(0, Ordering::SeqCst);
|
||||
if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); }
|
||||
}
|
||||
}
|
||||
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
// ── Public init ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let taskbar: ITaskbarList3 = match CoCreateInstance(
|
||||
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
||||
};
|
||||
|
||||
if let Err(e) = taskbar.HrInit() {
|
||||
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let h_prev = load_icon_from_memory(PREV_ICO);
|
||||
let h_play = load_icon_from_memory(PLAY_ICO);
|
||||
let h_pause = load_icon_from_memory(PAUSE_ICO);
|
||||
let h_next = load_icon_from_memory(NEXT_ICO);
|
||||
|
||||
// Store all HICONs for cleanup and play/pause swapping.
|
||||
HICON_PREV .store(h_prev .0 as isize, Ordering::SeqCst);
|
||||
HICON_PLAY .store(h_play .0 as isize, Ordering::SeqCst);
|
||||
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
||||
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
||||
|
||||
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
||||
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = Box::into_raw(Box::new(taskbar));
|
||||
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
||||
HWND_VAL .store(hwnd_raw, Ordering::SeqCst);
|
||||
|
||||
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
||||
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
||||
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
||||
drop(Box::from_raw(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri command ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_taskbar_icon(is_playing: bool) {
|
||||
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
||||
let hwnd_raw = HWND_VAL .load(Ordering::SeqCst);
|
||||
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
||||
|
||||
let icon_raw = if is_playing {
|
||||
HICON_PAUSE.load(Ordering::SeqCst)
|
||||
} else {
|
||||
HICON_PLAY.load(Ordering::SeqCst)
|
||||
};
|
||||
if icon_raw == 0 { return; }
|
||||
|
||||
unsafe {
|
||||
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let mut btn = THUMBBUTTON::default();
|
||||
btn.dwMask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
btn.iId = BTN_PLAY;
|
||||
btn.hIcon = HICON(icon_raw as *mut _);
|
||||
btn.dwFlags = THUMBBUTTONFLAGS(0);
|
||||
copy_tip(&mut btn.szTip, if is_playing { "Pause" } else { "Play" });
|
||||
|
||||
let mut btns = [btn];
|
||||
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.43.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -31,6 +31,17 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUVBhYzkK",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
+516
-36
@@ -21,6 +21,7 @@ import ArtistDetail from './pages/ArtistDetail';
|
||||
import NewReleases from './pages/NewReleases';
|
||||
import Favorites from './pages/Favorites';
|
||||
import RandomMix from './pages/RandomMix';
|
||||
import RandomLanding from './pages/RandomLanding';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
@@ -35,7 +36,11 @@ import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import WhatsNew from './pages/WhatsNew';
|
||||
import MiniPlayer from './components/MiniPlayer';
|
||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
@@ -44,32 +49,45 @@ import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import { IS_LINUX } from './utils/platform';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import {
|
||||
getMusicFolders,
|
||||
getSimilarSongs,
|
||||
getSong,
|
||||
probeEntityRatingSupport,
|
||||
search as subsonicSearch,
|
||||
setRating,
|
||||
star,
|
||||
unstar,
|
||||
} from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import i18n from './i18n';
|
||||
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||
import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore } from './store/keybindingsStore';
|
||||
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
|
||||
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
|
||||
let cliPremuteVolume: number | null = null;
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||
@@ -89,8 +107,24 @@ function AppShell() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('no_compositing_mode').then(noComp => {
|
||||
if (noComp) document.documentElement.classList.add('no-compositing');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown';
|
||||
document.documentElement.setAttribute('data-platform', platform);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const win = getCurrentWindow();
|
||||
// Check initial state (e.g. app launched maximised / already fullscreen).
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
let unlisten: (() => void) | undefined;
|
||||
// onResized fires on every size change, including fullscreen enter/exit on
|
||||
// all platforms. We re-query isFullscreen() rather than inferring from
|
||||
// the size so the flag is always accurate regardless of platform quirks.
|
||||
win.onResized(() => {
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
}).then(u => { unlisten = u; });
|
||||
@@ -100,6 +134,7 @@ function AppShell() {
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -111,9 +146,22 @@ function AppShell() {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
|
||||
// Mini player → main: route requests dispatched as `psy:navigate`
|
||||
// CustomEvents from the bridge land here so React Router can take over.
|
||||
useEffect(() => {
|
||||
const onPsyNavigate = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.to) navigate(detail.to);
|
||||
};
|
||||
window.addEventListener('psy:navigate', onPsyNavigate);
|
||||
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
||||
}, [navigate]);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
@@ -123,6 +171,11 @@ function AppShell() {
|
||||
invoke('set_window_decorations', { enabled }).catch(() => {});
|
||||
}, [useCustomTitlebar, isTilingWm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
|
||||
}, [linuxWebkitKineticScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
@@ -193,14 +246,9 @@ function AppShell() {
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
|
||||
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
|
||||
setChangelogModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
// Post-update changelog is now surfaced via a dismissible banner in the
|
||||
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
|
||||
// modal takeover on startup.
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
@@ -291,14 +339,29 @@ function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pause CSS animations when the window is minimized / hidden.
|
||||
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
|
||||
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
|
||||
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
|
||||
// components.css pauses all running animations while this flag is set.
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
|
||||
};
|
||||
document.addEventListener('visibilitychange', update);
|
||||
update();
|
||||
return () => document.removeEventListener('visibilitychange', update);
|
||||
}, []);
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
data-fullscreen={isWindowFullscreen || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
@@ -313,6 +376,7 @@ function AppShell() {
|
||||
/>
|
||||
)}
|
||||
<main className="main-content">
|
||||
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
@@ -328,27 +392,21 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
{connStatus === 'disconnected' && hasOfflineContent && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && !hasOfflineContent && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
isChecking={connRetrying}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random-albums" element={<RandomAlbums />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
@@ -356,6 +414,7 @@ function AppShell() {
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
@@ -364,8 +423,10 @@ function AppShell() {
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{!isMobile && (
|
||||
<div
|
||||
@@ -388,13 +449,13 @@ function AppShell() {
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Media key + tray event handler
|
||||
function TauriEventBridge() {
|
||||
const navigate = useNavigate();
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
@@ -408,6 +469,297 @@ function TauriEventBridge() {
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
|
||||
// The Rust device-watcher has already reopened the stream on the new device
|
||||
// and dropped the old Sink, so we just need to restart playback.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', () => {
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
// Paused: clear warm-pause flag so the next resume uses the cold path
|
||||
// (audio_play + seek) which creates a new Sink on the new device.
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Pinned output device was unplugged — Rust already fell back to system default.
|
||||
// Clear the stored device so the Settings dropdown resets to "System Default".
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-reset', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player audio-device set …` (forwarded on Linux via single-instance).
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<string>('cli:audio-device-set', async e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
const deviceName = raw.length > 0 ? raw : null;
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName });
|
||||
useAuthStore.getState().setAudioOutputDevice(deviceName);
|
||||
} catch {
|
||||
/* device open failed — do not persist (same as Settings) */
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player mix append|new` from the currently playing track.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<string>('cli:instant-mix', async e => {
|
||||
const mode = e.payload === 'append' ? 'append' : 'new';
|
||||
const state = usePlayerStore.getState();
|
||||
const song = state.currentTrack;
|
||||
if (!song) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
try {
|
||||
const similar = await getSimilarSongs(song.id, 50);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
||||
const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s));
|
||||
if (mode === 'append') {
|
||||
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
|
||||
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
|
||||
} else {
|
||||
// New queue from seed: collapse to [song] first, then radio tail (not append onto old queue).
|
||||
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
||||
const shuffled = shuffleArray(
|
||||
base.map(t => ({ ...t, radioAdded: true as const })),
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('CLI instant mix failed', err);
|
||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
|
||||
showToast(i18n.t('contextMenu.instantMixFailed'), 5000, 'error');
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// CLI: `--player library list` (Rust polls the JSON file) / `library set`.
|
||||
useEffect(() => {
|
||||
let u1: (() => void) | undefined;
|
||||
let u2: (() => void) | undefined;
|
||||
listen('cli:library-list', async () => {
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
const auth = useAuthStore.getState();
|
||||
const sid = auth.activeServerId;
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
await invoke('cli_publish_library_list', {
|
||||
payload: {
|
||||
folders: folders.map(f => ({ id: f.id, name: f.name })),
|
||||
selected,
|
||||
active_server_id: sid,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('CLI library list failed', e);
|
||||
await invoke('cli_publish_library_list', {
|
||||
payload: { folders: [], selected: 'all', active_server_id: null },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).then(u => { u1 = u; });
|
||||
listen<string>('cli:library-set', e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
if (raw === 'all') useAuthStore.getState().setMusicLibraryFilter('all');
|
||||
else if (raw.length > 0) useAuthStore.getState().setMusicLibraryFilter(raw);
|
||||
}).then(u => { u2 = u; });
|
||||
return () => {
|
||||
u1?.();
|
||||
u2?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload.
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
listen('cli:server-list', async () => {
|
||||
const auth = useAuthStore.getState();
|
||||
await invoke('cli_publish_server_list', {
|
||||
payload: {
|
||||
active_server_id: auth.activeServerId,
|
||||
servers: auth.servers.map(s => ({ id: s.id, name: s.name })),
|
||||
},
|
||||
});
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:server-set', async e => {
|
||||
const raw = typeof e.payload === 'string' ? e.payload : '';
|
||||
const id = raw.trim();
|
||||
if (!id) return;
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (!server) {
|
||||
showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
const ok = await switchActiveServer(server);
|
||||
if (!ok) {
|
||||
showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error');
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<{ scope: string; query: string }>('cli:search', async e => {
|
||||
const { scope, query } = e.payload;
|
||||
const base = { scope, query, ready: false };
|
||||
try {
|
||||
const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 });
|
||||
const payload =
|
||||
scope === 'track'
|
||||
? {
|
||||
...base,
|
||||
songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })),
|
||||
albums: [] as { id: string; name: string; artist: string }[],
|
||||
artists: [] as { id: string; name: string }[],
|
||||
ready: true,
|
||||
}
|
||||
: scope === 'album'
|
||||
? {
|
||||
...base,
|
||||
songs: [] as { id: string; title: string; artist: string }[],
|
||||
albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })),
|
||||
artists: [] as { id: string; name: string }[],
|
||||
ready: true,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
songs: [] as { id: string; title: string; artist: string }[],
|
||||
albums: [] as { id: string; name: string; artist: string }[],
|
||||
artists: r.artists.map(a => ({ id: a.id, name: a.name })),
|
||||
ready: true,
|
||||
};
|
||||
await invoke('cli_publish_search_results', { payload });
|
||||
} catch (err) {
|
||||
console.error('CLI search failed', err);
|
||||
await invoke('cli_publish_search_results', {
|
||||
payload: {
|
||||
...base,
|
||||
songs: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
ready: true,
|
||||
error: err instanceof Error ? err.message : 'search failed',
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:play-id', async e => {
|
||||
const id = typeof e.payload === 'string' ? e.payload.trim() : '';
|
||||
if (!id) return;
|
||||
try {
|
||||
await playByOpaqueId(id);
|
||||
} catch (err) {
|
||||
console.error('CLI play failed', err);
|
||||
const notFound = err instanceof Error && err.message === 'play_by_id_not_found';
|
||||
showToast(
|
||||
i18n.t('contextMenu.cliPlayIdNotFound', {
|
||||
defaultValue: notFound
|
||||
? 'No song, album, or artist matches this id.'
|
||||
: 'Could not start playback.',
|
||||
}),
|
||||
5000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:shuffle-queue', () => {
|
||||
usePlayerStore.getState().shuffleQueue();
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<string>('cli:set-repeat', e => {
|
||||
const m = typeof e.payload === 'string' ? e.payload : '';
|
||||
const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off';
|
||||
usePlayerStore.setState({ repeatMode: mode });
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:mute', () => {
|
||||
const { volume, setVolume } = usePlayerStore.getState();
|
||||
if (volume > 0) cliPremuteVolume = volume;
|
||||
setVolume(0);
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:unmute', () => {
|
||||
const restore = cliPremuteVolume ?? 0.8;
|
||||
cliPremuteVolume = null;
|
||||
usePlayerStore.getState().setVolume(restore);
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<boolean>('cli:star-current', async e => {
|
||||
const want = e.payload === true;
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
if (!track) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (want) {
|
||||
await star(track.id, 'song');
|
||||
usePlayerStore.getState().setStarredOverride(track.id, true);
|
||||
} else {
|
||||
await unstar(track.id, 'song');
|
||||
usePlayerStore.getState().setStarredOverride(track.id, false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('CLI star failed', err);
|
||||
showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error');
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<number>('cli:set-rating-current', async e => {
|
||||
const stars = e.payload;
|
||||
if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return;
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
if (!track) {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setRating(track.id, stars);
|
||||
usePlayerStore.getState().setUserRatingOverride(track.id, stars);
|
||||
} catch (err) {
|
||||
console.error('CLI set rating failed', err);
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen('cli:reload-player', async () => {
|
||||
const store = usePlayerStore.getState();
|
||||
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
|
||||
stop();
|
||||
resetAudioPause();
|
||||
await invoke('audio_stop').catch(() => {});
|
||||
if (currentTrack) {
|
||||
try {
|
||||
const fresh = await getSong(currentTrack.id);
|
||||
const t = fresh ? songToTrack(fresh) : currentTrack;
|
||||
playTrack(t, queue, true);
|
||||
} catch {
|
||||
playTrack(currentTrack, queue, true);
|
||||
}
|
||||
} else {
|
||||
await initializeFromServerQueue();
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
return () => {
|
||||
unsubs.forEach(u => u());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync tray-icon visibility with the user's stored setting.
|
||||
// Runs once on mount (initial sync) and again whenever the setting changes.
|
||||
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
||||
@@ -420,15 +772,18 @@ function TauriEventBridge() {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
// Global shortcuts use modifier combos — skip in-app bindings for those
|
||||
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const chord = buildInAppBinding(e);
|
||||
if (chord) {
|
||||
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
|
||||
if (registered.includes(chord)) return;
|
||||
}
|
||||
|
||||
const { bindings } = useKeybindingsStore.getState();
|
||||
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
|
||||
|
||||
const action = (Object.entries(bindings) as [string, string | null][])
|
||||
.find(([, code]) => code === e.code)?.[0];
|
||||
.find(([, b]) => matchInAppBinding(e, b))?.[0];
|
||||
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
@@ -441,21 +796,31 @@ function TauriEventBridge() {
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
|
||||
const dur = s.currentTrack?.duration ?? 0;
|
||||
if (!dur) break;
|
||||
seek(Math.min(1, (s.currentTime + 10) / dur));
|
||||
break;
|
||||
}
|
||||
case 'seek-backward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.max(0, s.currentTime - 10));
|
||||
const dur = s.currentTrack?.duration ?? 0;
|
||||
if (!dur) break;
|
||||
seek(Math.max(0, (s.currentTime - 10) / dur));
|
||||
break;
|
||||
}
|
||||
case 'toggle-queue': toggleQueue(); break;
|
||||
case 'open-folder-browser':
|
||||
navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } });
|
||||
break;
|
||||
case 'fullscreen-player': toggleFullscreen(); break;
|
||||
case 'native-fullscreen': {
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
case 'open-mini-player':
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -469,11 +834,14 @@ function TauriEventBridge() {
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }],
|
||||
['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:stop', () => usePlayerStore.getState().stop()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -504,6 +872,15 @@ function TauriEventBridge() {
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
{
|
||||
const u = await listen<number>('media:set-volume', e => {
|
||||
const p = e.payload;
|
||||
if (typeof p !== 'number' || Number.isNaN(p)) return;
|
||||
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
|
||||
});
|
||||
if (cancelled) { u(); return; }
|
||||
unlisten.push(u);
|
||||
}
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
@@ -522,6 +899,59 @@ function TauriEventBridge() {
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||
useEffect(() => {
|
||||
let tid: ReturnType<typeof setTimeout> | undefined;
|
||||
const publish = () => {
|
||||
const s = usePlayerStore.getState();
|
||||
const auth = useAuthStore.getState();
|
||||
const sid = auth.activeServerId;
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
const ct = s.currentTrack;
|
||||
const currentTrackUserRating =
|
||||
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
|
||||
const currentTrackStarred =
|
||||
ct != null
|
||||
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
||||
: null;
|
||||
const snapshot = {
|
||||
current_track: s.currentTrack,
|
||||
current_radio: s.currentRadio,
|
||||
queue: s.queue,
|
||||
queue_index: s.queueIndex,
|
||||
queue_length: s.queue.length,
|
||||
is_playing: s.isPlaying,
|
||||
current_time: s.currentTime,
|
||||
volume: s.volume,
|
||||
repeat_mode: s.repeatMode,
|
||||
current_track_user_rating: currentTrackUserRating,
|
||||
current_track_starred: currentTrackStarred,
|
||||
servers: auth.servers.map(({ id, name }) => ({ id, name })),
|
||||
music_library: {
|
||||
active_server_id: sid,
|
||||
selected,
|
||||
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
|
||||
},
|
||||
};
|
||||
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
|
||||
};
|
||||
publish();
|
||||
const schedule = () => {
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
tid = setTimeout(() => {
|
||||
tid = undefined;
|
||||
publish();
|
||||
}, 200);
|
||||
};
|
||||
const unsubP = usePlayerStore.subscribe(schedule);
|
||||
const unsubA = useAuthStore.subscribe(schedule);
|
||||
return () => {
|
||||
unsubP();
|
||||
unsubA();
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -529,9 +959,14 @@ export default function App() {
|
||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
// Mini Player window: detected via Tauri window label. Rendered without
|
||||
// router / sidebar / full audio listeners — it just listens for state + sends
|
||||
// control events. Label is read synchronously from a global set in main.tsx
|
||||
// so the initial render picks the right tree.
|
||||
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
@@ -540,9 +975,54 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// Main window only: push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = String(uiScale);
|
||||
}, [uiScale]);
|
||||
if (isMiniWindow) return;
|
||||
return initMiniPlayerBridgeOnMain();
|
||||
}, [isMiniWindow]);
|
||||
|
||||
// Main window only: optionally pre-create the mini player webview hidden so
|
||||
// the first open is instant. Windows already does this unconditionally in
|
||||
// Rust .setup() as a hang workaround — skip here to avoid double-building.
|
||||
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
|
||||
useEffect(() => {
|
||||
if (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
|
||||
invoke('preload_mini_player').catch(() => {});
|
||||
}, [isMiniWindow, preloadMiniPlayer]);
|
||||
|
||||
// Mini window only: re-hydrate persisted appearance stores when the main
|
||||
// window writes new values. Both webviews share localStorage (same origin),
|
||||
// so the `storage` event fires here whenever main mutates a key — but
|
||||
// Zustand persist only reads localStorage on initial load, hence the
|
||||
// explicit rehydrate.
|
||||
useEffect(() => {
|
||||
if (!isMiniWindow) return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (!e.key) return;
|
||||
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [isMiniWindow]);
|
||||
|
||||
if (isMiniWindow) {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
||||
// because they live in separate grid cells. Document-level zoom is not used
|
||||
// — it broke portal positioning and Tauri window measurement.
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ─── AzuraCast API types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AzuraCastSong {
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
art?: string;
|
||||
text?: string; // "Artist - Title" combined
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlayingTrack {
|
||||
song: AzuraCastSong;
|
||||
duration: number; // seconds
|
||||
elapsed: number; // seconds played so far
|
||||
remaining: number; // seconds remaining
|
||||
played_at?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastListeners {
|
||||
current: number;
|
||||
unique?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlaying {
|
||||
now_playing: AzuraCastNowPlayingTrack;
|
||||
playing_next?: { song: AzuraCastSong } | null;
|
||||
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
|
||||
listeners: AzuraCastListeners;
|
||||
station?: { name: string; shortcode: string };
|
||||
}
|
||||
|
||||
// ─── Detection helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
|
||||
*
|
||||
* AzuraCast stream URLs follow the pattern:
|
||||
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
|
||||
*
|
||||
* Returns the candidate API URL or `null` if the pattern doesn't match.
|
||||
*/
|
||||
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(streamUrl);
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
// Expect: ['listen', '<shortcode>', '<file>']
|
||||
if (parts.length >= 2 && parts[0] === 'listen') {
|
||||
const shortcode = parts[1];
|
||||
return `${u.origin}/api/nowplaying/${shortcode}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
|
||||
* API endpoint and return the canonical URL to use.
|
||||
*
|
||||
* Accepts:
|
||||
* - https://<host>/api/nowplaying → all stations, we use as-is
|
||||
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
|
||||
*/
|
||||
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(homepageUrl);
|
||||
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
|
||||
return homepageUrl;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
|
||||
* the Rust backend). Returns `null` if the request fails or the response
|
||||
* does not look like a valid AzuraCast payload.
|
||||
*
|
||||
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
|
||||
* first item in the array is returned. Otherwise the single-object form is
|
||||
* used directly.
|
||||
*/
|
||||
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
|
||||
try {
|
||||
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// If the response is an array (all-stations endpoint), take the first item.
|
||||
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
|
||||
const np = obj as Record<string, unknown>;
|
||||
// Minimal validation: must have `now_playing` with a `song` inside.
|
||||
if (
|
||||
np.now_playing &&
|
||||
typeof np.now_playing === 'object' &&
|
||||
(np.now_playing as Record<string, unknown>).song
|
||||
) {
|
||||
return np as unknown as AzuraCastNowPlaying;
|
||||
}
|
||||
} catch {
|
||||
// Network error, JSON parse error, etc.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+3
-1
@@ -36,7 +36,9 @@ export async function fetchLyrics(
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
|
||||
// \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches.
|
||||
// parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly.
|
||||
const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// lyricsplus backend powering YouLyPlus.
|
||||
// Public mirrors — no auth required. Subscription costs for Apple/Spotify/
|
||||
// Musixmatch are borne by the backend operator, not by us.
|
||||
//
|
||||
// Response semantics:
|
||||
// type: "Word" → syllabus[] contains per-word timings (karaoke-style)
|
||||
// type: "Line" → syllabus is empty/ignored; line-level sync only
|
||||
// No match / no data for the query → HTTP 503 with Cloudflare origin
|
||||
// error 1102. We treat any non-2xx as
|
||||
// a clean miss (silent caller fallback).
|
||||
//
|
||||
// All timings in the response are milliseconds.
|
||||
|
||||
const BASE_URLS: readonly string[] = [
|
||||
'https://lyricsplus.prjktla.my.id',
|
||||
'https://lyricsplus.atomix.one',
|
||||
'https://lyricsplus.binimum.org',
|
||||
'https://lyricsplus.prjktla.workers.dev',
|
||||
'https://lyricsplus-seven.vercel.app',
|
||||
];
|
||||
|
||||
export interface LyricsPlusWord {
|
||||
/** Word / syllable text, including any trailing whitespace as returned by the API. */
|
||||
text: string;
|
||||
/** Absolute start time, ms. */
|
||||
time: number;
|
||||
/** Duration, ms. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface LyricsPlusLine {
|
||||
/** Absolute line start time, ms. */
|
||||
time: number;
|
||||
/** Full line duration, ms. */
|
||||
duration: number;
|
||||
/** Line text (concatenation of syllabus text when word-sync is present). */
|
||||
text: string;
|
||||
/** Per-word timings; empty/undefined for `type: "Line"` responses. */
|
||||
syllabus?: LyricsPlusWord[];
|
||||
element?: { singer?: string };
|
||||
}
|
||||
|
||||
export interface LyricsPlusResult {
|
||||
/** `"Word"` (syllabus available) | `"Line"` (syllabus empty) | other. */
|
||||
type: 'Word' | 'Line' | string;
|
||||
lyrics: LyricsPlusLine[];
|
||||
metadata?: {
|
||||
source?: string;
|
||||
language?: string;
|
||||
songWriters?: string[];
|
||||
title?: string;
|
||||
album?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LyricsPlusQuery {
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
/** Track duration in seconds — the API expects seconds. */
|
||||
durationSec?: number;
|
||||
isrc?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch lyrics from lyricsplus. Returns `null` for any miss or network
|
||||
* failure so callers can silently fall back to another provider without
|
||||
* seeing distinguishable error cases.
|
||||
*
|
||||
* Tries the primary endpoint first; on network failure (but NOT on HTTP
|
||||
* errors like 503 that indicate a clean miss) it walks the mirror list.
|
||||
*/
|
||||
export async function fetchLyricsPlus(q: LyricsPlusQuery): Promise<LyricsPlusResult | null> {
|
||||
if (!q.title.trim() || !q.artist.trim()) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('title', q.title);
|
||||
params.set('artist', q.artist);
|
||||
if (q.album) params.set('album', q.album);
|
||||
if (q.durationSec && q.durationSec > 0) params.set('duration', String(Math.round(q.durationSec)));
|
||||
if (q.isrc) params.set('isrc', q.isrc);
|
||||
|
||||
for (const base of BASE_URLS) {
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/lyrics/get?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
// 503 + Cloudflare error 1102 = no data. Any non-2xx = miss (no retry).
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as Partial<LyricsPlusResult> | null;
|
||||
if (!data || !Array.isArray(data.lyrics) || data.lyrics.length === 0) return null;
|
||||
|
||||
return {
|
||||
type: data.type ?? 'Line',
|
||||
lyrics: data.lyrics as LyricsPlusLine[],
|
||||
metadata: data.metadata ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// Network / DNS failure → try next mirror.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `true` if any line has a non-empty syllabus array (i.e. karaoke-style). */
|
||||
export function hasWordSync(result: LyricsPlusResult): boolean {
|
||||
return result.lyrics.some(l => Array.isArray(l.syllabus) && l.syllabus.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface NdLibrary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NdUser {
|
||||
id: string;
|
||||
userName: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
libraryIds: number[];
|
||||
lastLoginAt?: string | null;
|
||||
lastAccessAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NdLoginResult {
|
||||
token: string;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function ndLogin(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<NdLoginResult> {
|
||||
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
||||
}
|
||||
|
||||
function extractLibraryIds(o: Record<string, unknown>): number[] {
|
||||
const libs = o.libraries;
|
||||
if (!Array.isArray(libs)) return [];
|
||||
const ids: number[] = [];
|
||||
for (const l of libs) {
|
||||
const id = (l as Record<string, unknown>)?.id;
|
||||
if (typeof id === 'number') ids.push(id);
|
||||
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
||||
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(u => {
|
||||
const o = u as Record<string, unknown>;
|
||||
return {
|
||||
id: String(o.id ?? ''),
|
||||
userName: String(o.userName ?? ''),
|
||||
name: String(o.name ?? ''),
|
||||
email: String(o.email ?? ''),
|
||||
isAdmin: !!o.isAdmin,
|
||||
libraryIds: extractLibraryIds(o),
|
||||
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
|
||||
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
|
||||
createdAt: o.createdAt as string | undefined,
|
||||
updatedAt: o.updatedAt as string | undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
|
||||
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(l => {
|
||||
const o = l as Record<string, unknown>;
|
||||
const id = typeof o.id === 'number'
|
||||
? o.id
|
||||
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
|
||||
return { id, name: String(o.name ?? '') };
|
||||
}).filter(l => l.id > 0);
|
||||
}
|
||||
|
||||
export async function ndSetUserLibraries(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
libraryIds: number[],
|
||||
): Promise<void> {
|
||||
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
|
||||
}
|
||||
|
||||
export async function ndCreateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<{ id: string }> {
|
||||
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
|
||||
const o = (raw as Record<string, unknown> | null) ?? {};
|
||||
return { id: String(o.id ?? '') };
|
||||
}
|
||||
|
||||
export async function ndUpdateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<void> {
|
||||
await invoke('nd_update_user', { serverUrl, token, id, ...data });
|
||||
}
|
||||
|
||||
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
||||
await invoke('nd_delete_user', { serverUrl, token, id });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
|
||||
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+368
-15
@@ -3,6 +3,11 @@ import md5 from 'md5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { version } from '../../package.json';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
@@ -18,7 +23,7 @@ function getAuthParams(username: string, password: string) {
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
export function getClient() {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
@@ -66,6 +71,8 @@ export interface SubsonicAlbum {
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
||||
isCompilation?: boolean;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
@@ -105,6 +112,8 @@ export interface SubsonicSong {
|
||||
genre?: string;
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
/** ISRC code when available (e.g., Navidrome) */
|
||||
isrc?: string;
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
@@ -265,8 +274,14 @@ export async function ping(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
/** Test a connection with explicit credentials — does NOT depend on store state. */
|
||||
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
|
||||
export async function pingWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
@@ -277,12 +292,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
return data?.status === 'ok';
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function restBaseFromUrl(serverUrl: string): string {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
return `${base}/rest`;
|
||||
}
|
||||
|
||||
async function apiWithCredentials<T>(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
|
||||
/**
|
||||
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
|
||||
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
|
||||
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
|
||||
*/
|
||||
export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
if (songs.length === 0) return 'skipped';
|
||||
|
||||
let anyError = false;
|
||||
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
|
||||
try {
|
||||
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
if (list.some(s => s.id !== song.id)) return 'ok';
|
||||
} catch {
|
||||
anyError = true;
|
||||
}
|
||||
}
|
||||
return anyError ? 'error' : 'empty';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
|
||||
export function scheduleInstantMixProbeForServer(
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
identity: SubsonicServerIdentity,
|
||||
): void {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
@@ -309,6 +420,68 @@ export async function getAlbumList(
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
||||
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
||||
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
||||
*/
|
||||
let scopedLibraryAlbumIdCache: {
|
||||
serverId: string;
|
||||
folderId: string;
|
||||
filterVersion: number;
|
||||
ids: Set<string>;
|
||||
} | null = null;
|
||||
|
||||
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
||||
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId];
|
||||
if (folder === undefined || folder === 'all') {
|
||||
scopedLibraryAlbumIdCache = null;
|
||||
return null;
|
||||
}
|
||||
const hit = scopedLibraryAlbumIdCache;
|
||||
if (
|
||||
hit &&
|
||||
hit.serverId === activeServerId &&
|
||||
hit.folderId === folder &&
|
||||
hit.filterVersion === musicLibraryFilterVersion
|
||||
) {
|
||||
return hit.ids;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pageSize = 500;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
for (const a of albums) ids.add(a.id);
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
if (offset > 500_000) break;
|
||||
}
|
||||
scopedLibraryAlbumIdCache = {
|
||||
serverId: activeServerId,
|
||||
folderId: folder,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
ids,
|
||||
};
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||
const allowed = await albumIdsInActiveLibraryScope();
|
||||
if (!allowed || allowed.size === 0) return songs;
|
||||
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||
}
|
||||
|
||||
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||
function similarSongsRequestCount(desired: number): number {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||
if (f === undefined || f === 'all') return desired;
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||
if (genre) params.genre = genre;
|
||||
@@ -425,12 +598,165 @@ export async function prefetchAlbumUserRatings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
|
||||
export interface StatisticsLibraryAggregates {
|
||||
playtimeSec: number;
|
||||
albumsCounted: number;
|
||||
songsCounted: number;
|
||||
capped: boolean;
|
||||
genres: SubsonicGenre[];
|
||||
}
|
||||
|
||||
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
|
||||
function statisticsPageCacheKey(prefix: string): string | null {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
|
||||
const folderPart = folder === 'all' ? 'all' : folder;
|
||||
return `${prefix}:${activeServerId}:${folderPart}`;
|
||||
}
|
||||
|
||||
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
|
||||
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
|
||||
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
|
||||
*/
|
||||
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
|
||||
const key = statisticsPageCacheKey('statsAgg');
|
||||
if (key) {
|
||||
const hit = statisticsAggregatesCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
|
||||
let playtimeSec = 0;
|
||||
let albumsCounted = 0;
|
||||
let songsCounted = 0;
|
||||
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
|
||||
const pageSize = 500;
|
||||
const capped = false;
|
||||
let offset = 0;
|
||||
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
|
||||
for (;;) {
|
||||
try {
|
||||
const albums = await nextPage;
|
||||
for (const a of albums) {
|
||||
playtimeSec += a.duration ?? 0;
|
||||
albumsCounted += 1;
|
||||
const sc = a.songCount ?? 0;
|
||||
songsCounted += sc;
|
||||
const label = (a.genre?.trim()) ? a.genre.trim() : '';
|
||||
let g = genreAgg.get(label);
|
||||
if (!g) {
|
||||
g = { songCount: 0, albumCount: 0 };
|
||||
genreAgg.set(label, g);
|
||||
}
|
||||
g.songCount += sc;
|
||||
g.albumCount += 1;
|
||||
}
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const genres: SubsonicGenre[] = [...genreAgg.entries()]
|
||||
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
|
||||
.sort((a, b) => b.songCount - a.songCount);
|
||||
|
||||
const result: StatisticsLibraryAggregates = {
|
||||
playtimeSec,
|
||||
albumsCounted,
|
||||
songsCounted,
|
||||
capped,
|
||||
genres,
|
||||
};
|
||||
if (key) {
|
||||
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Recent / frequent / highest album strips + artist count for Statistics. */
|
||||
export interface StatisticsOverviewData {
|
||||
recent: SubsonicAlbum[];
|
||||
frequent: SubsonicAlbum[];
|
||||
highest: SubsonicAlbum[];
|
||||
artistCount: number;
|
||||
}
|
||||
|
||||
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
|
||||
const key = statisticsPageCacheKey('statsOverview');
|
||||
if (key) {
|
||||
const hit = statisticsOverviewCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const [recent, frequent, highest, artists] = await Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getArtists().catch(() => [] as SubsonicArtist[]),
|
||||
]);
|
||||
const result: StatisticsOverviewData = {
|
||||
recent,
|
||||
frequent,
|
||||
highest,
|
||||
artistCount: artists.length,
|
||||
};
|
||||
if (key) {
|
||||
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Format (suffix) histogram from a random sample for Statistics. */
|
||||
export interface StatisticsFormatSample {
|
||||
rows: { format: string; count: number }[];
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
|
||||
const key = statisticsPageCacheKey('statsFormat');
|
||||
if (key) {
|
||||
const hit = statisticsFormatCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const rows = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
|
||||
if (key) {
|
||||
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
const rawIdx = data.artists?.index;
|
||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||
const artists: SubsonicArtist[] = [];
|
||||
for (const idx of indices) {
|
||||
const rawArt = idx.artist;
|
||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||
artists.push(...arr);
|
||||
}
|
||||
return artists;
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
@@ -439,15 +765,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
|
||||
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||
const count = options?.similarArtistCount ?? 5;
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
|
||||
return data.topSongs?.song ?? [];
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
||||
const topCount = scoped ? 20 : 5;
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
||||
const raw = data.topSongs?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -455,8 +787,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
|
||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
|
||||
return data.similarSongs2?.song ?? [];
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs2?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
|
||||
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs?.song;
|
||||
if (!raw) return [];
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
const filtered = await filterSongsToActiveLibrary(list);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -856,7 +1206,10 @@ export interface SubsonicLyricLine {
|
||||
}
|
||||
|
||||
export interface SubsonicStructuredLyrics {
|
||||
issynced: boolean;
|
||||
/** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */
|
||||
synced?: boolean;
|
||||
/** Legacy / alternate casing used by some older Subsonic-compatible servers. */
|
||||
issynced?: boolean;
|
||||
lang?: string;
|
||||
offset?: number;
|
||||
displayArtist?: string;
|
||||
@@ -878,7 +1231,7 @@ export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredL
|
||||
);
|
||||
const list = data.lyricsList?.structuredLyrics;
|
||||
if (!list || list.length === 0) return null;
|
||||
return list.find(l => l.issynced) ?? list[0];
|
||||
return list.find(l => l.synced || l.issynced) ?? list[0];
|
||||
} catch {
|
||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||
return null;
|
||||
|
||||
@@ -15,9 +15,10 @@ interface AlbumCardProps {
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -43,9 +44,12 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
if (selectionMode) { e.preventDefault(); return; }
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
if (selectionMode && selectedAlbums.length > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (selectionMode || e.button !== 0) return;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
@@ -85,6 +86,7 @@ interface AlbumHeaderProps {
|
||||
onRemoveOffline: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onShuffleAll?: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
@@ -111,6 +113,7 @@ export default function AlbumHeader({
|
||||
onRemoveOffline,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onShuffleAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
@@ -120,6 +123,7 @@ export default function AlbumHeader({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
@@ -138,14 +142,16 @@ export default function AlbumHeader({
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
@@ -165,7 +171,6 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
@@ -243,7 +248,7 @@ export default function AlbumHeader({
|
||||
aria-label={t('albumDetail.artistBio')}
|
||||
data-tooltip={t('albumDetail.artistBio')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
<Highlighter size={16} />
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
@@ -291,29 +296,35 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={onShuffleAll}
|
||||
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
className="btn btn-ghost"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
|
||||
+438
-236
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check, RotateCcw } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -9,6 +9,8 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -18,18 +20,14 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate}`);
|
||||
return parts.join(' ');
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
// rest → persistent px values from useTracklistColumns hook
|
||||
|
||||
const COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
@@ -43,176 +41,95 @@ const COLUMNS: readonly ColDef[] = [
|
||||
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
// Columns where header label is centred in the cell (matches row controls below)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
sorted?: boolean;
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
ratings: Record<string, number>;
|
||||
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
}
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
|
||||
// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle.
|
||||
|
||||
interface TrackRowProps {
|
||||
song: SubsonicSong;
|
||||
globalIdx: number;
|
||||
visibleCols: readonly ColDef[];
|
||||
gridStyle: React.CSSProperties;
|
||||
currentTrackId: string | null;
|
||||
isPlaying: boolean;
|
||||
ratingValue: number;
|
||||
isStarred: boolean;
|
||||
inSelectMode: boolean;
|
||||
isContextMenuSong: boolean;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: AlbumTrackListProps['onContextMenu'];
|
||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||
setContextMenuSongId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const TrackRow = React.memo(function TrackRow({
|
||||
song,
|
||||
globalIdx,
|
||||
visibleCols,
|
||||
gridStyle,
|
||||
currentTrackId,
|
||||
isPlaying,
|
||||
ratings,
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
ratingValue,
|
||||
isStarred,
|
||||
inSelectMode,
|
||||
isContextMenuSong,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
onToggleSelect,
|
||||
onDragStart,
|
||||
setContextMenuSongId,
|
||||
}: TrackRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||
const isActive = currentTrackId === song.id;
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state (resize, visibility, picker) via shared hook ────────────
|
||||
const {
|
||||
colWidths, colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const renderCell = (colDef: ColDef) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = CENTERED_COLS.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// num header: checkbox + # label, mirrors row-cell layout exactly
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Row cell renderer ─────────────────────────────────────────────────────
|
||||
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
|
||||
switch (key) {
|
||||
case 'num':
|
||||
return (
|
||||
<div
|
||||
key="num"
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
|
||||
className={`track-num${isActive ? ' track-num-active' : ''}${isActive && !isPlaying ? ' track-num-paused' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
{currentTrack?.id === song.id && isPlaying && (
|
||||
{isActive && isPlaying && (
|
||||
<span className="track-num-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
@@ -227,27 +144,36 @@ export default function AlbumTrackList({
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist':
|
||||
case 'artist': {
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.artist }];
|
||||
return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>
|
||||
{song.artist}
|
||||
</span>
|
||||
{artistRefs.map((a, i) => (
|
||||
<React.Fragment key={a.id ?? a.name ?? i}>
|
||||
{i > 0 && <span className="track-artist-sep"> · </span>}
|
||||
<span
|
||||
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
|
||||
>
|
||||
{a.name ?? song.artist}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'favorite':
|
||||
return (
|
||||
<div key="favorite" className="track-star-cell">
|
||||
<button
|
||||
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
|
||||
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -255,7 +181,7 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
@@ -268,8 +194,8 @@ export default function AlbumTrackList({
|
||||
case 'format':
|
||||
return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec">{codecLabel(song)}</span>
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && (
|
||||
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -284,7 +210,284 @@ export default function AlbumTrackList({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ─────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
className={`track-row track-row-va${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
onToggleSelect(song.id, globalIdx, false);
|
||||
} else if (inSelectMode) {
|
||||
onToggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
onDragStart(song, me);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => renderCell(colDef))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
sorted,
|
||||
hasVariousArtists: _hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
ratings,
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// Selection state lives in selectionStore — only the toggled row re-renders (O(1)).
|
||||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||||
const inSelectMode = selectedCount > 0;
|
||||
const allSelected = selectedCount === songs.length && songs.length > 0;
|
||||
const lastSelectedIdxRef = useRef<number | null>(null);
|
||||
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn, resetColumns,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
// Clear selection when the song list changes (different album / filter applied).
|
||||
useEffect(() => {
|
||||
useSelectionStore.getState().clearAll();
|
||||
lastSelectedIdxRef.current = null;
|
||||
}, [songs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Clear selection on click outside the tracklist (header, album art, etc.)
|
||||
useEffect(() => {
|
||||
if (!inSelectMode) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [inSelectMode, tracklistRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
|
||||
|
||||
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
|
||||
useSelectionStore.getState().setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdxRef.current !== null) {
|
||||
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
|
||||
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
lastSelectedIdxRef.current = globalIdx;
|
||||
return next;
|
||||
});
|
||||
}, [songs]);
|
||||
|
||||
// Drag: if the dragged song is part of the selection, drag all selected songs.
|
||||
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
|
||||
const { selectedIds } = useSelectionStore.getState();
|
||||
if (selectedIds.has(song.id) && selectedIds.size > 1) {
|
||||
const tracks = songs
|
||||
.filter(s => selectedIds.has(s.id))
|
||||
.map(s => songToTrack(s));
|
||||
psyDrag.startDrag(
|
||||
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
|
||||
me.clientX, me.clientY,
|
||||
);
|
||||
} else {
|
||||
psyDrag.startDrag(
|
||||
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
|
||||
me.clientX, me.clientY,
|
||||
);
|
||||
}
|
||||
}, [songs, psyDrag]);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
} else {
|
||||
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
|
||||
}
|
||||
}, [allSelected, songs]);
|
||||
|
||||
// ── Disc grouping ─────────────────────────────────────────────────────────
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
if (!sorted) {
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
} else {
|
||||
discs.set(1, songs as SubsonicSong[]);
|
||||
}
|
||||
const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = !sorted && discNums.length > 1;
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
|
||||
// ── Sortable columns ──────────────────────────────────────────────────────
|
||||
const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
||||
|
||||
const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey);
|
||||
|
||||
const handleHeaderClick = (key: ColKey | string) => {
|
||||
if (!isSortable(key) || !onSort) return;
|
||||
onSort(key);
|
||||
};
|
||||
|
||||
const renderSortIndicator = (key: SortKey) => {
|
||||
if (sortKey !== key) return null;
|
||||
return (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||
{sortDir === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = CENTERED_COLS.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
const canSort = isSortable(key) && onSort;
|
||||
const isActive = canSort && sortKey === key;
|
||||
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => handleHeaderClick(key)}
|
||||
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && renderSortIndicator(key as SortKey)}
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => handleHeaderClick(key)}
|
||||
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ──────────────────────────────────────────────────────
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="tracklist-mobile">
|
||||
@@ -296,7 +499,7 @@ export default function AlbumTrackList({
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const isActive = currentTrack?.id === song.id;
|
||||
const isActive = currentTrackId === song.id;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
@@ -329,48 +532,10 @@ export default function AlbumTrackList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
{/* Column visibility picker */}
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<>
|
||||
{/* Column visibility picker - outside .tracklist to avoid overflow cutoff */}
|
||||
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
||||
<div className="tracklist-col-picker">
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
@@ -397,11 +562,63 @@ export default function AlbumTrackList({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="tracklist"
|
||||
ref={tracklistRef}
|
||||
onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
}}
|
||||
>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="tracklist-header-wrapper">
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tracks ── */}
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
@@ -411,50 +628,35 @@ export default function AlbumTrackList({
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
<TrackRow
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
|
||||
</div>
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
isStarred={starredSongs.has(song.id)}
|
||||
inSelectMode={inSelectMode}
|
||||
isContextMenuSong={contextMenuSongId === song.id}
|
||||
onPlaySong={onPlaySong}
|
||||
onRate={onRate}
|
||||
onToggleSongStar={onToggleSongStar}
|
||||
onContextMenu={onContextMenu}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+477
-42
@@ -1,9 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
@@ -16,57 +22,486 @@ function isNewer(a: string, b: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Minimal inline-markdown renderer (bold, italic, code)
|
||||
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
||||
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChangelog(body: string) {
|
||||
return body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### '))
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
if (line.startsWith('#### '))
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
if (line.startsWith('## '))
|
||||
return null; // skip nested release headers in body
|
||||
if (line.startsWith('- '))
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
});
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ReleaseData {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||
if (IS_WINDOWS) {
|
||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||
?? assets.find(a => a.name.endsWith('.exe'));
|
||||
}
|
||||
if (IS_MACOS) {
|
||||
// Prefer Apple Silicon, fall back to Intel
|
||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||
}
|
||||
if (IS_LINUX) {
|
||||
// AppImage > deb > rpm
|
||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||
?? assets.find(a => a.name.endsWith('.deb'))
|
||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [isArch, setIsArch] = useState(false);
|
||||
const [dlState, setDlState] = useState<DlState>('idle');
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
const countdownRef = useRef<number | null>(null);
|
||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
const version = tag.replace(/^[^0-9]*/, '');
|
||||
if (!version) return;
|
||||
if (!preview) {
|
||||
if (!isNewer(version, currentVersion)) return;
|
||||
const skipped = localStorage.getItem(SKIP_KEY);
|
||||
if (skipped === version) return;
|
||||
}
|
||||
setDismissed(false);
|
||||
setDlState('idle');
|
||||
setRelease({
|
||||
version,
|
||||
tag,
|
||||
body: (data.body ?? '').trim(),
|
||||
assets: data.assets ?? [],
|
||||
});
|
||||
if (IS_LINUX) {
|
||||
const arch = await invoke<boolean>('check_arch_linux');
|
||||
setIsArch(arch);
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setNewVersion(tag.replace(/^[^0-9]*/, ''));
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
}, 4000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||
|
||||
const handler = () => fetchRelease(true);
|
||||
window.addEventListener('psysonic:preview-update', handler);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!newVersion || dismissed) return null;
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
// On macOS the Tauri Updater handles architecture, signature verification
|
||||
// and in-place install — we don't need (and should not show) a DMG asset.
|
||||
const useTauriUpdater = IS_MACOS;
|
||||
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const startRestartCountdown = (seconds: number) => {
|
||||
let remaining = seconds;
|
||||
setCountdown(remaining);
|
||||
countdownRef.current = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
setCountdown(null);
|
||||
relaunchFnRef.current?.();
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
if (countdownRef.current) {
|
||||
window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setCountdown(null);
|
||||
await relaunchFnRef.current?.();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||
// relaunches. No manual "open the DMG" step needed.
|
||||
if (IS_MACOS) {
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: 0 });
|
||||
setDlError('');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
relaunchFnRef.current = relaunch;
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
setDlError(t('common.updaterErrorMsg'));
|
||||
setDlState('error');
|
||||
return;
|
||||
}
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await update.downloadAndInstall(event => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
setDlProgress({ bytes: 0, total });
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setDlProgress({ bytes: downloaded, total });
|
||||
} else if (event.event === 'Finished') {
|
||||
setDlState('done');
|
||||
// downloadAndInstall replaces the .app in place but does not exit
|
||||
// the running process. Give the user a 3s countdown (with a manual
|
||||
// "Restart now" button) before auto-relaunch.
|
||||
startRestartCountdown(3);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
setDlError('');
|
||||
|
||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||
'update:download:progress',
|
||||
e => {
|
||||
setDlProgress({
|
||||
bytes: e.payload.bytes,
|
||||
total: e.payload.total ?? asset.size,
|
||||
});
|
||||
}
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
try {
|
||||
const finalPath = await invoke<string>('download_update', {
|
||||
url: asset.browser_download_url,
|
||||
filename: asset.name,
|
||||
});
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlPath(finalPath);
|
||||
setDlState('done');
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowFolder = async () => {
|
||||
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||
const dir = await dirname(dlPath);
|
||||
await invoke('open_folder', { path: dir });
|
||||
};
|
||||
|
||||
const pct = dlProgress.total > 0
|
||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||
: 0;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
||||
<div
|
||||
className="eq-popup update-modal"
|
||||
style={{ zIndex: 3001 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="eq-popup-header update-modal-header">
|
||||
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
|
||||
<span className="update-modal-versions">
|
||||
v{currentVersion} → <strong>v{release.version}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="app-updater-dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
data-tooltip={t('common.updaterRemindBtn')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body: changelog + download area — single overflow container */}
|
||||
<div className="update-modal-body">
|
||||
{/* Collapsible Changelog */}
|
||||
{release.body && (
|
||||
<div className="update-modal-changelog">
|
||||
<button
|
||||
type="button"
|
||||
className="update-modal-changelog-toggle"
|
||||
onClick={() => setChangelogOpen(v => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transform: changelogOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{t('common.updaterChangelog')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div className="update-modal-changelog-body">
|
||||
{renderChangelog(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download / AUR area */}
|
||||
<div className="update-modal-download-area">
|
||||
{showAurHint ? (
|
||||
<div className="update-modal-aur">
|
||||
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{fmtBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
|
||||
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
|
||||
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
|
||||
<FolderOpen size={14} />
|
||||
{t('common.updaterShowFolder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="update-modal-asset-none">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
|
||||
>
|
||||
{t('common.updaterOpenGitHub')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
|
||||
<div className="update-modal-footer">
|
||||
{dlState === 'idle' && (
|
||||
<>
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
|
||||
{dlState === 'done' && useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'done' && !useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
|
||||
<div className="app-updater-actions">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
className="app-updater-btn-secondary"
|
||||
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
|
||||
>
|
||||
{t('common.updaterWebsite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
@@ -38,7 +40,7 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
{t('artists.albumCount', { count: artist.albumCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user