mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 00:05:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9f9167c4 | |||
| 21033e82f4 | |||
| c7407af05b | |||
| 159973fb16 |
@@ -1,138 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something is broken or behaves incorrectly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill in as much as you can — the more
|
||||
precise the report, the faster it can be reproduced and fixed.
|
||||
|
||||
**Before opening an issue:** if you're not sure whether the problem is on the Psysonic side
|
||||
or on your Subsonic-compatible server, please ask in the [Discord](https://discord.gg/AMnDRErm4u)
|
||||
or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) community first.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: What's wrong?
|
||||
description: A short description of the problem (one or two sentences).
|
||||
placeholder: e.g. The volume slider jumps back to 100 % every time I switch tracks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered, minimal steps that lead to the problem.
|
||||
placeholder: |
|
||||
1. Open Psysonic and connect to my Navidrome server.
|
||||
2. Start playback of any track.
|
||||
3. Drag the volume slider to ~30 %.
|
||||
4. Press the next button.
|
||||
5. Volume slider has snapped back to 100 %.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What actually happens?
|
||||
description: Include any error toast or popup text exactly as shown.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Psysonic version
|
||||
description: |
|
||||
Exact version string from `Settings → About` (e.g. `1.45.2` or `1.46.0-dev`).
|
||||
placeholder: 1.45.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- Other (please describe in "Anything else")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: Install source
|
||||
description: How did you install Psysonic?
|
||||
options:
|
||||
- AUR — psysonic
|
||||
- AUR — psysonic-bin
|
||||
- .deb (Linux)
|
||||
- .rpm (Linux)
|
||||
- flakes
|
||||
- .dmg (macOS)
|
||||
- .msi installer (Windows)
|
||||
- Built from source
|
||||
- Other (please describe in "Anything else")
|
||||
|
||||
- type: dropdown
|
||||
id: server
|
||||
attributes:
|
||||
label: Subsonic-compatible server
|
||||
options:
|
||||
- Navidrome
|
||||
- Gonic
|
||||
- Airsonic
|
||||
- LMS
|
||||
- Other Subsonic-compatible server
|
||||
- Not sure / not relevant
|
||||
|
||||
- type: input
|
||||
id: server_version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: If your server has a version string, paste it here. Skip if the bug is clearly UI-side.
|
||||
placeholder: e.g. Navidrome 0.55.0
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
How to grab logs:
|
||||
1. `Settings → Logging → Debug` (enables verbose logging)
|
||||
2. Reproduce the bug
|
||||
3. `Settings → Logging → Export logs` and attach the resulting file or paste the relevant section below.
|
||||
|
||||
Logs help a lot, especially for playback / streaming / sync bugs.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / video
|
||||
description: Drag-and-drop is supported. Optional but appreciated for UI bugs.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Distro / WM / GPU driver, Tauri-specific quirks, theme, anything that might be relevant.
|
||||
@@ -1,16 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Usage questions / general help
|
||||
url: https://discord.gg/AMnDRErm4u
|
||||
about: |
|
||||
Have a question, need help connecting to your server, or not sure if a behaviour is
|
||||
a bug? Please ask in the Discord community first — the issue tracker is reserved for
|
||||
confirmed bugs and feature requests.
|
||||
- name: Telegram community
|
||||
url: https://t.me/+GLBx1_xeH28xYTJi
|
||||
about: Alternative chat channel for usage questions and general discussion.
|
||||
- name: AUR packaging issues
|
||||
url: https://aur.archlinux.org/packages/psysonic
|
||||
about: |
|
||||
For problems specific to the AUR `psysonic` or `psysonic-bin` packages (build failures,
|
||||
dependency mismatches, AUR metadata), please comment on the AUR page first.
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting an improvement. A short description of the use case is more
|
||||
valuable than a fully-specified solution — the implementation usually shifts during
|
||||
review, but the underlying need stays the same.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the use case?
|
||||
description: |
|
||||
What are you trying to do that's currently hard or impossible? Describe the workflow
|
||||
or situation, not the proposed solution.
|
||||
placeholder: |
|
||||
e.g. When I'm on a long flight I want to keep listening to my offline-cached tracks,
|
||||
but if I open Psysonic without an internet connection it shows the connection
|
||||
screen instead of letting me into the player.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How do you imagine this working? Sketches, UI ideas, command names — anything goes.
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Workarounds you've tried, or other approaches that didn't fit.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Links to related issues, similar requests, screenshots, etc.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Dependabot: security updates only (no scheduled version bumps).
|
||||
#
|
||||
# Version updates are disabled via open-pull-requests-limit: 0. GitHub still opens
|
||||
# PRs when Dependabot/npm/cargo audit reports a vulnerability (and related
|
||||
# transitive fixes in the same bump). Routine minor/patch upgrades are manual.
|
||||
#
|
||||
# Requires "Dependabot security updates" enabled in repo Settings → Code security.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
npm-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
cargo-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Symphonia 0.6 is a coordinated migration (API break + isomp4 patch port).
|
||||
# See workdocs: internal/collaboration/tasks/2026-05-symphonia-0.6-migration/
|
||||
- dependency-name: symphonia
|
||||
versions: [">= 0.6"]
|
||||
- dependency-name: symphonia-adapter-libopus
|
||||
versions: [">= 0.3"]
|
||||
@@ -1,44 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70 % coverage gate (frontend).
|
||||
#
|
||||
# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a
|
||||
# workspace-relative path; the gate script (`scripts/check-frontend-hot-path-
|
||||
# coverage.sh`) reads `coverage/coverage-summary.json` produced by
|
||||
# `vitest run --coverage` and fails the frontend-tests coverage job when a
|
||||
# listed file drops below the floor.
|
||||
#
|
||||
# Curation rule (mirrors the backend list): a file belongs here when its
|
||||
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
|
||||
# that are partially covered today but contain large untested surface area
|
||||
# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live
|
||||
# OUTSIDE the gate until their tests grow. Add them as Phase 1–4 coverage
|
||||
# work lands.
|
||||
#
|
||||
# Deferred from the gate, with current coverage shown for reference:
|
||||
# - src/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
|
||||
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
|
||||
# ── utils (already at or above threshold) ────────────────────────────
|
||||
src/utils/cover/coverArtRegisteredSizes.ts
|
||||
src/utils/server/serverDisplayName.ts
|
||||
src/utils/server/serverMagicString.ts
|
||||
src/utils/share/shareLink.ts
|
||||
src/utils/ui/dynamicColors.ts
|
||||
src/utils/playback/resolvePlaybackUrl.ts
|
||||
src/utils/share/copyEntityShareLink.ts
|
||||
|
||||
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
|
||||
src/utils/playback/shuffleArray.ts
|
||||
src/utils/audio/resolveReplayGainDb.ts
|
||||
src/utils/playback/songToTrack.ts
|
||||
src/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
|
||||
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
|
||||
src/app/windowKind.ts
|
||||
src/app/bootstrap.ts
|
||||
|
||||
# ── Phase B.2: mini-player webview tree (2026-05-12) ─────────────────
|
||||
src/app/MiniPlayerApp.tsx
|
||||
|
||||
# ── stores (added as their tests grew past the floor) ────────────────
|
||||
src/store/previewStore.ts
|
||||
@@ -1,42 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70% coverage gate.
|
||||
#
|
||||
# Background: cucadmuh asked for ≥80% per-function coverage on functions in
|
||||
# typical user-triggered sequences. The original implementation tried to
|
||||
# parse cargo-llvm-cov per-function region data, but the metric is not
|
||||
# reliable for async state-machines (async fn body lives in synthetic
|
||||
# closures, the "main" symbol is just 1-2 regions) or generic functions
|
||||
# (every monomorphic instantiation is a separate symbol). See
|
||||
# `scripts/check-hot-path-coverage.sh` header for details.
|
||||
#
|
||||
# Switched to file-level coverage: every file listed below must stay
|
||||
# at ≥70% line coverage. The list is the subset of source files where
|
||||
# the hot-path code dominates and 70% is a reasonable floor. Files like
|
||||
# `psysonic-syncfs/src/sync/batch.rs` aren't listed because their hot-
|
||||
# path functions ARE tested (parse_subsonic_songs / estimate_track_size
|
||||
# / track_sync_info_from_subsonic_json all 100%) but the same file
|
||||
# also holds a large amount of untested admin Tauri commands that drag
|
||||
# the file aggregate down. Those are tracked individually via direct
|
||||
# unit tests, not via this gate.
|
||||
#
|
||||
# Each line is a path relative to the workspace root (so `src-tauri/...`).
|
||||
# `#` for comments. CI runs cargo-llvm-cov + this gate; PRs that drop
|
||||
# any listed file below the threshold fail the rust-tests coverage job.
|
||||
|
||||
# ── psysonic-syncfs ──────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/offline.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/file_transfer.rs
|
||||
|
||||
# ── psysonic-analysis ────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs
|
||||
|
||||
# ── psysonic-audio ───────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-audio/src/decode.rs
|
||||
src-tauri/crates/psysonic-audio/src/stream/icy.rs
|
||||
src-tauri/crates/psysonic-audio/src/progress_task.rs
|
||||
src-tauri/crates/psysonic-audio/src/ipc.rs
|
||||
|
||||
# ── psysonic-integration ─────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-integration/src/discord.rs
|
||||
src-tauri/crates/psysonic-integration/src/navidrome/client.rs
|
||||
@@ -1,84 +0,0 @@
|
||||
name: frontend-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: vitest run
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: vitest
|
||||
run: npm test
|
||||
|
||||
typecheck:
|
||||
name: tsc --noEmit
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
coverage:
|
||||
name: vitest --coverage (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: vitest run --coverage
|
||||
run: npx vitest run --coverage
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-frontend-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-coverage-lcov
|
||||
path: coverage/lcov.info
|
||||
if-no-files-found: error
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version bump changes to commit."
|
||||
exit 0
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No finalization changes to commit."
|
||||
exit 0
|
||||
|
||||
@@ -35,70 +35,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# Refresh npmDepsHash + flake.lock on the channel branch *before* tagging.
|
||||
# Promote workflows push with GITHUB_TOKEN (no downstream workflow runs), and the
|
||||
# old post-tag verify-nix PR landed after app-v* tags were already cut.
|
||||
prepare-nix-sources:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
source_commit_sha: ${{ steps.final-sha.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- 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
|
||||
- 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
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: commit and push refreshed lock and hash (if changed)
|
||||
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="$(node -p 'require("./package.json").version')"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "HEAD:${{ inputs.target_branch }}"
|
||||
- name: capture source commit sha
|
||||
id: final-sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
needs: prepare-nix-sources
|
||||
if: ${{ !cancelled() && !failure() && (needs.prepare-nix-sources.result == 'success' || needs.prepare-nix-sources.result == 'skipped') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -110,7 +47,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.verify_nix && needs.prepare-nix-sources.outputs.source_commit_sha || inputs.source_ref }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -176,32 +113,18 @@ jobs:
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=""
|
||||
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
|
||||
BODY="$(cat /tmp/changelog-body.md)"
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$BODY" ]; then
|
||||
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
|
||||
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
fi
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: extract what's new for release asset
|
||||
id: whats-new
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
CHANNEL="${{ inputs.channel }}"
|
||||
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
|
||||
if [ "$CHANNEL" = "release" ]; then
|
||||
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v9
|
||||
@@ -251,14 +174,6 @@ jobs:
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
- name: upload whats-new.md release asset
|
||||
if: steps.whats-new.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_TAG="${{ steps.tag.outputs.value }}"
|
||||
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
|
||||
|
||||
build-macos-windows:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
@@ -279,7 +194,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -353,7 +268,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
@@ -375,7 +290,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -410,6 +325,68 @@ jobs:
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "$RELEASE_TAG" --clobber
|
||||
|
||||
verify-nix:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- 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
|
||||
- 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
|
||||
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-${{ inputs.target_branch }}-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 "${{ inputs.target_branch }}" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
|
||||
|
||||
bump-main-to-next-dev:
|
||||
if: ${{ inputs.channel == 'release' }}
|
||||
needs: create-release
|
||||
@@ -470,7 +447,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dev version bump required."
|
||||
exit 0
|
||||
@@ -486,4 +463,4 @@ jobs:
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(release): bump main to ${VERSION}" \
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, \`src-tauri/Cargo.lock\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
name: rust-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo test --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo test --workspace --all-targets
|
||||
|
||||
clippy:
|
||||
name: cargo clippy --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo clippy --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
coverage:
|
||||
name: cargo llvm-cov (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake jq
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
- name: generate workspace coverage (lcov + json)
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
mkdir -p target/llvm-cov
|
||||
cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||
cargo llvm-cov --workspace --json --output-path target/llvm-cov/cov.json
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-coverage-lcov
|
||||
path: src-tauri/lcov.info
|
||||
if-no-files-found: error
|
||||
-11
@@ -32,20 +32,10 @@ dist-ssr
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/lcov.info
|
||||
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
|
||||
src/generated/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Local commit-instructions for agents (never commit)
|
||||
to_commit.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
@@ -66,4 +56,3 @@ result-*
|
||||
dev.sh
|
||||
shell.nix
|
||||
prod.sh
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+6
-2550
File diff suppressed because it is too large
Load Diff
-184
@@ -1,184 +0,0 @@
|
||||
# Contributing to Psysonic
|
||||
|
||||
Thanks for your interest in helping the project.
|
||||
|
||||
Psysonic is **GPLv3** — see [LICENSE](LICENSE). Forks and modifications are welcome under the license; for attribution expectations when publishing derivative work, see **Forks and Attribution** in the [README](README.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Before you write code](#before-you-write-code)
|
||||
- [Repository layout](#repository-layout)
|
||||
- [Environment and running the app](#environment-and-running-the-app)
|
||||
- [Where processes and conventions are documented](#where-processes-and-conventions-are-documented)
|
||||
- [House rules](#house-rules)
|
||||
- [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract)
|
||||
- [CI on pull requests to `main`](#ci-on-pull-requests-to-main)
|
||||
- [Local checks](#local-checks)
|
||||
- [Pull request expectations](#pull-request-expectations)
|
||||
- [Why we are wary of irreversible UI churn](#why-we-are-wary-of-irreversible-ui-churn)
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
npm install
|
||||
npm run tauri:dev # run the desktop app in dev mode
|
||||
npm test # frontend tests (Vitest)
|
||||
( cd src-tauri && cargo test --workspace --all-targets ) # backend tests
|
||||
```
|
||||
|
||||
Open pull requests against `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly. The rest of this document covers what reviewers look for, especially around the [Tauri contract](#the-rust--frontend-tauri-contract) and UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Before you write code
|
||||
|
||||
- **Usage questions** ("is this a bug or my setup?") — please use [Discord](https://discord.gg/AMnDRErm4u) or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) first. The issue tracker is intended for confirmed bugs and feature requests (see [issue templates](.github/ISSUE_TEMPLATE/)).
|
||||
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
|
||||
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
|
||||
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
|
||||
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
src/ React / TypeScript frontend
|
||||
src-tauri/ Rust backend (Tauri host process)
|
||||
public/ Static assets served by Vite
|
||||
scripts/ CI helpers (coverage gates, install, version sync)
|
||||
.github/ Workflows, issue templates, hot-path lists
|
||||
flake.nix Nix development shell + packaging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment and running the app
|
||||
|
||||
See [README](README.md) (**Development**) for the basic flow: from the repository root, `npm install` then `npm run tauri:dev` for development or `npm run tauri:build` for a release build. Use `npm install` while iterating; `npm ci` is what CI runs and is the right command when you want a reproducible install.
|
||||
|
||||
For non-Linux contributors, install the native dependencies Tauri requires on your OS — see the upstream [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) (Windows: WebView2 + MSVC build tools; macOS: Xcode Command Line Tools). The Linux package list used in CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml).
|
||||
|
||||
If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pinned toolchain and native dependencies. Adding `psysonic.cachix.org` as a substituter (badge in the [README](README.md)) lets you pull prebuilt dev-shell dependencies instead of rebuilding them locally.
|
||||
|
||||
---
|
||||
|
||||
## Where processes and conventions are documented
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
|
||||
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
|
||||
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
|
||||
|
||||
---
|
||||
|
||||
## House rules
|
||||
|
||||
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
|
||||
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
|
||||
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
|
||||
5. **License:** new code must remain compatible with the project's GPLv3.
|
||||
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
|
||||
7. **i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
|
||||
|
||||
---
|
||||
|
||||
## The Rust ↔ frontend (Tauri) contract
|
||||
|
||||
Treat `invoke` handlers, event names, and JSON/payload shapes as a **public API between two codebases**. Prefer **additive** changes (new optional fields, new commands/events) over silent renames or breaking shape changes.
|
||||
|
||||
When a breaking change is unavoidable, it should be:
|
||||
|
||||
- **narrow** and **documented in the PR**,
|
||||
- paired with updates on **both sides** of the boundary, and
|
||||
- paired with updates to any Vitest Tauri mocks that encode the contract.
|
||||
|
||||
Drive-by churn here is expensive: it hurts forks, complicates bisects, and forces every contributor to relearn the boundary. If the same outcome can be achieved inside Rust or inside React alone, default to that.
|
||||
|
||||
Align early: open an issue or chat thread before sending a PR that renames `invoke` commands, changes event payloads, or reshapes data across the boundary. Reviewers will ask for a clear benefit because every such change ripples through `src-tauri`, `src`, tests, and future contributors' mental model.
|
||||
|
||||
---
|
||||
|
||||
## CI on pull requests to `main`
|
||||
|
||||
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
|
||||
|
||||
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
|
||||
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
|
||||
|
||||
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
|
||||
|
||||
---
|
||||
|
||||
## Local checks
|
||||
|
||||
Assume the repository root is `psysonic/` (for example after `git clone https://github.com/Psychotoxical/psysonic.git` and `cd psysonic`).
|
||||
|
||||
**Frontend** — from the repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
bash scripts/check-frontend-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
The last command mirrors the optional hot-path gate used in CI; `jq` must be on `PATH`.
|
||||
|
||||
**Rust** — install the Linux packages your distro needs to build Tauri/WebKitGTK (the list used in Ubuntu CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml) under `apt-get install`), or use `nix develop`. Then:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo test --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
To reproduce the **coverage + hot-path** job locally you also need:
|
||||
|
||||
- `cargo-llvm-cov`
|
||||
- the `llvm-tools-preview` rustup component
|
||||
- `jq` on `PATH`
|
||||
|
||||
The exact `cargo llvm-cov` invocations and the gate call are taken from the `coverage` job in [`rust-tests.yml`](.github/workflows/rust-tests.yml). After generating `src-tauri/target/llvm-cov/cov.json` as that job does, run the gate from the **repository root**:
|
||||
|
||||
```bash
|
||||
bash scripts/check-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
If you change both frontend and backend, run the relevant blocks above before opening a PR.
|
||||
|
||||
---
|
||||
|
||||
## Pull request expectations
|
||||
|
||||
- **Description:** what changed, who should notice (end users vs developers only), how to verify manually. Link the issue if the PR closes it.
|
||||
- **Scope:** stay on task; no unrelated reformatting or cleanup in the same PR.
|
||||
- **UI/UX:** describe the user flow; before/after screenshots help reviewers a lot.
|
||||
- **i18n:** see [House rules](#house-rules) — add the key to `en.ts` first, keep the shape of other locales consistent.
|
||||
- **Server compatibility:** the client targets the Subsonic API and is **Navidrome-first**; if a feature depends on server support, say so explicitly.
|
||||
- **Tauri boundary:** if you touched it, list added/removed/renamed commands and events, describe payload changes, and note how you verified both `src-tauri` and `src` (plus any updated tests/mocks). If you did **not** touch the boundary, saying so helps reviewers scope the review.
|
||||
- **Persisted settings / on-disk layout:** if you change how configuration or local data is stored, migrated, or located, spell out the impact on **existing installs** (one-time migration, backwards compatibility, or explicit break with rationale).
|
||||
|
||||
---
|
||||
|
||||
## Why we are wary of irreversible UI churn
|
||||
|
||||
Psysonic is a desktop app people use for hours: muscle memory, layout, themes, keyboard workflows, and accessibility settings all matter. Abrupt changes to navigation, information hierarchy, or visual language without a migration path:
|
||||
|
||||
- break **habits and power-user flows**;
|
||||
- complicate **themes and accessibility** (contrast, sizing, custom fonts);
|
||||
- increase support load and frustration — some users stay on old builds or fork.
|
||||
|
||||
We prefer **evolutionary** UI work: discuss large shifts early, ship in steps where possible, use settings or toggles when a breaking visual change is justified, and preserve predictability where users did not ask for an experiment. That is not a ban on fresh design — it is a preference to **not strand users** without a strong reason and a clear adaptation path.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Notice
|
||||
|
||||
Psysonic is licensed under the GNU GPLv3.
|
||||
|
||||
Psysonic, including features such as **Orbit** (synchronized shared
|
||||
listening sessions), was originally designed and implemented by the
|
||||
Psysonic project maintainers and contributors.
|
||||
|
||||
Forks and modified versions are welcome under the GPLv3, but they
|
||||
must preserve copyright notices, license information, and clear
|
||||
attribution to the original Psysonic project.
|
||||
|
||||
Modified versions must not imply that original Psysonic features were
|
||||
independently created by the fork, nor imply endorsement by the
|
||||
Psysonic project.
|
||||
|
||||
## Required Attribution for Derivative Works
|
||||
|
||||
The following attribution requirements apply to all derivative works
|
||||
under the additional terms permitted by **GPLv3 §7(b)**. Forks and
|
||||
redistributions must preserve these notices intact:
|
||||
|
||||
1. The unmodified contents of this `NOTICE.md` file must be retained
|
||||
in the source tree of any fork or derivative work.
|
||||
2. Derivative works that include or are based on the **Orbit**
|
||||
feature — its design, protocol, or implementation — must clearly
|
||||
state in their own documentation and release notes that the Orbit
|
||||
feature was originally designed and implemented in Psysonic, and
|
||||
must not present Orbit's design or implementation as independent
|
||||
original work of the fork.
|
||||
3. The user-facing name "Orbit" is a Psysonic feature mark and may
|
||||
not be reused by forks; see `TRADEMARK.md`. The attribution
|
||||
requirement in (2) applies regardless of what the feature is
|
||||
renamed to in a fork.
|
||||
4. The same applies to any other original Psysonic feature that a
|
||||
fork chooses to retain.
|
||||
|
||||
These terms do not restrict the freedoms granted by the GPLv3; they
|
||||
are attribution-preservation terms explicitly permitted by GPLv3
|
||||
§7(b) and must be carried forward in further redistribution.
|
||||
+6
-12
@@ -9,19 +9,13 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
|
||||
### 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.
|
||||
|
||||
### Music Network (scrobble & enrichment services)
|
||||
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
|
||||
|
||||
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
|
||||
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
|
||||
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
|
||||
|
||||
To each connected service, Psysonic may send:
|
||||
### 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), where the service supports it
|
||||
- **Love / Unlove** — when you mark a track as loved, on services that support it
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
|
||||
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
|
||||
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.
|
||||
@@ -43,7 +37,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
|
||||
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)
|
||||
- Scrobble service credentials (session keys / API tokens)
|
||||
- Last.fm session key
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
<div align="center">
|
||||
# Psysonic
|
||||
|
||||
<div align="center">
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||
|
||||
## A modern desktop client for self-hosted music libraries
|
||||
## The Ultimate Desktop Client for Self-Hosted Music Libraries
|
||||
|
||||
**Fast. Native. Beautiful. Built for people who actually care about their music collection.**
|
||||
|
||||
Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers, depending on the features supported by your server.
|
||||
**Fast. Beautiful. Native. Feature-packed.**
|
||||
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a>
|
||||
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
|
||||
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
**No telemetry • Native performance • Navidrome-first • Community driven**
|
||||
**No telemetry • Native performance • Massive feature set • Community driven**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,101 +24,96 @@ More translations are added over time.
|
||||
|
||||
---
|
||||
|
||||
## What is Psysonic?
|
||||
> [!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.
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
## Server Compatibility
|
||||
|
||||
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
|
||||
**Psysonic is optimized first and foremost for Navidrome.**
|
||||
|
||||
Psysonic is **optimized first and foremost for Navidrome**. Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
|
||||
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
|
||||
|
||||
## Why Psysonic?
|
||||
|
||||
Most Subsonic clients feel like web wrappers.
|
||||
|
||||
**Psysonic does not.**
|
||||
|
||||
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
|
||||
|
||||
If you host your own music, this is what the premium experience should feel like.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
# Core Features
|
||||
|
||||
## Playback & Queue
|
||||
## Playback Engine
|
||||
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* LUFS-based Smart Loudness Normalization
|
||||
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
|
||||
* Smart Loudness Normalization
|
||||
* Infinite Queue
|
||||
* Smart Radio sessions
|
||||
* Fast and responsive playback handling
|
||||
* Low memory usage compared to heavy web-first clients
|
||||
* High responsiveness with low memory usage
|
||||
|
||||
## Audio Tools
|
||||
|
||||
* 10-band Equalizer
|
||||
* Equalizer presets
|
||||
* Presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device optimization
|
||||
* Loudness-aware playback options
|
||||
|
||||
## Library Management
|
||||
## Library Power
|
||||
|
||||
* Fast search across large libraries
|
||||
* Albums, artists, tracks and genres
|
||||
* Ratings support
|
||||
* Lightning-fast search
|
||||
* Albums / Artists / Tracks / Genres
|
||||
* Ratings system
|
||||
* Multi-select bulk actions
|
||||
* Drag & drop playlist management
|
||||
* Smart Playlists
|
||||
* Built for large self-hosted collections
|
||||
* Huge library friendly
|
||||
|
||||
## Lyrics & Discovery
|
||||
|
||||
* Synced lyrics with seek support
|
||||
* Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
|
||||
* Auto-scrolling sidebar lyrics
|
||||
* Auto-scroll sidebar lyrics
|
||||
* Fullscreen lyric mode
|
||||
* Last.fm scrobbling
|
||||
* Similar artists
|
||||
* Loved tracks and listening stats
|
||||
* Similar artists / love tracks / stats
|
||||
|
||||
## Sharing & Social Listening
|
||||
## Personalization
|
||||
|
||||
* Magic Strings sharing:
|
||||
|
||||
* share albums, artists and queues
|
||||
* Navidrome user management helpers
|
||||
* fast account sharing
|
||||
* Orbit shared listening sessions:
|
||||
|
||||
* host-controlled synchronized playback
|
||||
* session invites via link
|
||||
* guest song suggestions
|
||||
* real-time queue interaction
|
||||
|
||||
## Personalization & Accessibility
|
||||
|
||||
* Large theme collection
|
||||
* Catppuccin and Nord inspired styles
|
||||
* Huge theme collection
|
||||
* Catppuccin / Nord inspired styles
|
||||
* Glassmorphism effects
|
||||
* Font customization
|
||||
* Zoom controls
|
||||
* Keybind remapping
|
||||
* Theme Scheduler for automatic day/night switching
|
||||
* Colorblind-friendly theme options
|
||||
* Keyboard-friendly navigation
|
||||
* Theme Scheduler (day/night auto switch)
|
||||
|
||||
## Power User Extras
|
||||
|
||||
* CLI controls
|
||||
* USB / portable sync
|
||||
* Backup and restore settings
|
||||
* Backup & restore settings
|
||||
* In-app auto updater
|
||||
* LAN / remote auto switching
|
||||
|
||||
---
|
||||
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
|
||||
</div>
|
||||
# Orbit (Upcoming)
|
||||
|
||||
Orbit brings synchronized shared listening sessions directly into Psysonic.
|
||||
## Listen Together. In Sync. Soon.
|
||||
|
||||
Start a session, invite others with a link and listen together with host-controlled playback, shared queue interaction and guest song suggestions. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
|
||||
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
|
||||
|
||||
* Host-controlled playback
|
||||
* Join via link
|
||||
* Shared listening sessions
|
||||
* Guest song suggestions
|
||||
* Real-time queue interaction
|
||||
|
||||
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
|
||||
|
||||
---
|
||||
|
||||
@@ -134,10 +121,12 @@ Start a session, invite others with a link and listen together with host-control
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer / WinGet |
|
||||
| Windows | Native Installer *(certificate pending)* |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
Supports **8 languages** and growing.
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
@@ -148,31 +137,20 @@ Start a session, invite others with a link and listen together with host-control
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
or,
|
||||
install via Windows Package Manager (WinGet):
|
||||
```powershell
|
||||
winget install Psysonic
|
||||
```
|
||||
Download the latest installer from Releases.
|
||||
|
||||
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
|
||||
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||
|
||||
## macOS
|
||||
|
||||
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
Download the signed DMG from Releases.
|
||||
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
Contributor expectations (PRs, CI, Tauri boundary, UI): [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
@@ -188,58 +166,31 @@ npm run tauri:build
|
||||
|
||||
---
|
||||
|
||||
# Privacy
|
||||
|
||||
Psysonic is built for self-hosted music collections. Your library is yours.
|
||||
# Privacy First
|
||||
|
||||
* No telemetry
|
||||
* No spyware nonsense
|
||||
* No analytics harvesting
|
||||
* No hidden tracking
|
||||
|
||||
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
|
||||
* Your library stays yours
|
||||
|
||||
---
|
||||
|
||||
# Reviews
|
||||
# Community
|
||||
|
||||
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
|
||||
|
||||
---
|
||||
|
||||
# Community & Support
|
||||
|
||||
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
|
||||
|
||||
* [Discord](https://discord.gg/AMnDRErm4u)
|
||||
* [Telegram](https://t.me/+GLBx1_xeH28xYTJi)
|
||||
* [GitHub Issues](https://github.com/Psychotoxical/psysonic/issues)
|
||||
* [Support Psysonic on Ko-fi](https://ko-fi.com/psychotoxic)
|
||||
Join Discord, report bugs, suggest features, share themes, shape the future.
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
Psysonic is licensed under the **GNU GPL v3.0**.
|
||||
|
||||
---
|
||||
|
||||
## Forks and Attribution
|
||||
|
||||
Psysonic is free and open-source software under the GPLv3. You are welcome to fork it, modify it and build upon it under the terms of the license.
|
||||
|
||||
If you publish a modified or rebranded version, please make it clear that your project is based on Psysonic and preserve proper attribution to the original project.
|
||||
|
||||
That is not about preventing forks. Forks are part of open source. It is about being honest with users and contributors about where the work comes from.
|
||||
|
||||
Features, design work and implementation ideas developed in Psysonic should not be presented as unrelated original work in downstream projects.
|
||||
GNU GPL v3.0
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Own your music. Enjoy the client too.
|
||||
## Stop using boring music clients.
|
||||
|
||||
**Psysonic brings a modern desktop experience to self-hosted music libraries.**
|
||||
## Use Psysonic.
|
||||
|
||||
</div>
|
||||
|
||||
+1
-7
@@ -21,7 +21,7 @@ Direct push to these branches is not part of normal human workflow. Use PRs and
|
||||
|
||||
## 2) Versioning rules (mandatory)
|
||||
|
||||
Version is authoritative in `package.json` and `package-lock.json`. Promotion workflows run `scripts/sync-tauri-version-from-package.js`, which also aligns `[workspace.package]` in `src-tauri/Cargo.toml`, `tauri.conf.json`, and the `psysonic*` workspace crate version fields in `src-tauri/Cargo.lock` (local `cargo build` alone does not commit that lock metadata).
|
||||
Version is authoritative in `package.json` and `package-lock.json`.
|
||||
|
||||
- `main` version format: `X.Y.Z-dev`
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
@@ -41,15 +41,9 @@ Rules:
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
3. Refresh the bundled Open-Source-Licenses data (maintainer-only, run locally):
|
||||
- Requires `cargo-about` (one-time install: `cargo install cargo-about --features cli`).
|
||||
- Run directly: `node scripts/generate-licenses.mjs` from the repo root.
|
||||
- Inspect the diff on `src/data/licenses.json` (size + entry count delta should be plausible).
|
||||
- Commit on `main` if the file changed. No npm script wrapper exists on purpose — adding one to `package.json` would trigger the `nix-npm-deps-hash-sync` workflow on every push. Contributors consume the committed JSON as-is.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security problems.**
|
||||
|
||||
Report them privately so we can investigate and coordinate a fix before details are public:
|
||||
|
||||
- [Discord](https://discord.gg/AMnDRErm4u) — reach a maintainer directly
|
||||
- [Telegram](https://t.me/+GLBx1_xeH28xYTJi) — same
|
||||
|
||||
Include what you can: affected version, platform (Windows / macOS / Linux), steps to reproduce, and impact if known.
|
||||
|
||||
## What to expect
|
||||
|
||||
- We will acknowledge your report as soon as we can.
|
||||
- We will work with you on verification and timing of any public disclosure.
|
||||
- We do not offer a paid bug-bounty program; credit in the changelog or release notes is given when reporters want it and when it fits the fix.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- **This repository** — Psysonic desktop application source.
|
||||
- **AUR packages** ([`psysonic`](https://aur.archlinux.org/packages/psysonic), [`psysonic-bin`](https://aur.archlinux.org/packages/psysonic-bin)) are maintained separately; packaging issues there should go through the AUR unless they reflect a vulnerability in the upstream app itself.
|
||||
- **Your music server** (Navidrome, Gonic, etc.) is outside this project's scope; report server-side issues to those projects.
|
||||
|
||||
## Secure development
|
||||
|
||||
Pull requests are reviewed on `main`. **Dependency security updates** are tracked via Dependabot (PRs only for reported vulnerabilities and their fix paths—not routine version bumps). For general contribution expectations, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
@@ -1,65 +0,0 @@
|
||||
# Privacy & Telemetry
|
||||
|
||||
Privacy is not an afterthought in Psysonic. It has been part of the project’s foundation from the very beginning.
|
||||
|
||||
Psysonic was built with the clear intention of giving users control over their own music experience without silently observing what they do. We believe that people should be able to use software on their own systems without being monitored by the developers behind it.
|
||||
|
||||
## No Telemetry
|
||||
|
||||
Psysonic does **not** collect telemetry.
|
||||
|
||||
That means:
|
||||
|
||||
- no usage statistics
|
||||
- no background tracking
|
||||
- no hidden analytics
|
||||
- no hardware or system profiling
|
||||
- no automatic crash or behavior reporting
|
||||
- no data harvesting of any kind
|
||||
|
||||
Psysonic itself does not collect, store, sell, analyze, or transmit personal usage data.
|
||||
|
||||
## A Conscious Decision
|
||||
|
||||
We are aware that telemetry can make software development easier.
|
||||
|
||||
Anonymous usage statistics, crash reports, platform information, and diagnostic data can help developers find bugs faster, understand which systems are used most often, and prioritize fixes more efficiently.
|
||||
|
||||
However, we made a conscious decision not to build Psysonic around that model.
|
||||
|
||||
For us, respecting user privacy is more important than collecting additional data for convenience. Modern software already tracks more than enough, often by default and often without users fully understanding what is being collected. Psysonic intentionally takes a different approach.
|
||||
|
||||
## External Services Are Opt-In
|
||||
|
||||
Some Psysonic features can communicate with external services, such as:
|
||||
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
If you enable one of these integrations, data may be transmitted to the respective external service provider as part of how that service works. Psysonic itself does not collect or process that data for its own analytics.
|
||||
|
||||
You decide which integrations you want to use.
|
||||
|
||||
## Community Feedback Instead of Silent Tracking
|
||||
|
||||
Instead of telemetry, Psysonic relies on direct community feedback.
|
||||
|
||||
Bug reports, feature requests, platform issues, and general discussions happen openly through community channels such as Discord and Telegram. Additional contact options, including WhatsApp and Facebook groups, are planned for the future.
|
||||
|
||||
We prefer talking to users directly over silently collecting information in the background.
|
||||
|
||||
This approach may sometimes require more communication, but it also keeps the relationship between the project and its users transparent and respectful.
|
||||
|
||||
## Our Position
|
||||
|
||||
Psysonic is built for people who want to enjoy their own music collection on their own terms.
|
||||
|
||||
No hidden tracking.
|
||||
No telemetry by default.
|
||||
No analytics quietly running in the background.
|
||||
|
||||
This is intentional, and it is not planned to change.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Trademark Notice
|
||||
|
||||
The Psysonic source code is licensed under the GNU GPLv3.
|
||||
The **Psysonic name, logo, wordmark, visual brand identity, and the
|
||||
names of original Psysonic features are not** — they are excluded
|
||||
from the GPLv3 grant and remain the property of the Psysonic project
|
||||
maintainers.
|
||||
|
||||
## Covered Marks
|
||||
|
||||
- The name **"Psysonic"** (in any capitalization or stylization)
|
||||
- The Psysonic logo and icon (`public/psysonic-inapp-logo.svg`,
|
||||
app icons, tray icons, installer artwork)
|
||||
- The Psysonic wordmark and any derivative branding
|
||||
- The names of original Psysonic features that function as feature
|
||||
identity, in particular **"Orbit"**
|
||||
|
||||
## What You May Do
|
||||
|
||||
- Use the Psysonic name to factually refer to the upstream project
|
||||
(e.g. *"based on Psysonic"*, *"a fork of Psysonic"*, *"compatible
|
||||
with Psysonic"*).
|
||||
- Redistribute unmodified Psysonic builds under the Psysonic name,
|
||||
in accordance with the GPLv3.
|
||||
- Link to the official Psysonic repository, website, and community
|
||||
channels.
|
||||
|
||||
## What You May Not Do
|
||||
|
||||
- Use the Psysonic name, logo, or branding for a **modified or
|
||||
rebranded fork**, distribution, or derivative product.
|
||||
- Name a fork in a way that suggests it *is* Psysonic, an official
|
||||
release of Psysonic, or endorsed by the Psysonic project (e.g.
|
||||
"Psysonic Pro", "Psysonic Plus", "Psysonic Next", "MyPsysonic").
|
||||
- Reuse the Psysonic logo, icon set, or installer artwork in a fork
|
||||
or derivative product, even with modifications.
|
||||
- Continue to use the feature name **"Orbit"** in a fork or
|
||||
derivative. If your fork ships the Orbit feature (or code derived
|
||||
from it), you must rename the user-facing feature.
|
||||
- Present original Psysonic features (such as Orbit) as independent
|
||||
creations of a fork. See `NOTICE.md` for the attribution
|
||||
requirements that apply to derivative works under GPLv3 §7(b).
|
||||
- Imply sponsorship, endorsement, or affiliation with the Psysonic
|
||||
project where none exists.
|
||||
|
||||
## Forking Done Right
|
||||
|
||||
You are warmly invited to fork the code under the GPLv3.
|
||||
If you publish a fork or derivative, please:
|
||||
|
||||
1. Choose your own distinct project name and branding.
|
||||
2. Keep the GPLv3 license, copyright notices, and the `NOTICE.md`
|
||||
file intact (this is required, not optional — see `NOTICE.md`).
|
||||
3. State clearly that your project is *based on Psysonic* and link
|
||||
back to the upstream repository.
|
||||
4. Replace Psysonic-branded assets (logos, icons, installer art,
|
||||
wordmarks) with your own.
|
||||
5. If you ship features that originated in Psysonic (such as Orbit),
|
||||
rename the user-facing feature and credit Psysonic as the
|
||||
original designer and implementer.
|
||||
|
||||
## Questions
|
||||
|
||||
If you are unsure whether a planned use of the Psysonic name or
|
||||
branding is acceptable, please open an issue or contact the
|
||||
maintainers before publishing.
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
# What's New
|
||||
|
||||
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
|
||||
current line before promoting to `next` / `release`. Technical details and PR credits stay in
|
||||
`CHANGELOG.md`.
|
||||
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Japanese — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Artist artwork — richer artist and fullscreen views
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
|
||||
### Windows
|
||||
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Offline listening
|
||||
|
||||
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
|
||||
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
|
||||
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
|
||||
|
||||
### Music Network — scrobble beyond Last.fm
|
||||
|
||||
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
|
||||
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
|
||||
- A master switch turns the whole network on or off.
|
||||
|
||||
### Theme Store
|
||||
|
||||
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
|
||||
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
|
||||
- **Now Playing** follows every theme cleanly, including light palettes.
|
||||
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
|
||||
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
|
||||
|
||||
### Fullscreen player
|
||||
|
||||
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
|
||||
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
|
||||
|
||||
### Live — richer now playing on Navidrome 0.62+
|
||||
|
||||
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
|
||||
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
|
||||
|
||||
### Queue — Timeline mode
|
||||
|
||||
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
|
||||
|
||||
### Settings → Servers
|
||||
|
||||
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
|
||||
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
|
||||
|
||||
### Sidebar — pin Now Playing to the top
|
||||
|
||||
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
|
||||
|
||||
### Startup
|
||||
|
||||
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
|
||||
|
||||
## Improved
|
||||
|
||||
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
|
||||
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
|
||||
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
|
||||
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
|
||||
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
|
||||
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
|
||||
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
|
||||
- Mixed-server queues still report to the correct Navidrome server.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
|
||||
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
|
||||
- Discord Rich Presence shows album art again when covers come from the server.
|
||||
- Focus rings and dropdown borders follow the active theme consistently.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
|
||||
- **All Albums → Only compilations** returns results for common tagging patterns.
|
||||
- Album grids show the album artist on compilations instead of a random track artist.
|
||||
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
|
||||
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
|
||||
- **Home → Most Played** no longer jumps the page when you load more albums.
|
||||
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
|
||||
|
||||
### Other
|
||||
|
||||
- **Linux:** the `curl | bash` auto-installer works again.
|
||||
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
|
||||
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
|
||||
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
|
||||
- The active server card under **Settings → Servers** draws a complete border on all sides.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"lastModified": 1777578337,
|
||||
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
Psysonic for NixOS / nixpkgs: installable app + dev shell.
|
||||
|
||||
Packages:
|
||||
nix build .#psysonic # or .#default — desktop app; GDK follows session (no wrapper pin)
|
||||
nix build .#psysonic-gdk-session # same derivation (back-compat alias); see nixos-install.md
|
||||
nix build .#psysonic-x11-legacy # legacy: GDK_BACKEND=x11 wrapper (old default)
|
||||
nix build .#psysonic # or .#default — desktop app (.desktop + icon); GDK_BACKEND=x11 (default, fewer WebKit surprises)
|
||||
nix build .#psysonic-gdk-session # same app, no forced GDK x11 — optional; can misbehave on some stacks (see nixos-install.md)
|
||||
nix profile install .#psysonic
|
||||
|
||||
Run (after build, or from any clone with flake):
|
||||
nix run .#psysonic
|
||||
nix run .#psysonic-gdk-session # identical to psysonic
|
||||
nix run .#psysonic-x11-legacy # GDK x11 pinned (former default wrap)
|
||||
nix run .#psysonic-gdk-session
|
||||
nix run github:Psychotoxical/psysonic
|
||||
|
||||
Development:
|
||||
nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin)
|
||||
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
|
||||
nix shell .#devShells.default # same environment without entering subshell semantics
|
||||
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
|
||||
|
||||
@@ -57,10 +55,6 @@
|
||||
nodejs_22
|
||||
rustc
|
||||
cargo
|
||||
clippy
|
||||
cargo-llvm-cov
|
||||
llvmPackages.llvm
|
||||
jq
|
||||
cmake
|
||||
pkg-config
|
||||
openssl
|
||||
@@ -87,8 +81,9 @@
|
||||
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 LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov"
|
||||
export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata"
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
unset CI
|
||||
'';
|
||||
|
||||
@@ -105,38 +100,28 @@
|
||||
inherit upstreamMeta;
|
||||
};
|
||||
|
||||
# Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md).
|
||||
psysonicX11LegacyFor =
|
||||
psysonicGdkSessionFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
forceGdkX11 = true;
|
||||
forceGdkX11 = false;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
devShells = forSystem (system: { default = mkShellFor system; });
|
||||
|
||||
packages = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
in
|
||||
{
|
||||
psysonic = p;
|
||||
psysonic-gdk-session = p;
|
||||
psysonic-x11-legacy = pX11;
|
||||
default = p;
|
||||
}
|
||||
);
|
||||
packages = forSystem (system: {
|
||||
psysonic = psysonicFor system;
|
||||
psysonic-gdk-session = psysonicGdkSessionFor system;
|
||||
default = psysonicFor system;
|
||||
});
|
||||
|
||||
apps = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
pGdk = psysonicGdkSessionFor system;
|
||||
in
|
||||
{
|
||||
default = {
|
||||
@@ -149,17 +134,9 @@
|
||||
};
|
||||
psysonic-gdk-session = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
program = lib.getExe pGdk;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
psysonic-x11-legacy = {
|
||||
type = "app";
|
||||
program = lib.getExe pX11;
|
||||
meta = {
|
||||
inherit (pX11.meta) description homepage license;
|
||||
inherit (pGdk.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
|
||||
-82
@@ -6,91 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-6
@@ -35,9 +35,9 @@
|
||||
gst_all_1,
|
||||
src,
|
||||
upstreamMeta,
|
||||
# When true, wrapProgram sets GDK_BACKEND=x11 (legacy conservative stack).
|
||||
# When false (default), only lib paths — GDK follows session; binary applies webkit2gtk-nvidia-quirk only.
|
||||
forceGdkX11 ? false,
|
||||
# When true (default), wrapProgram sets GDK_BACKEND=x11 for WebKit stability on many setups.
|
||||
# When false, GDK follows the session (e.g. native Wayland) — often better HiDPI sizing.
|
||||
forceGdkX11 ? true,
|
||||
}:
|
||||
|
||||
let
|
||||
@@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle)
|
||||
(cd src-tauri && cargo tauri build --no-bundle -v)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
@@ -163,7 +163,10 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
postFixup =
|
||||
let
|
||||
gdkX11Wrap = lib.optionalString forceGdkX11 ''
|
||||
--set GDK_BACKEND x11
|
||||
--set GDK_BACKEND x11 \
|
||||
'';
|
||||
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
|
||||
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
|
||||
'';
|
||||
in
|
||||
''
|
||||
@@ -171,7 +174,8 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
|
||||
${gdkX11Wrap}
|
||||
${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
|
||||
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
|
||||
'';
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
|
||||
"npmDepsHash": "sha256-WXfc5WsypV2HdUTaZLDzPN9OdgLq18/hcJVvKCn3LTU="
|
||||
}
|
||||
|
||||
+10
-10
@@ -85,27 +85,25 @@ environment.systemPackages = with pkgs; [
|
||||
];
|
||||
```
|
||||
|
||||
### Linux wrapper (default vs legacy X11)
|
||||
### Linux wrapper: default vs gdk-session
|
||||
|
||||
The flake exposes **three** Linux attributes (two are the **same derivation**):
|
||||
The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs:
|
||||
|
||||
| Flake attribute | Wrapper behaviour |
|
||||
|----------------|-------------------|
|
||||
| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. |
|
||||
| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. |
|
||||
| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. |
|
||||
| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. |
|
||||
|
||||
`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
|
||||
|
||||
### Example: legacy X11 wrap
|
||||
Use the alternate package when you understand that trade-off:
|
||||
|
||||
```nix
|
||||
inputs.psysonic.packages.${system}.psysonic-x11-legacy
|
||||
inputs.psysonic.packages.${system}.psysonic-gdk-session
|
||||
```
|
||||
|
||||
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help
|
||||
```
|
||||
|
||||
### Pinning a revision, branch, or tag
|
||||
@@ -144,7 +142,7 @@ From any machine with flakes:
|
||||
nix run 'github:Psychotoxical/psysonic'
|
||||
```
|
||||
|
||||
Same as `nix build` / `packages.<system>.default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
Same as `nix build` / `packages.<system>.default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
@@ -181,6 +179,8 @@ From a **flake-enabled** clone of the repo:
|
||||
|
||||
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
|
||||
|
||||
Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`).
|
||||
|
||||
## 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.
|
||||
|
||||
Generated
+1137
-1895
File diff suppressed because it is too large
Load Diff
+27
-41
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.49.0-dev",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
|
||||
"dev": "npm run prebuild:release-notes && vite",
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
|
||||
"tauri:build": "npm run prebuild:release-notes && tauri build",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
@@ -30,47 +26,37 @@
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@fontsource/opendyslexic": "^5.2.5",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@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",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.16.0",
|
||||
"i18next": "^26.0.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"zustand": "^5.0.14"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/md5": "^2.3.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.28.0"
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.48.1
|
||||
pkgver=1.44.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -53,9 +53,12 @@ package() {
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper: thin exec (path hygiene only); GDK/session + WebKit mitigations come from main.rs / quirk (no GDK pin).
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
})();
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Walk the same root CSS entrypoints as src/main.tsx and ensure every
|
||||
* relative @import target exists on disk. Catches postcss-import ENOENT
|
||||
* that Vitest does not surface (CSS is not fully resolved like vite build).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const stylesRoot = path.join(repoRoot, 'src', 'styles');
|
||||
|
||||
const ENTRY_RELS = [
|
||||
'themes/index.css',
|
||||
'layout/index.css',
|
||||
'components/index.css',
|
||||
'tracks/index.css',
|
||||
];
|
||||
|
||||
/** Quoted path in @import '...' or "..." (postcss-import style in this repo). */
|
||||
const IMPORT_RE = /@import\s+(['"])([^'"]+)\1/g;
|
||||
|
||||
function isFilesystemImport(spec) {
|
||||
if (spec.startsWith('http://') || spec.startsWith('https://') || spec.startsWith('data:')) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith('@')) return false;
|
||||
return spec.startsWith('./') || spec.startsWith('../');
|
||||
}
|
||||
|
||||
function walk(absPath, visited, missing) {
|
||||
const key = path.resolve(absPath);
|
||||
if (visited.has(key)) return;
|
||||
if (!fs.existsSync(key)) {
|
||||
missing.add(path.relative(repoRoot, key));
|
||||
visited.add(key);
|
||||
return;
|
||||
}
|
||||
visited.add(key);
|
||||
const text = fs.readFileSync(key, 'utf8');
|
||||
let m;
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
while ((m = IMPORT_RE.exec(text)) !== null) {
|
||||
const spec = m[2];
|
||||
if (!isFilesystemImport(spec)) continue;
|
||||
const next = path.resolve(path.dirname(key), spec);
|
||||
walk(next, visited, missing);
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const missing = new Set();
|
||||
for (const rel of ENTRY_RELS) {
|
||||
walk(path.join(stylesRoot, rel), visited, missing);
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
const list = [...missing].sort().join('\n ');
|
||||
console.error(`check-css-import-graph: missing @import target(s):\n ${list}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate — frontend.
|
||||
#
|
||||
# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For
|
||||
# each source file listed in `.github/frontend-hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning
|
||||
# annotations for files below the floor and exits 1 when any file is below.
|
||||
#
|
||||
# Why files instead of per-function: v8 coverage's per-function data is
|
||||
# fragile under React Compiler / Vite minification — file-level line
|
||||
# coverage tracks the underlying intent ("is the hot-path file thoroughly
|
||||
# tested?") more robustly.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-frontend-hot-path-coverage.sh [<summary.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# summary.json — coverage/coverage-summary.json
|
||||
# hot-path-list.txt — .github/frontend-hot-path-files.txt
|
||||
#
|
||||
# Requires: jq.
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-coverage/coverage-summary.json}"
|
||||
HOT_PATH_LIST="${2:-.github/frontend-hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage summary not found at $JSON. Did you run 'npm run test:coverage' first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The v8 / Istanbul coverage-summary.json has the form:
|
||||
# { "total": {...}, "<absolute-path>": { "lines": { "pct": 87.5 }, ... }, ... }
|
||||
# We want each file path + its line pct as a TSV keyed by basename suffix.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r 'to_entries[] | select(.key != "total") | [.key, .value.lines.pct] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check, frontend (threshold: ≥${THRESHOLD}%) ──"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — JSON paths are absolute, hot-path list is workspace-relative.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed? or no test imports it yet)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate.
|
||||
#
|
||||
# For each source file listed in `.github/hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions
|
||||
# warning annotations for files below the floor and exits 1 when any
|
||||
# file is below.
|
||||
#
|
||||
# Why files instead of per-function: cargo-llvm-cov's per-function
|
||||
# region data is unreliable for async state-machines (most regions live
|
||||
# in synthetic closures) and generic functions (every instantiation is
|
||||
# a separate symbol). File-level line coverage is robustly measured and
|
||||
# tracks the underlying intent: "is the hot-path file thoroughly tested?".
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-hot-path-coverage.sh [<coverage.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# coverage.json — src-tauri/target/llvm-cov/cov.json
|
||||
# hot-path-list.txt — .github/hot-path-files.txt
|
||||
#
|
||||
# Requires: jq (preinstalled on Ubuntu runners; on Windows install via
|
||||
# `winget install jqlang.jq` or `choco install jq`).
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-src-tauri/target/llvm-cov/cov.json}"
|
||||
HOT_PATH_LIST="${2:-.github/hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage JSON not found at $JSON. Did you run cargo llvm-cov --workspace --json --output-path \"$JSON\" first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Pre-extract every file's line coverage % into a TSV keyed by basename.
|
||||
# We match by suffix (file path ends with the listed relative path) because
|
||||
# the JSON stores absolute paths that vary between Windows runners and Linux.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r '.data[0].files[] | [.filename, .summary.lines.percent] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check (threshold: ≥${THRESHOLD}%) ──────────"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}" # strip trailing comment
|
||||
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — the JSON stores absolute paths; the hot-path list uses
|
||||
# workspace-relative paths. Convert both to forward slashes first so the
|
||||
# endsWith works on Windows-encoded paths too.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed?)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# bash arithmetic doesn't do float. Truncate to int for comparison.
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
|
||||
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
|
||||
*
|
||||
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
|
||||
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
|
||||
|
||||
function versionCore(version) {
|
||||
const m = version.trim().match(SEMVER_CORE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function isPlainTriple(header) {
|
||||
return /^\d+\.\d+\.\d+$/.test(header.trim());
|
||||
}
|
||||
|
||||
function splitBlocks(raw) {
|
||||
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
|
||||
}
|
||||
|
||||
function headerVersion(block) {
|
||||
const m = block.match(/^## \[([^\]]+)\]/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseBlock(block) {
|
||||
const lines = block.split('\n');
|
||||
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
headerVersion: m[1],
|
||||
date: (m[2] ?? '').trim(),
|
||||
body: lines.slice(1).join('\n').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findReleaseSection(raw, appVersion) {
|
||||
const blocks = splitBlocks(raw);
|
||||
|
||||
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
|
||||
if (exact) return parseBlock(exact);
|
||||
|
||||
const appCore = versionCore(appVersion);
|
||||
if (!appCore) return null;
|
||||
|
||||
const candidates = blocks.filter((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && versionCore(hv) === appCore;
|
||||
});
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const plain = candidates.find((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && isPlainTriple(hv);
|
||||
});
|
||||
return parseBlock(plain ?? candidates[0]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const allowEmpty = args.includes('--allow-empty');
|
||||
const positional = args.filter((a) => a !== '--allow-empty');
|
||||
const [file, version] = positional;
|
||||
|
||||
if (!file || !version) {
|
||||
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const entry = findReleaseSection(raw, version);
|
||||
const body = entry?.body?.trim() ?? '';
|
||||
|
||||
if (!body) {
|
||||
if (allowEmpty) process.exit(0);
|
||||
console.error(`No release section found in ${file} for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) main();
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const FIXTURE = `
|
||||
## [1.48.0] - 2026-06-10
|
||||
|
||||
## Highlights
|
||||
- One
|
||||
|
||||
## [1.47.0]
|
||||
- Old
|
||||
`;
|
||||
|
||||
describe('findReleaseSection', () => {
|
||||
it('matches base line for -rc versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
assert.match(entry.body, /Highlights/);
|
||||
});
|
||||
|
||||
it('matches base line for -dev versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
});
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
|
||||
//
|
||||
// Maintainer-only operation: run before each release to refresh the bundled
|
||||
// dependency list. Requires `cargo-about` installed globally
|
||||
// (`cargo install cargo-about --features cli`). The npm-side license data is
|
||||
// read via npx without persisting a devDependency, so package.json stays clean
|
||||
// and the nix-npm-deps-hash-sync workflow is not triggered.
|
||||
//
|
||||
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
|
||||
// — no runtime fetch, the data is fixed into the build artifact.
|
||||
//
|
||||
// Output format (per entry):
|
||||
// {
|
||||
// name: string,
|
||||
// version: string,
|
||||
// source: 'npm' | 'cargo',
|
||||
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
|
||||
// repository?: string,
|
||||
// homepage?: string,
|
||||
// description?: string,
|
||||
// publisher?: string,
|
||||
// licenseText?: string, // full license text, may be empty if not found
|
||||
// }
|
||||
//
|
||||
// Usage: node scripts/generate-licenses.mjs
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
|
||||
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
|
||||
const OUT_PATH = join(OUT_DIR, 'licenses.json');
|
||||
|
||||
function die(msg) {
|
||||
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`\x1b[36m→\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
// ── Cargo side via cargo-about ──────────────────────────────────────────────
|
||||
function checkCargoAbout() {
|
||||
try {
|
||||
execSync('cargo about --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
die(
|
||||
'cargo-about not found. Install with:\n' +
|
||||
' cargo install cargo-about --features cli',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCargoLicenses() {
|
||||
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'about',
|
||||
'generate',
|
||||
'about.hbs',
|
||||
'--config', 'about.toml',
|
||||
'--manifest-path', 'Cargo.toml',
|
||||
'--all-features',
|
||||
],
|
||||
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('cargo-about failed.');
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
|
||||
|
||||
// Flatten: pivot from license-grouped to crate-grouped.
|
||||
const byCrate = new Map();
|
||||
for (const lic of parsed.licenses) {
|
||||
for (const u of lic.used_by ?? []) {
|
||||
const key = `${u.name}@${u.version}`;
|
||||
if (!byCrate.has(key)) {
|
||||
byCrate.set(key, {
|
||||
name: u.name,
|
||||
version: u.version,
|
||||
source: 'cargo',
|
||||
licenses: [],
|
||||
repository: u.repository || undefined,
|
||||
description: u.description || undefined,
|
||||
licenseText: '',
|
||||
});
|
||||
}
|
||||
const entry = byCrate.get(key);
|
||||
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
|
||||
// Append text — a crate may have multiple licenses (dual). Keep them all,
|
||||
// separated by a clear divider.
|
||||
if (lic.text) {
|
||||
if (entry.licenseText) {
|
||||
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
|
||||
} else {
|
||||
entry.licenseText = lic.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`cargo: ${byCrate.size} crates`);
|
||||
return [...byCrate.values()];
|
||||
}
|
||||
|
||||
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
|
||||
function readLicenseFile(path) {
|
||||
if (!path || !existsSync(path)) return '';
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a notice/copyright file alongside the license file.
|
||||
function findExtraLicenseTexts(packagePath) {
|
||||
if (!packagePath || !existsSync(packagePath)) return '';
|
||||
let extra = '';
|
||||
try {
|
||||
const entries = readdirSync(packagePath);
|
||||
for (const name of entries) {
|
||||
const upper = name.toUpperCase();
|
||||
if (
|
||||
upper === 'NOTICE' ||
|
||||
upper === 'NOTICE.MD' ||
|
||||
upper === 'NOTICE.TXT' ||
|
||||
upper === 'COPYRIGHT' ||
|
||||
upper === 'COPYRIGHT.MD' ||
|
||||
upper === 'COPYRIGHT.TXT'
|
||||
) {
|
||||
const full = join(packagePath, name);
|
||||
if (statSync(full).isFile()) {
|
||||
const txt = readFileSync(full, 'utf-8').trim();
|
||||
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function generateNpmLicenses() {
|
||||
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'license-checker-rseidelsohn@latest',
|
||||
'--json',
|
||||
'--production',
|
||||
'--excludePrivatePackages',
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('license-checker-rseidelsohn failed.');
|
||||
}
|
||||
const data = JSON.parse(result.stdout);
|
||||
const out = [];
|
||||
for (const [key, info] of Object.entries(data)) {
|
||||
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
|
||||
const at = key.lastIndexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const name = key.slice(0, at);
|
||||
const version = key.slice(at + 1);
|
||||
const licenses = Array.isArray(info.licenses)
|
||||
? info.licenses
|
||||
: typeof info.licenses === 'string'
|
||||
? [info.licenses]
|
||||
: [];
|
||||
const text =
|
||||
readLicenseFile(info.licenseFile) +
|
||||
findExtraLicenseTexts(info.path);
|
||||
out.push({
|
||||
name,
|
||||
version,
|
||||
source: 'npm',
|
||||
licenses: licenses.map(String),
|
||||
repository: info.repository || undefined,
|
||||
homepage: info.url || undefined,
|
||||
publisher: info.publisher || undefined,
|
||||
description: undefined, // license-checker doesn't surface descriptions
|
||||
licenseText: text.trim(),
|
||||
});
|
||||
}
|
||||
ok(`npm: ${out.length} packages`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Self ────────────────────────────────────────────────────────────────────
|
||||
function readSelfPackageJson() {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
||||
return {
|
||||
name: pkg.name ?? 'psysonic',
|
||||
version: pkg.version ?? '0.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
function main() {
|
||||
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
checkCargoAbout();
|
||||
const cargoEntries = generateCargoLicenses();
|
||||
const npmEntries = generateNpmLicenses();
|
||||
|
||||
const entries = [...cargoEntries, ...npmEntries];
|
||||
// Stable sort: name, then version. Case-insensitive on name.
|
||||
entries.sort((a, b) => {
|
||||
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
if (n !== 0) return n;
|
||||
return a.version.localeCompare(b.version);
|
||||
});
|
||||
|
||||
const self = readSelfPackageJson();
|
||||
const stats = {
|
||||
npm: npmEntries.length,
|
||||
cargo: cargoEntries.length,
|
||||
total: entries.length,
|
||||
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
project: self,
|
||||
stats,
|
||||
entries,
|
||||
};
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
|
||||
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
|
||||
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
|
||||
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/generated/releaseNotesBundle.ts for production bundles.
|
||||
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
|
||||
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
const version = pkg.version;
|
||||
|
||||
const whatsNewPath = join(root, 'WHATS_NEW.md');
|
||||
const changelogPath = join(root, 'CHANGELOG.md');
|
||||
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
|
||||
const changelogFull = readFileSync(changelogPath, 'utf8');
|
||||
|
||||
function sliceForVersion(full, fileLabel) {
|
||||
const entry = findReleaseSection(full, version);
|
||||
if (!entry?.body) {
|
||||
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
|
||||
return '';
|
||||
}
|
||||
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
|
||||
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
|
||||
}
|
||||
|
||||
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
|
||||
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
|
||||
|
||||
const outDir = join(root, 'src/generated');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
|
||||
|
||||
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
|
||||
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
|
||||
+8
-19
@@ -14,22 +14,20 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Log helpers write to stderr so functions that return values via stdout
|
||||
# (e.g. get_download_url) stay clean when called in command substitution.
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1" >&2
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -107,7 +105,7 @@ install_package() {
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
@@ -116,7 +114,7 @@ install_package() {
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
@@ -130,17 +128,8 @@ install_package() {
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
# Under `curl ... | bash`, stdin is the script stream itself, so
|
||||
# read the answer from the controlling terminal instead. Probe by
|
||||
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
|
||||
# without a controlling terminal; only open() reports the failure.
|
||||
if { : < /dev/tty; } 2>/dev/null; then
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
|
||||
echo
|
||||
else
|
||||
warn "No terminal available for prompt; skipping reinstall."
|
||||
exit 0
|
||||
fi
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Align src-tauri/Cargo.toml, src-tauri/tauri.conf.json, and workspace entries in
|
||||
* src-tauri/Cargo.lock with package.json "version".
|
||||
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
|
||||
* Used after npm version in promote workflows so bundle names match release semver.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
@@ -29,21 +28,3 @@ const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.version = version;
|
||||
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
|
||||
console.log(`tauri.conf.json -> ${version}`);
|
||||
|
||||
/** @param {string} lockText */
|
||||
function syncCargoLockWorkspaceVersions(lockText, targetVersion) {
|
||||
return lockText.replace(
|
||||
/^(name = "psysonic[^"]*"\nversion = ")[^"]+"/gm,
|
||||
`$1${targetVersion}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const lockPath = path.join(root, 'src-tauri', 'Cargo.lock');
|
||||
let lock = fs.readFileSync(lockPath, 'utf8');
|
||||
const updatedLock = syncCargoLockWorkspaceVersions(lock, version);
|
||||
if (updatedLock !== lock) {
|
||||
fs.writeFileSync(lockPath, updatedLock);
|
||||
console.log(`Cargo.lock workspace crates -> ${version}`);
|
||||
} else {
|
||||
console.log(`Cargo.lock workspace crates already at ${version}`);
|
||||
}
|
||||
|
||||
Generated
+999
-1607
File diff suppressed because it is too large
Load Diff
+36
-57
@@ -1,29 +1,13 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.49.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
mockall = "0.13"
|
||||
proptest = "1"
|
||||
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version.workspace = true
|
||||
version = "1.45.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license.workspace = true
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -37,13 +21,7 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "crates/psysonic-core" }
|
||||
psysonic-analysis = { path = "crates/psysonic-analysis" }
|
||||
psysonic-audio = { path = "crates/psysonic-audio" }
|
||||
psysonic-library = { path = "crates/psysonic-library" }
|
||||
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
|
||||
psysonic-integration = { path = "crates/psysonic-integration" }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
@@ -52,61 +30,62 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
|
||||
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", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.8"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
biquad = "0.6"
|
||||
ringbuf = "0.5"
|
||||
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 = "1.1"
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
|
||||
id3 = "1.17"
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
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"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "8"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mach2 = "0.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
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"] }
|
||||
webkit2gtk-nvidia-quirk = "1.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Power",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
|
||||
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
|
||||
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
|
||||
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
|
||||
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
|
||||
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
|
||||
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
|
||||
# - 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" }
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{{!--
|
||||
cargo-about template — emits JSON grouped by license. The Node generator
|
||||
(scripts/generate-licenses.mjs) flattens this into a per-crate list and
|
||||
merges it with the npm license data into the final public/licenses.json.
|
||||
--}}
|
||||
{
|
||||
"licenses": [
|
||||
{{#each licenses}}
|
||||
{
|
||||
"id": "{{id}}",
|
||||
"name": "{{name}}",
|
||||
"text": {{json text}},
|
||||
"used_by": [
|
||||
{{#each used_by}}
|
||||
{
|
||||
"name": "{{crate.name}}",
|
||||
"version": "{{crate.version}}",
|
||||
"repository": {{json crate.repository}},
|
||||
"description": {{json crate.description}}
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# cargo-about config — controls which licenses are allowed in the dependency
|
||||
# tree and how the licenses listing is generated. Permissive list: anything
|
||||
# the Rust ecosystem commonly publishes under, plus a few exceptions.
|
||||
#
|
||||
# Run via `npm run licenses:generate` (which calls `cargo about generate`).
|
||||
|
||||
accepted = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
# we ship binaries for so the Linux user sees Linux-only deps too, etc.
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
# Show transitive deps (we want everything in the binary) but skip dev-deps
|
||||
# (test-only crates aren't in the shipped artifact).
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
workarounds = ["ring"]
|
||||
@@ -22,7 +22,6 @@
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-app-write-recursive",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
@@ -39,7 +38,6 @@
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
ebur128 = "0.1"
|
||||
md5 = "0.8"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Baseline: the pre-versioning analysis cache schema.
|
||||
--
|
||||
-- This is the exact shape every existing user DB already carries (created by
|
||||
-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a
|
||||
-- no-op on those DBs and creates the tables on a fresh one, so "migration 1
|
||||
-- applied" means "the schema that shipped before versioned migrations".
|
||||
--
|
||||
-- Server-scoping (server_id) is added additively in 002.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analysis_track (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waveform_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loudness_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped
|
||||
-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each
|
||||
-- table is rebuilt: create the v2 shape, copy every row with server_id = '',
|
||||
-- drop the old table, rename. Existing rows become legacy ('') rows that the
|
||||
-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2).
|
||||
--
|
||||
-- Atomicity: the migration runner wraps this whole file plus the
|
||||
-- schema_migrations marker in one transaction, so any failure or crash rolls
|
||||
-- everything back to the original tables — DROP never runs unless the copy
|
||||
-- before it succeeded. No BEGIN/COMMIT here (that would nest).
|
||||
--
|
||||
-- These three tables have no foreign keys between them or from any other table,
|
||||
-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op
|
||||
-- inside a transaction anyway).
|
||||
|
||||
-- analysis_track
|
||||
CREATE TABLE analysis_track_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO analysis_track_v2
|
||||
(server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at)
|
||||
SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
|
||||
FROM analysis_track;
|
||||
DROP TABLE analysis_track;
|
||||
ALTER TABLE analysis_track_v2 RENAME TO analysis_track;
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
|
||||
-- waveform_cache
|
||||
CREATE TABLE waveform_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO waveform_cache_v2
|
||||
(server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at)
|
||||
SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
|
||||
FROM waveform_cache;
|
||||
DROP TABLE waveform_cache;
|
||||
ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache;
|
||||
|
||||
-- loudness_cache
|
||||
CREATE TABLE loudness_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
INSERT INTO loudness_cache_v2
|
||||
(server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at)
|
||||
SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
|
||||
FROM loudness_cache;
|
||||
DROP TABLE loudness_cache;
|
||||
ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
mod compute;
|
||||
mod store;
|
||||
|
||||
pub use compute::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
|
||||
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
|
||||
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
|
||||
};
|
||||
pub use store::{
|
||||
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
|
||||
WaveformEntry,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
//! Per-track analysis timing events for the Performance Probe overlay.
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AnalysisSeedTimings {
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisTrackPerfPayload {
|
||||
pub track_id: String,
|
||||
pub fetch_ms: u64,
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
pub fn emit_analysis_track_perf(
|
||||
app: &AppHandle,
|
||||
track_id: &str,
|
||||
fetch_ms: u64,
|
||||
seed_ms: u64,
|
||||
bpm_ms: u64,
|
||||
) {
|
||||
let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms);
|
||||
if total_ms == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"analysis:track-perf",
|
||||
AnalysisTrackPerfPayload {
|
||||
track_id: track_id.to_string(),
|
||||
fetch_ms,
|
||||
seed_ms,
|
||||
bpm_ms,
|
||||
total_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
|
||||
use symphonia::core::codecs::registry::CodecRegistry;
|
||||
|
||||
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_decoder(
|
||||
params: &AudioCodecParameters,
|
||||
opts: &AudioDecoderOptions,
|
||||
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make_audio_decoder(params, opts)
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
//! Tauri commands that read/write the analysis cache and steer the backfill
|
||||
//! queue. Thin wrappers around `analysis_cache::*` and `analysis_runtime::*`
|
||||
//! plus the playback-query port (for "is this track currently playing? /
|
||||
//! is a ranged playback already going to seed it?").
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::analysis_cache;
|
||||
use crate::analysis_runtime::{
|
||||
analysis_backfill_queue_stats, analysis_pipeline_queue_stats, enqueue_seed_from_url,
|
||||
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformCachePayload {
|
||||
pub bins: Vec<u8>,
|
||||
pub bin_count: i64,
|
||||
pub is_partial: bool,
|
||||
pub known_until_sec: f64,
|
||||
pub duration_sec: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
|
||||
fn from(v: analysis_cache::WaveformEntry) -> Self {
|
||||
Self {
|
||||
bins: v.bins,
|
||||
bin_count: v.bin_count,
|
||||
is_partial: v.is_partial,
|
||||
known_until_sec: v.known_until_sec,
|
||||
duration_sec: v.duration_sec,
|
||||
updated_at: v.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoudnessCachePayload {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisDeleteServerReportDto {
|
||||
pub analysis_tracks: u64,
|
||||
pub waveforms: u64,
|
||||
pub loudness: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisFailedTrackDto {
|
||||
pub track_id: String,
|
||||
pub md5_16kb: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
|
||||
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
|
||||
Self {
|
||||
analysis_tracks: value.analysis_tracks,
|
||||
waveforms: value.waveforms,
|
||||
loudness: value.loudness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
|
||||
fn from(value: analysis_cache::FailedTrackEntry) -> Self {
|
||||
Self {
|
||||
track_id: value.track_id,
|
||||
md5_16kb: value.md5_16kb,
|
||||
updated_at: value.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id,
|
||||
/// md5_16kb)` key. Converts the `WaveformEntry` into the JSON-serialisable
|
||||
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can be
|
||||
/// tested with `AnalysisCache::open_in_memory()` and direct upserts.
|
||||
pub fn get_waveform_payload(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
md5_16kb: &str,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let exact = analysis_cache::TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_16kb.to_string(),
|
||||
};
|
||||
Ok(cache
|
||||
.get_waveform(&exact)?
|
||||
.map(WaveformCachePayload::from))
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
|
||||
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
|
||||
pub fn get_waveform_payload_for_track(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
Ok(cache
|
||||
.get_latest_waveform_for_track(server_id, track_id)?
|
||||
.map(WaveformCachePayload::from))
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest loudness row for `(server_id,
|
||||
/// track_id)` and recomputes `recommended_gain_db`
|
||||
/// against the optional requested target (clamped to [-30, -8]). When
|
||||
/// `target_lufs` is `None`, the cached row's own target is used.
|
||||
pub fn get_loudness_payload_for_track(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
target_lufs: Option<f64>,
|
||||
) -> Result<Option<LoudnessCachePayload>, String> {
|
||||
Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| {
|
||||
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
|
||||
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
|
||||
v.integrated_lufs,
|
||||
v.true_peak,
|
||||
requested_target,
|
||||
);
|
||||
LoudnessCachePayload {
|
||||
integrated_lufs: v.integrated_lufs,
|
||||
true_peak: v.true_peak,
|
||||
recommended_gain_db,
|
||||
target_lufs: requested_target,
|
||||
updated_at: v.updated_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_waveform(
|
||||
track_id: String,
|
||||
md5_16kb: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb);
|
||||
if let Ok(ref payload) = result {
|
||||
match payload {
|
||||
Some(v) => crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id, md5_16kb, v.bins.len(), v.bin_count, v.updated_at
|
||||
),
|
||||
None => crate::app_deprintln!(
|
||||
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
|
||||
track_id, md5_16kb
|
||||
),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id);
|
||||
if let Ok(ref payload) = result {
|
||||
match payload {
|
||||
Some(v) => crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id, v.bins.len(), v.bin_count, v.updated_at
|
||||
),
|
||||
None => crate::app_deprintln!("[analysis][waveform] db miss track_id={}", track_id),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_loudness_for_track(
|
||||
track_id: String,
|
||||
target_lufs: Option<f64>,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<LoudnessCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_loudness_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_all_waveforms(
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_all_waveforms()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_all_for_server(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<AnalysisDeleteServerReportDto, String> {
|
||||
if server_id.trim().is_empty() {
|
||||
return Err("server_id required".to_string());
|
||||
}
|
||||
let report = cache.delete_all_for_server(&server_id)?;
|
||||
Ok(report.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_failed_track_count(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<i64, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
cache.count_failed_tracks(&server_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_list_failed_tracks(
|
||||
server_id: String,
|
||||
limit: Option<u32>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Vec<AnalysisFailedTrackDto>, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let limit = limit
|
||||
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
.map(|v| v.clamp(1, 5_000));
|
||||
let rows = cache.list_failed_tracks(&server_id, limit)?;
|
||||
Ok(rows.into_iter().map(AnalysisFailedTrackDto::from).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_clear_failed_tracks(
|
||||
server_id: String,
|
||||
track_ids: Option<Vec<String>>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Err("server_id required".to_string());
|
||||
}
|
||||
let track_ids = track_ids
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
cache.clear_failed_tracks(&server_id, &track_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_migrate_server_index_keys(
|
||||
mappings: Vec<AnalysisServerKeyMigrationDto>,
|
||||
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<(), String> {
|
||||
for mapping in mappings {
|
||||
let _ = (mapping.legacy_id, mapping.index_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
force: Option<bool>,
|
||||
server_id: Option<String>,
|
||||
priority: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref());
|
||||
enqueue_seed_from_url(
|
||||
&app,
|
||||
&track_id,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
explicit,
|
||||
force.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPriorityHintDto {
|
||||
pub server_id: String,
|
||||
pub track_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_playback_priority_hints(
|
||||
middle_track_refs: Vec<AnalysisPriorityHintDto>,
|
||||
hints: tauri::State<'_, PlaybackPriorityHints>,
|
||||
) -> Result<(), String> {
|
||||
let pairs = middle_track_refs
|
||||
.into_iter()
|
||||
.map(|r| (r.server_id, r.track_id));
|
||||
hints.set_middle_track_ids(pairs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisBackfillQueueStatsDto {
|
||||
pub queued: usize,
|
||||
pub in_progress_count: usize,
|
||||
pub in_progress_track_id: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
|
||||
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
|
||||
Ok(analysis_pipeline_queue_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
|
||||
let (queued, in_progress_count, in_progress_track_id) =
|
||||
analysis_backfill_queue_stats();
|
||||
Ok(AnalysisBackfillQueueStatsDto {
|
||||
queued,
|
||||
in_progress_count,
|
||||
in_progress_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPrunePendingResult {
|
||||
pub keep_count: usize,
|
||||
pub http_removed: usize,
|
||||
pub cpu_removed_jobs: usize,
|
||||
pub cpu_removed_waiters: usize,
|
||||
}
|
||||
|
||||
/// Prunes pending analysis work for tracks no longer present in the playback queue.
|
||||
///
|
||||
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
|
||||
#[tauri::command]
|
||||
pub fn analysis_prune_pending_to_track_ids(
|
||||
track_ids: Vec<String>,
|
||||
server_id: String,
|
||||
) -> Result<AnalysisPrunePendingResult, String> {
|
||||
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
|
||||
let mut seen = HashSet::new();
|
||||
for raw in track_ids {
|
||||
let tid = raw.trim();
|
||||
if tid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(tid.to_string()) {
|
||||
normalized.push(tid.to_string());
|
||||
}
|
||||
}
|
||||
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let server_id = server_id.trim().to_string();
|
||||
let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) };
|
||||
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
|
||||
prune_analysis_queues(&keep_track_ids, server_filter)?;
|
||||
|
||||
if http_removed > 0 || cpu_removed_jobs > 0 {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] pruned pending queues keep={} removed_http={} removed_cpu_jobs={} removed_cpu_waiters={}",
|
||||
keep_track_ids.len(),
|
||||
http_removed,
|
||||
cpu_removed_jobs,
|
||||
cpu_removed_waiters
|
||||
);
|
||||
}
|
||||
|
||||
Ok(AnalysisPrunePendingResult {
|
||||
keep_count: keep_track_ids.len(),
|
||||
http_removed,
|
||||
cpu_removed_jobs,
|
||||
cpu_removed_waiters,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::analysis_cache::{
|
||||
AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry,
|
||||
};
|
||||
|
||||
fn key(track_id: &str, md5: &str) -> TrackKey {
|
||||
TrackKey {
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_waveform(cache: &AnalysisCache, track_id: &str, md5: &str, bins: Vec<u8>) {
|
||||
let k = key(track_id, md5);
|
||||
cache.touch_track_status(&k, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(
|
||||
&k,
|
||||
&WaveformEntry {
|
||||
bin_count: (bins.len() / 2) as i64,
|
||||
bins,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 60.0,
|
||||
updated_at: 1_700_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn upsert_loudness(cache: &AnalysisCache, track_id: &str, md5: &str, target_lufs: f64) {
|
||||
let k = key(track_id, md5);
|
||||
cache.touch_track_status(&k, "ready").unwrap();
|
||||
cache
|
||||
.upsert_loudness(
|
||||
&k,
|
||||
&LoudnessEntry {
|
||||
integrated_lufs: -14.0,
|
||||
true_peak: 0.5,
|
||||
recommended_gain_db: 0.0,
|
||||
target_lufs,
|
||||
updated_at: 1_700_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── get_waveform_payload ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_returns_none_for_unknown_key() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let payload = get_waveform_payload(&cache, "server-a", "missing", "deadbeef").unwrap();
|
||||
assert!(payload.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_returns_payload_for_existing_row() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let bins: Vec<u8> = (0..8u8).collect();
|
||||
upsert_waveform(&cache, "abc", "deadbeef", bins.clone());
|
||||
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
|
||||
.unwrap()
|
||||
.expect("payload exists");
|
||||
assert_eq!(payload.bins, bins);
|
||||
assert_eq!(payload.bin_count, 4);
|
||||
assert!(!payload.is_partial);
|
||||
assert_eq!(payload.duration_sec, 60.0);
|
||||
assert_eq!(payload.updated_at, 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_distinguishes_md5_keys() {
|
||||
// Same track_id, different md5_16kb → independent rows.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]);
|
||||
upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]);
|
||||
let p1 = get_waveform_payload(&cache, "server-a", "abc", "aaaa").unwrap().unwrap();
|
||||
let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap();
|
||||
assert_ne!(p1.bins, p2.bins);
|
||||
}
|
||||
|
||||
// ── get_waveform_payload_for_track ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_waveform_for_track_finds_row_under_stream_prefix() {
|
||||
// Insert under `stream:abc`, look up with bare `abc` — id-variant
|
||||
// matching is the whole point of get_latest_waveform_for_track.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]);
|
||||
let payload = get_waveform_payload_for_track(&cache, "server-a", "abc")
|
||||
.unwrap()
|
||||
.expect("bare-id lookup must hit the stream-prefixed row");
|
||||
assert_eq!(payload.bin_count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ── get_loudness_payload_for_track ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_recomputes_gain_against_requested_target() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Cached row: integrated -14, target -14 → gain 0. Request target -10 →
|
||||
// recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard).
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-10.0))
|
||||
.unwrap()
|
||||
.expect("loudness row exists");
|
||||
assert_eq!(payload.target_lufs, -10.0);
|
||||
assert!(
|
||||
payload.recommended_gain_db.is_finite() && payload.recommended_gain_db <= 4.0,
|
||||
"recommended_gain_db must reflect the new target, got {}",
|
||||
payload.recommended_gain_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_uses_cached_target_when_request_is_none() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -16.0);
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(payload.target_lufs, -16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_clamps_target_into_supported_range() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Out-of-range target gets clamped to [-30, -8].
|
||||
let too_high = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(0.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_high.target_lufs, -8.0);
|
||||
let too_low = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-100.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_low.target_lufs, -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_loudness_payload_for_track(&cache, "server-a", "phantom", None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ── WaveformCachePayload::from(WaveformEntry) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn waveform_payload_from_entry_preserves_all_fields() {
|
||||
let entry = WaveformEntry {
|
||||
bins: vec![1, 2, 3, 4],
|
||||
bin_count: 2,
|
||||
is_partial: true,
|
||||
known_until_sec: 5.5,
|
||||
duration_sec: 10.0,
|
||||
updated_at: 42,
|
||||
};
|
||||
let payload = WaveformCachePayload::from(entry);
|
||||
assert_eq!(payload.bins, vec![1, 2, 3, 4]);
|
||||
assert_eq!(payload.bin_count, 2);
|
||||
assert!(payload.is_partial);
|
||||
assert_eq!(payload.known_until_sec, 5.5);
|
||||
assert_eq!(payload.duration_sec, 10.0);
|
||||
assert_eq!(payload.updated_at, 42);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! `psysonic-analysis` — loudness/waveform analysis cache and the runtime
|
||||
//! that drives HTTP backfill + CPU-seed work queues.
|
||||
//!
|
||||
//! Submodules mirror the original layout in the top crate:
|
||||
//! - `analysis_cache` — SQLite-backed loudness/waveform store + compute helpers
|
||||
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
|
||||
|
||||
pub mod analysis_cache;
|
||||
pub mod analysis_perf;
|
||||
pub mod analysis_runtime;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
pub mod track_analysis_plan;
|
||||
pub mod track_enrichment;
|
||||
|
||||
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
|
||||
// the same way they did when they lived in the top crate.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Plan what a track still needs: waveform, LUFS, enrichment (BPM/mood), …
|
||||
//!
|
||||
//! All byte-backed enqueue paths should call [`crate::analysis_runtime::enqueue_track_analysis`],
|
||||
//! which uses this module to decide full CPU seed vs enrichment-only vs no-op.
|
||||
|
||||
use psysonic_core::track_analysis::TrackAnalysisPlan;
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentPort;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::analysis_cache::{AnalysisCache, TrackKey};
|
||||
|
||||
pub fn plan_track_analysis(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
/// Offline/library download: waveform cache and enrichment facts may live under the
|
||||
/// playback index key while library rows use the UUID — try every scope before seeding.
|
||||
pub fn plan_track_analysis_offline_library(
|
||||
app: &AppHandle,
|
||||
cache_server_ids: &[&str],
|
||||
_enrichment_server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) =
|
||||
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
enrichment,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan from the latest cached fingerprint when bytes are not available yet (HTTP backfill gate).
|
||||
pub fn plan_track_analysis_from_cache(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<TrackAnalysisPlan, String> {
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
return Ok(TrackAnalysisPlan {
|
||||
need_waveform: true,
|
||||
need_loudness: true,
|
||||
enrichment: Default::default(),
|
||||
});
|
||||
};
|
||||
let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? else {
|
||||
return Ok(TrackAnalysisPlan {
|
||||
need_waveform: true,
|
||||
need_loudness: true,
|
||||
enrichment: Default::default(),
|
||||
});
|
||||
};
|
||||
Ok(plan_track_analysis(app, server_id, track_id, &md5))
|
||||
}
|
||||
|
||||
pub fn track_analysis_needs_work(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(cache) = app.try_state::<AnalysisCache>() {
|
||||
let latest_status = cache.get_latest_status_for_track(server_id, track_id)?;
|
||||
if latest_status
|
||||
.as_ref()
|
||||
.is_some_and(|(status, _)| status == "failed")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let plan = plan_track_analysis_from_cache(app, server_id, track_id)?;
|
||||
if !plan.any() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Legacy reconciliation: some old rows are persisted as `ready` with
|
||||
// waveform present but no loudness (typically unsupported decode path).
|
||||
// Those tracks spin forever in pending without converging. Promote to
|
||||
// terminal `failed` so scheduler/progress can converge.
|
||||
if latest_status
|
||||
.as_ref()
|
||||
.is_some_and(|(status, _)| status == "ready")
|
||||
&& plan.need_loudness
|
||||
&& !plan.need_waveform
|
||||
{
|
||||
if let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? {
|
||||
let key = TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5,
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
return Ok(plan.any());
|
||||
}
|
||||
Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any())
|
||||
}
|
||||
|
||||
fn cache_gaps(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
cache_gaps_for_content(
|
||||
app.try_state::<AnalysisCache>().as_deref(),
|
||||
server_id,
|
||||
track_id,
|
||||
content_hash,
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_gaps_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let mut need_waveform = true;
|
||||
let mut need_loudness = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
if !nw {
|
||||
need_waveform = false;
|
||||
}
|
||||
if !nl {
|
||||
need_loudness = false;
|
||||
}
|
||||
if !need_waveform && !need_loudness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(need_waveform, need_loudness)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
if server_id.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
app.try_state::<TrackEnrichmentPort>()
|
||||
.map(|port| port.plan(server_id, track_id, content_hash))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enrichment_plan_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
let mut need_bpm = true;
|
||||
let mut need_valence = true;
|
||||
let mut need_arousal = true;
|
||||
let mut need_moods = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let plan = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
if !plan.need_bpm {
|
||||
need_bpm = false;
|
||||
}
|
||||
if !plan.need_valence {
|
||||
need_valence = false;
|
||||
}
|
||||
if !plan.need_arousal {
|
||||
need_arousal = false;
|
||||
}
|
||||
if !plan.need_moods {
|
||||
need_moods = false;
|
||||
}
|
||||
if !need_bpm && !need_valence && !need_arousal && !need_moods {
|
||||
break;
|
||||
}
|
||||
}
|
||||
psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
need_bpm,
|
||||
need_valence,
|
||||
need_arousal,
|
||||
need_moods,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let Some(cache) = cache else {
|
||||
return (true, true);
|
||||
};
|
||||
match cache.content_cache_coverage(server_id, track_id, content_hash) {
|
||||
Ok(coverage) => (!coverage.has_waveform, !coverage.has_loudness),
|
||||
Err(_) => (true, true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::analysis_cache::{LoudnessEntry, TrackKey, WaveformEntry};
|
||||
|
||||
fn seed_waveform_loudness(cache: &AnalysisCache, server_id: &str, track_id: &str, md5: &str) {
|
||||
let key = TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5.to_string(),
|
||||
};
|
||||
cache.touch_track_status(&key, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(
|
||||
&key,
|
||||
&WaveformEntry {
|
||||
bins: vec![0u8; 1000],
|
||||
bin_count: 500,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 0.0,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.upsert_loudness(
|
||||
&key,
|
||||
&LoudnessEntry {
|
||||
integrated_lufs: -14.0,
|
||||
true_peak: 1.0,
|
||||
recommended_gain_db: 0.0,
|
||||
target_lufs: -14.0,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_true_when_empty() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(wf && ld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_false_when_fingerprint_present() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "s1", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(!wf && !ld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_finds_stream_prefix_row_for_bare_track_id() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "s1", "stream:t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
|
||||
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
|
||||
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
|
||||
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Client-side track enrichment — oximedia BPM + mood into library facts.
|
||||
|
||||
use oximedia_mir::{mood, tempo, MirConfig};
|
||||
use psysonic_core::track_enrichment::{
|
||||
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
|
||||
TrackEnrichmentPlan, TrackEnrichmentRealFact,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
|
||||
use crate::analysis_cache::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
|
||||
};
|
||||
|
||||
pub const ENRICHMENT_WINDOW_SEC: f64 = 60.0;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnrichmentUpdatedPayload {
|
||||
pub track_id: String,
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
fn emit_enrichment_updated<R: Runtime>(app: &AppHandle<R>, server_id: &str, track_id: &str) {
|
||||
let _ = app.emit(
|
||||
"analysis:enrichment-updated",
|
||||
EnrichmentUpdatedPayload {
|
||||
track_id: track_id.to_string(),
|
||||
server_id: server_id.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run_track_enrichment_if_needed<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
notify_ui: bool,
|
||||
) -> TrackEnrichmentOutcome {
|
||||
if server_id.is_empty() {
|
||||
return TrackEnrichmentOutcome::SkippedNoServer;
|
||||
}
|
||||
let Some(port) = app.try_state::<TrackEnrichmentPort>() else {
|
||||
return TrackEnrichmentOutcome::SkippedNoPort;
|
||||
};
|
||||
let content_hash = md5_first_16kb(bytes);
|
||||
let plan = port.plan(server_id, track_id, &content_hash);
|
||||
if !plan.any() {
|
||||
return TrackEnrichmentOutcome::SkippedComplete;
|
||||
}
|
||||
|
||||
match analyze_and_store(&port, server_id, track_id, &content_hash, bytes, plan) {
|
||||
Ok(()) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][enrichment] applied track_id={} server_id={} hash={}",
|
||||
track_id,
|
||||
server_id,
|
||||
content_hash
|
||||
);
|
||||
if notify_ui {
|
||||
emit_enrichment_updated(app, server_id, track_id);
|
||||
}
|
||||
TrackEnrichmentOutcome::Applied
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[analysis][enrichment] failed track_id={} server_id={}: {}",
|
||||
track_id,
|
||||
server_id,
|
||||
e
|
||||
);
|
||||
TrackEnrichmentOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_and_store(
|
||||
port: &TrackEnrichmentPort,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
bytes: &[u8],
|
||||
plan: TrackEnrichmentPlan,
|
||||
) -> Result<(), String> {
|
||||
let total_duration = audio_duration_from_bytes(bytes).unwrap_or(0.0);
|
||||
let window = analysis_pcm_window(total_duration, ENRICHMENT_WINDOW_SEC);
|
||||
let (mono, sample_rate) =
|
||||
decode_mono_pcm_window(bytes, window.start_sec, window.duration_sec)?;
|
||||
if mono.is_empty() || sample_rate <= 0.0 {
|
||||
return Err("empty PCM window".to_string());
|
||||
}
|
||||
|
||||
let config = MirConfig::default();
|
||||
let mut facts = TrackEnrichmentFacts::default();
|
||||
|
||||
if plan.need_bpm {
|
||||
let detector = tempo::TempoDetector::new(sample_rate, config.min_tempo, config.max_tempo);
|
||||
let tempo = detector.detect(&mono).map_err(|e| format!("tempo: {e}"))?;
|
||||
let bpm = tempo.bpm.round().clamp(20.0, 999.0) as i64;
|
||||
facts.bpm = Some(TrackEnrichmentIntFact {
|
||||
value: bpm,
|
||||
confidence: tempo.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
if plan.need_valence || plan.need_arousal || plan.need_moods {
|
||||
let detector = mood::MoodDetector::new(sample_rate);
|
||||
let mood = detector.detect(&mono).map_err(|e| format!("mood: {e}"))?;
|
||||
let confidence = mood.intensity.clamp(0.0, 1.0);
|
||||
if plan.need_valence {
|
||||
facts.valence = Some(TrackEnrichmentRealFact {
|
||||
value: mood.valence as f64,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
if plan.need_arousal {
|
||||
facts.arousal = Some(TrackEnrichmentRealFact {
|
||||
value: mood.arousal as f64,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
if plan.need_moods && !mood.moods.is_empty() {
|
||||
facts.moods = Some(
|
||||
serde_json::to_string(&mood.moods).map_err(|e| format!("moods json: {e}"))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
port.store(server_id, track_id, content_hash, &facts)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
ringbuf = "0.5"
|
||||
biquad = "0.6"
|
||||
dasp_sample = "0.11.0"
|
||||
md5 = "0.8"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
id3 = "1.17"
|
||||
pitch_shift = "2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "sync", "macros", "rt-multi-thread", "test-util"] }
|
||||
wiremock = { workspace = true }
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Unified playback → track analysis dispatch.
|
||||
//!
|
||||
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
|
||||
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
|
||||
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
|
||||
use url::Url;
|
||||
use crate::state::ChainedInfo;
|
||||
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
|
||||
/// Where playback obtained the bytes — used for logging and size caps only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TrackAnalysisOrigin {
|
||||
InMemoryReplay,
|
||||
StreamDownloadComplete,
|
||||
LocalFilePlayback,
|
||||
StreamSpillFile,
|
||||
PrefetchOrCacheFile,
|
||||
GaplessChainReady,
|
||||
GaplessTransition,
|
||||
}
|
||||
|
||||
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
|
||||
match origin {
|
||||
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback server scope: explicit IPC value, else pinned engine scope.
|
||||
pub(crate) fn resolve_analysis_server_id(
|
||||
explicit: Option<&str>,
|
||||
engine: Option<&AudioEngine>,
|
||||
) -> String {
|
||||
if let Some(engine) = engine {
|
||||
if let Some(url) = engine
|
||||
.current_playback_url
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
{
|
||||
if let Some(derived) = server_id_from_playback_url(&url) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
}
|
||||
explicit
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn server_id_from_playback_url(url_raw: &str) -> Option<String> {
|
||||
if url_raw.starts_with("psysonic-local://") {
|
||||
return None;
|
||||
}
|
||||
let parsed = Url::parse(url_raw).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let mut base_path = parsed.path().to_string();
|
||||
if let Some(idx) = base_path.find("/rest") {
|
||||
base_path.truncate(idx);
|
||||
}
|
||||
while base_path.ends_with('/') {
|
||||
base_path.pop();
|
||||
}
|
||||
let mut base = host.to_string();
|
||||
if let Some(port) = parsed.port() {
|
||||
base.push_str(&format!(":{port}"));
|
||||
}
|
||||
if !base_path.is_empty() {
|
||||
base.push_str(&base_path);
|
||||
}
|
||||
Some(base)
|
||||
}
|
||||
|
||||
fn resolve_analysis_priority(
|
||||
app: &AppHandle,
|
||||
engine: Option<&AudioEngine>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
if let Some(priority) = explicit {
|
||||
return priority;
|
||||
}
|
||||
if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||
{
|
||||
return AnalysisBackfillPriority::High;
|
||||
}
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve `(server_id, priority)` when the caller has live engine state.
|
||||
pub(crate) fn prepare_playback_analysis(
|
||||
app: &AppHandle,
|
||||
engine: &AudioEngine,
|
||||
explicit_server_id: Option<&str>,
|
||||
track_id: &str,
|
||||
priority: Option<AnalysisBackfillPriority>,
|
||||
) -> (String, AnalysisBackfillPriority) {
|
||||
let sid = resolve_analysis_server_id(explicit_server_id, Some(engine));
|
||||
let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority);
|
||||
(sid, resolved)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_server_id_for_app(
|
||||
app: &AppHandle,
|
||||
explicit: Option<&str>,
|
||||
) -> String {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_analysis_server_id(explicit, engine.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn analysis_priority_for_app(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit)
|
||||
}
|
||||
|
||||
/// Gapless boundary: chained track became audible — run unified analysis if needed.
|
||||
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
|
||||
let track_id = analysis_cache_track_id(
|
||||
info.analysis_track_id.as_deref(),
|
||||
&info.url,
|
||||
);
|
||||
let Some(track_id) = track_id else {
|
||||
return;
|
||||
};
|
||||
let engine = app.state::<AudioEngine>();
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
&engine,
|
||||
info.server_id.as_deref(),
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::High),
|
||||
);
|
||||
let bytes = (*info.raw_bytes).clone();
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::GaplessTransition,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
|
||||
pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: Vec<u8>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
) -> Result<(), String> {
|
||||
let track_id = track_id.trim();
|
||||
if track_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let max = max_bytes_for_origin(origin);
|
||||
if bytes.len() > max {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
|
||||
bytes.len(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}",
|
||||
if server_id.is_empty() { "''" } else { server_id },
|
||||
bytes.len() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
&bytes,
|
||||
None,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Non-blocking wrapper with optional play-generation supersede guard.
|
||||
pub(crate) fn spawn_track_analysis_bytes(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() || bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_track_analysis_file(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
file_path: PathBuf,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = &generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != *gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let bytes = match tokio::fs::read(&file_path).await {
|
||||
Ok(b) if !b.is_empty() => b,
|
||||
_ => return,
|
||||
};
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! AutoEQ proxy commands: fetch entry list and FixedBandEQ profiles from
|
||||
//! GitHub via Rust to bypass WebView CORS.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
|
||||
/// AutoEQ raw-content base URL — the GitHub directory that holds every
|
||||
/// FixedBandEQ profile by `(source, form, name)` (and `rig`-prefixed forms
|
||||
/// for crinacle's measurements).
|
||||
pub(crate) const AUTOEQ_RAW_BASE: &str =
|
||||
"https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
|
||||
|
||||
/// Pure URL builder for [`autoeq_fetch_profile`]. The AutoEQ repo lays out
|
||||
/// FixedBandEQ profiles either as
|
||||
///
|
||||
/// `{base}/{source}/{form}/{name}/{name} FixedBandEQ.txt` (most sources)
|
||||
/// `{base}/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt` (crinacle — rig-prefixed dir)
|
||||
///
|
||||
/// When `rig` is supplied the function emits the rig-prefixed candidate first
|
||||
/// (so callers try it before the form-only fallback). When `rig` is `None`
|
||||
/// only the form-only path is returned.
|
||||
pub(crate) fn autoeq_profile_url_candidates(
|
||||
base: &str,
|
||||
source: &str,
|
||||
form: &str,
|
||||
name: &str,
|
||||
rig: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let filename = format!("{} FixedBandEQ.txt", name);
|
||||
if let Some(r) = rig {
|
||||
vec![
|
||||
format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename),
|
||||
format!("{}/{}/{}/{}/{}", base, source, form, name, filename),
|
||||
]
|
||||
} else {
|
||||
vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
|
||||
audio_http_client(&state)
|
||||
.get("https://autoeq.app/entries")
|
||||
.send().await.map_err(|e| e.to_string())?
|
||||
.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_fetch_profile(
|
||||
name: String,
|
||||
source: String,
|
||||
rig: Option<String>,
|
||||
form: String,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<String, String> {
|
||||
let candidates =
|
||||
autoeq_profile_url_candidates(AUTOEQ_RAW_BASE, &source, &form, &name, rig.as_deref());
|
||||
|
||||
for url in &candidates {
|
||||
let resp = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if resp.status().is_success() {
|
||||
return resp.text().await.map_err(|e| e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("FixedBandEQ profile not found for '{}'", name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_candidates_returns_form_only_path_when_no_rig_supplied() {
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"https://example/results",
|
||||
"oratory1990",
|
||||
"over-ear",
|
||||
"Sennheiser HD 600",
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec!["https://example/results/oratory1990/over-ear/Sennheiser HD 600/Sennheiser HD 600 FixedBandEQ.txt".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_emits_rig_prefixed_candidate_first_when_rig_supplied() {
|
||||
// crinacle's measurements use a rig-prefixed directory like
|
||||
// `crinacle/IEC711 in-ear` instead of plain `crinacle/in-ear`.
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"https://example/results",
|
||||
"crinacle",
|
||||
"in-ear",
|
||||
"Moondrop Variations",
|
||||
Some("IEC711"),
|
||||
);
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://example/results/crinacle/IEC711 in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
|
||||
"rig-prefixed path tried first"
|
||||
);
|
||||
assert_eq!(
|
||||
urls[1],
|
||||
"https://example/results/crinacle/in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
|
||||
"form-only fallback emitted second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_preserves_spaces_in_headphone_names() {
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"base",
|
||||
"src",
|
||||
"form",
|
||||
"Audio-Technica ATH-M50x",
|
||||
None,
|
||||
);
|
||||
// Spaces inside the name aren't URL-encoded — reqwest does that on send.
|
||||
assert!(urls[0].contains("Audio-Technica ATH-M50x"));
|
||||
assert!(urls[0].ends_with("Audio-Technica ATH-M50x FixedBandEQ.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_uses_real_autoeq_base_in_production() {
|
||||
// The const is the production raw-content URL — guard against typos.
|
||||
assert!(AUTOEQ_RAW_BASE.starts_with("https://raw.githubusercontent.com/"));
|
||||
assert!(AUTOEQ_RAW_BASE.contains("/jaakkopasanen/AutoEq"));
|
||||
assert!(AUTOEQ_RAW_BASE.ends_with("/results"));
|
||||
}
|
||||
}
|
||||
@@ -1,750 +0,0 @@
|
||||
//! Tauri commands: audio_play / chain_preload / preload + the shared
|
||||
//! spawn_progress_task helper. Transport (pause/resume/stop/seek), device,
|
||||
//! radio, mix-mode and AutoEQ commands live in sibling modules.
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rodio::Player;
|
||||
use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::build_source;
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
use super::sink_swap::{
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
|
||||
SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
|
||||
/// for HTTP streams (`playback_identity` is used as fallback).
|
||||
///
|
||||
/// `server_id`: app id of the server that owns this track (`playbackServerId ??
|
||||
/// activeServerId` on the frontend). Scopes the analysis-cache write key so a
|
||||
/// later server switch can't surface another server's waveform for the same bare
|
||||
/// `track_id`. Empty/absent falls back to the legacy `''` scope.
|
||||
///
|
||||
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
|
||||
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
|
||||
// `false` so older/external `audio_play` callers that omit it still work.
|
||||
start_paused: Option<bool>,
|
||||
// Silence-aware crossfade (B-head): begin playback past the next track's
|
||||
// leading silence. Optional + defaults to `0` so existing callers are
|
||||
// unaffected; only applied when the freshly built source is seekable.
|
||||
start_secs: Option<f64>,
|
||||
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
|
||||
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
|
||||
// transition instead of the global `crossfade_secs`. `None` → use the global
|
||||
// setting (today's behaviour); always still clamped to the measured remaining.
|
||||
crossfade_secs_override: Option<f32>,
|
||||
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
|
||||
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
|
||||
// already fades out in the recording, so let it ride at full engine gain
|
||||
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
|
||||
// B's fade (today's behaviour). Always clamped to A's measured remaining.
|
||||
outgoing_fade_secs_override: Option<f32>,
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let start_paused = start_paused.unwrap_or(false);
|
||||
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
// After a gapless auto-advance, the frontend may fire a stale playTrack()
|
||||
// call via IPC. If we're within 500 ms of the last gapless switch AND the
|
||||
// requested URL matches the already-playing chained track, reject it.
|
||||
{
|
||||
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
||||
if switch_ms > 0 {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
if now_ms.saturating_sub(switch_ms) < 500 {
|
||||
// Within the guard window — suppress this ghost command.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any active preview before starting fresh main playback so the
|
||||
// two sinks don't end up mixed.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
|
||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||
// advance. The source is live in the queue — just return and let the
|
||||
// progress task handle the state transition when the previous source ends.
|
||||
//
|
||||
// Never for manual skips: the UI already jumped to this track in JS, but
|
||||
// the current source is still playing until the chain drains. User-initiated
|
||||
// play must clear the chain and start this URL immediately (standard path).
|
||||
if gapless && !manual {
|
||||
let already_chained = state.chained_info.lock().unwrap()
|
||||
.as_ref()
|
||||
.map(|c| same_playback_target(&c.url, &url))
|
||||
.unwrap_or(false);
|
||||
if already_chained {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard (new-sink) path ─────────────────────────────────────────────
|
||||
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
||||
// proactive chain was not set up in time.
|
||||
|
||||
// Bump generation first so the old progress task stops before we peel
|
||||
// chained_info (avoids a race where it sees current_done + empty chain).
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
// Ranged/legacy HTTP paths reset this to false in `select_play_input`.
|
||||
state.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
|
||||
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
|
||||
// preload cache was already consumed when the chain was built). Otherwise
|
||||
// clear any stale chain metadata.
|
||||
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
|
||||
let mut ci = state.chained_info.lock().unwrap();
|
||||
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
ci.take().map(|info| {
|
||||
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
|
||||
})
|
||||
} else {
|
||||
*ci = None;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
None
|
||||
};
|
||||
|
||||
// Stop fading-out sink from previous crossfade.
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
|
||||
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
|
||||
// the previous URL still stored until the sink swap completes.
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
|
||||
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
|
||||
// Playback server scope for the analysis-cache write key (empty → legacy '').
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
// Pin it so the gain-resolution + replay-gain-update + device-resume reads
|
||||
// scope to this server too (mirrors `current_analysis_track_id`).
|
||||
*state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string);
|
||||
|
||||
let format_hint = url_format_hint(&url);
|
||||
|
||||
let play_input = match select_play_input(
|
||||
PlayInputContext {
|
||||
url: &url,
|
||||
gen,
|
||||
duration_hint,
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
format_hint: format_hint.as_deref(),
|
||||
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
|
||||
server_id: analysis_server_id,
|
||||
reuse_chained_bytes,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
).await? {
|
||||
Some(input) => input,
|
||||
None => {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded inside select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded after select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
|
||||
playback_identity(&url),
|
||||
normalization_engine_name(gain_inputs.norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.cache_loudness_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
gain_inputs.target_lufs,
|
||||
volume,
|
||||
effective_volume
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(gain_inputs.norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs: gain_inputs.target_lufs,
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
|
||||
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
|
||||
let crossfade_enabled =
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = crossfade_secs_override
|
||||
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
|
||||
.clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
// have consumed ~500–800ms from Track A's tail — so its true remaining time
|
||||
// is always less than crossfade_secs_val. Using the measured remaining time
|
||||
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
|
||||
// guarantees Track A reaches 0 exactly when its source exhausts.
|
||||
let actual_fade_secs: f32 = if crossfade_enabled {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let remaining = (cur.duration_secs - cur.position()) as f32;
|
||||
remaining.clamp(0.1, crossfade_secs_val)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Fade-in duration for Track B:
|
||||
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
|
||||
// hard cut → 5 ms micro-fade to suppress DC-offset click
|
||||
let fade_in_dur = if crossfade_enabled {
|
||||
Duration::from_secs_f32(actual_fade_secs)
|
||||
} else {
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
|
||||
// override means A already fades out in the recording, so we leave it at
|
||||
// full engine gain (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
let playback_source = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &url,
|
||||
gen,
|
||||
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
|
||||
server_id: analysis_server_id,
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Suppress the audio:error toast when this play was already superseded
|
||||
// by a newer audio_play (rapid skip): the failure is the inevitable
|
||||
// Ok(0)/EOF from RangedHttpSource after gen-bump, not a real codec
|
||||
// problem. The frontend would otherwise show "Couldn't play track" for
|
||||
// the abandoned URL while a new track is already loading.
|
||||
if state.generation.load(Ordering::SeqCst) == gen {
|
||||
app.emit("audio:error", &e).ok();
|
||||
} else {
|
||||
crate::app_deprintln!(
|
||||
"[audio] suppressed audio:error for superseded play (gen={} cur={}): {}",
|
||||
gen, state.generation.load(Ordering::SeqCst), e
|
||||
);
|
||||
}
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let source_seekable = playback_source.is_seekable;
|
||||
let built = playback_source.built;
|
||||
let mut source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
let output_channels = built.output_channels;
|
||||
|
||||
// Store the actual output rate/channels for position calculation.
|
||||
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
|
||||
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
// toggle was just turned off mid-session), restore the device
|
||||
// default rate so playback is no longer at 88.2/96 kHz etc.
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
match super::engine::open_output_stream_blocking(
|
||||
&state,
|
||||
target_rate,
|
||||
hi_res_enabled,
|
||||
dev,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||
// At sample rates > 48 kHz the hardware quantum is larger and the first
|
||||
// period demands more decoded frames than at 44.1/48 kHz.
|
||||
// Strategy: pause the sink before appending so rodio's internal mixer
|
||||
// decodes into its ring buffer ahead of the hardware. After a short delay
|
||||
// we resume — the buffer is already full and the hardware gets its frames
|
||||
// without an underrun on the very first period.
|
||||
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
|
||||
// Preserve-pitch phase vocoder runs on a worker thread; pre-fill gives the
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
}
|
||||
|
||||
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
||||
// Only when this is an auto-advance (near end), not on manual skip.
|
||||
//
|
||||
// Use a frame-aligned `SamplesBuffer` rather than `Zero + take_duration` —
|
||||
// the latter computes its sample count via integer-nanosecond division
|
||||
// (1_000_000_000 / (sr * ch)), which at common rates leaks an odd number
|
||||
// of samples (e.g. 44103 at 44.1 kHz / 2 ch / 500 ms = 22051.5 frames).
|
||||
// The half-frame leak shifts the next source's L/R parity in the device
|
||||
// stream and can manifest as a dead channel for the rest of the track
|
||||
// (reported by users for natural-end-without-gapless transitions only).
|
||||
if !gapless {
|
||||
let cur_pos = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.position()
|
||||
};
|
||||
let cur_dur = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.duration_secs
|
||||
};
|
||||
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
|
||||
if is_auto_advance {
|
||||
let ch = source.channels();
|
||||
let sr = source.sample_rate();
|
||||
// 500 ms in whole frames, then expand to interleaved samples.
|
||||
let frames = (sr.get() / 2) as usize;
|
||||
let total_samples = frames.saturating_mul(ch.get() as usize);
|
||||
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
|
||||
sink.append(silence);
|
||||
}
|
||||
}
|
||||
|
||||
// Silence-aware crossfade (B-head): skip the next track's leading silence by
|
||||
// seeking the freshly built source before it is appended. The outermost
|
||||
// `CountingSource` stores the sample counter on a successful seek; we still
|
||||
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
|
||||
// so the seekbar and the crossfade-remaining math are content-relative.
|
||||
let did_start_seek = if start_secs > 0.05 && source_seekable {
|
||||
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
let prefill_ms = if needs_preserve_prefill {
|
||||
800
|
||||
} else {
|
||||
500
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
if !defer_playback_start && !start_paused {
|
||||
sink.play();
|
||||
}
|
||||
}
|
||||
|
||||
swap_in_new_sink(&state, SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: built.fadeout_trigger,
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
|
||||
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
|
||||
// sample counter to the content offset so position reporting is correct.
|
||||
if did_start_seek {
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.seek_offset = start_secs;
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
start_secs,
|
||||
output_rate,
|
||||
output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
if defer_playback_start {
|
||||
if !start_paused {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
gen_arc: state.generation.clone(),
|
||||
playback_armed: state.stream_playback_armed.clone(),
|
||||
samples_played: state.samples_played.clone(),
|
||||
current: state.current.clone(),
|
||||
app: app.clone(),
|
||||
duration_secs,
|
||||
hold_paused: start_paused,
|
||||
});
|
||||
} else if !start_paused {
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
}
|
||||
|
||||
// ── Progress + ended detection ────────────────────────────────────────────
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.current.clone(),
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.playback_rate.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proactively appends the next track to the current Sink ~30 s before the
|
||||
/// current track ends. Called from JS at the same trigger point as preload.
|
||||
///
|
||||
/// Because this runs well before the track boundary, the IPC round-trip is
|
||||
/// irrelevant — by the time the current track actually ends, the next source
|
||||
/// is already live in the Sink queue and rodio transitions at sample accuracy.
|
||||
///
|
||||
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
|
||||
/// immediately without touching the Sink (pure no-op on the audio path).
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_chain_preload(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Idempotent: already chained this track → nothing to do.
|
||||
{
|
||||
let chained = state.chained_info.lock().unwrap();
|
||||
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless must be enabled and a sink must exist.
|
||||
if !state.gapless_enabled.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let snapshot_gen = state.generation.load(Ordering::SeqCst);
|
||||
|
||||
// Fetch bytes — use preload cache if available, otherwise HTTP.
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(d) = cached {
|
||||
d
|
||||
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let resp = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(()); // silently fail — audio_play will retry
|
||||
}
|
||||
let hint = resp.content_length().unwrap_or(0) as usize;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buf = Vec::with_capacity(hint);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(()); // superseded by manual skip — abort download
|
||||
}
|
||||
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
|
||||
}
|
||||
buf
|
||||
}
|
||||
};
|
||||
|
||||
// Bail if the user skipped to a different track while we were downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let raw_bytes = Arc::new(data);
|
||||
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||
&app,
|
||||
&state,
|
||||
analysis_server_id,
|
||||
&track_id,
|
||||
Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
let bytes = (*raw_bytes).clone();
|
||||
crate::analysis_dispatch::spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
||||
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
||||
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
|
||||
// the still-playing current source). Volume for the chained track is
|
||||
// applied at the gapless transition in `spawn_progress_task`, not here.
|
||||
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, _effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
(*raw_bytes).clone(),
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_next.clone(),
|
||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||
chain_counter.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
|
||||
// Final gen check — reject if a manual skip happened during decode.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
// Note: `set_volume` is deliberately NOT called here (see comment above).
|
||||
{
|
||||
let cur = state.current.lock().unwrap();
|
||||
match &cur.sink {
|
||||
Some(sink) => {
|
||||
sink.append(source);
|
||||
}
|
||||
None => return Ok(()), // playback stopped — bail
|
||||
}
|
||||
}
|
||||
|
||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||
url,
|
||||
analysis_track_id: logical_trim,
|
||||
server_id: analysis_server_id.map(str::to_string),
|
||||
raw_bytes,
|
||||
duration_secs,
|
||||
replay_gain_linear: gain_linear,
|
||||
base_volume: volume.clamp(0.0, 1.0),
|
||||
source_done: done_next,
|
||||
sample_counter: chain_counter,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +0,0 @@
|
||||
//! Output device enumeration with suppressed ALSA stderr noise.
|
||||
// `rodio::cpal` is referenced from the included body.
|
||||
|
||||
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
|
||||
}
|
||||
}
|
||||
let _guard = unsafe {
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
f()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[inline]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
f()
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
|
||||
const IFACES: &[&str] = &[
|
||||
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
|
||||
"surround51", "surround71",
|
||||
];
|
||||
let colon = name.find(':')?;
|
||||
let iface = name[..colon].to_ascii_lowercase();
|
||||
if !IFACES.contains(&iface.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
|
||||
let dev = name
|
||||
.split("DEV=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
Some((iface, card, dev))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[inline]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (
|
||||
linux_alsa_sink_fingerprint(a),
|
||||
linux_alsa_sink_fingerprint(b),
|
||||
) {
|
||||
(Some(fa), Some(fb)) => fa == fb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||
available
|
||||
.iter()
|
||||
.any(|d| output_devices_logically_same(d, pinned))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── output_devices_logically_same ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn logically_same_returns_true_for_identical_names() {
|
||||
assert!(output_devices_logically_same("Generic Audio", "Generic Audio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logically_same_returns_false_for_different_non_alsa_names() {
|
||||
assert!(!output_devices_logically_same(
|
||||
"Built-in Speakers",
|
||||
"External DAC"
|
||||
));
|
||||
}
|
||||
|
||||
// ── output_enumeration_includes_pinned ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_finds_exact_match() {
|
||||
let avail = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||
assert!(output_enumeration_includes_pinned(&avail, "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_returns_false_when_absent() {
|
||||
let avail = vec!["A".to_string(), "B".to_string()];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_returns_false_for_empty_list() {
|
||||
let avail: Vec<String> = vec![];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
|
||||
}
|
||||
|
||||
// ── linux_alsa_sink_fingerprint (Linux-only path) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_extracts_iface_card_dev() {
|
||||
let fp = linux_alsa_sink_fingerprint("hdmi:CARD=NVidia,DEV=3");
|
||||
assert_eq!(fp, Some(("hdmi".to_string(), "NVidia".to_string(), 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_defaults_dev_to_zero_when_missing() {
|
||||
let fp = linux_alsa_sink_fingerprint("plughw:CARD=PCH");
|
||||
assert_eq!(fp, Some(("plughw".to_string(), "PCH".to_string(), 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_returns_none_for_unknown_iface() {
|
||||
// "pulse" is not in the recognised ALSA-iface list — frontend-only sink.
|
||||
assert!(linux_alsa_sink_fingerprint("pulse:something").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_returns_none_when_no_colon() {
|
||||
assert!(linux_alsa_sink_fingerprint("Generic Audio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_lowercases_iface_name() {
|
||||
let fp = linux_alsa_sink_fingerprint("HDMI:CARD=card,DEV=0");
|
||||
assert_eq!(fp.unwrap().0, "hdmi", "iface is normalised to lowercase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn logically_same_treats_same_card_dev_as_match_across_alsa_ifaces() {
|
||||
// Same physical sink can appear under "hw:CARD=X,DEV=0" and "plughw:CARD=X,DEV=0".
|
||||
// The fingerprint comparison includes the iface, so these are NOT
|
||||
// logically the same — clarifying the contract here.
|
||||
assert!(!output_devices_logically_same(
|
||||
"hw:CARD=X,DEV=0",
|
||||
"plughw:CARD=X,DEV=0"
|
||||
));
|
||||
// But the SAME iface with the same card/dev is the same sink:
|
||||
assert!(output_devices_logically_same(
|
||||
"hw:CARD=X,DEV=0",
|
||||
"hw:CARD=X,DEV=0"
|
||||
));
|
||||
}
|
||||
|
||||
// ── linux_alsa_sink_fingerprint stub on non-Linux ─────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn alsa_fingerprint_is_none_on_non_linux_for_any_input() {
|
||||
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
|
||||
assert!(linux_alsa_sink_fingerprint("anything").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//! Tauri commands for output-device listing + selection. Pulled out of
|
||||
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
||||
//! enumeration + reopen path.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// When the saved `selected_device` no longer literally matches any listed
|
||||
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
||||
#[tauri::command]
|
||||
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
||||
let pinned = state.selected_device.lock().unwrap().clone()?;
|
||||
if pinned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
return None;
|
||||
}
|
||||
let canon = list
|
||||
.iter()
|
||||
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||
.clone();
|
||||
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
///
|
||||
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
audio_list_devices_for_engine(&state)
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
#[tauri::command]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
||||
#[tauri::command]
|
||||
pub async fn audio_set_device(
|
||||
device_name: Option<String>,
|
||||
state: State<'_, AudioEngine>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
state.device_default_rate
|
||||
};
|
||||
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
|
||||
.map_err(|_| "device open timed out".to_string())?;
|
||||
|
||||
// Capture position and drop the active sink atomically so the position
|
||||
// reading is still valid (play_started / paused_at intact before take).
|
||||
let current_time = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let pos = cur.position();
|
||||
if let Some(s) = cur.sink.take() { s.stop(); }
|
||||
pos
|
||||
};
|
||||
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
// Emit the saved position so the frontend can use seekFallbackVisualTarget
|
||||
// and resume from where the track was, rather than restarting from the beginning.
|
||||
// null is reserved for "Rust already resumed internally" (see reopen_output_stream).
|
||||
app.emit("audio:device-changed", current_time).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//! Rust-side seamless replay after an output-device switch.
|
||||
//!
|
||||
//! `try_resume_after_device_change` is called from `reopen_output_stream`
|
||||
//! (device_watcher.rs) after the new CPAL stream is ready and the old sink
|
||||
//! has been stopped. It attempts to restart the current track on the new
|
||||
//! device without any frontend round-trip.
|
||||
//!
|
||||
//! Supported source paths (in order of preference):
|
||||
//! - `psysonic-local://` — opened directly from disk via `LocalFileSource`.
|
||||
//! - HTTP, fully cached in RAM — replayed from `stream_completed_cache`.
|
||||
//! - HTTP, spilled to disk — bytes read from `stream_completed_spill`.
|
||||
//!
|
||||
//! Falls back to the frontend (returns `false`) for:
|
||||
//! - paused playback
|
||||
//! - radio / live stream
|
||||
//! - HTTP track whose download was only partial
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{
|
||||
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
|
||||
};
|
||||
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
/// Snapshot of playback state captured before the blocking stream reopen.
|
||||
pub(crate) struct ResumeSnapshot {
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) current_time_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
pub(crate) is_playing: bool,
|
||||
}
|
||||
|
||||
/// Try to replay the current track on the new device without involving the
|
||||
/// frontend. Returns `true` if playback was successfully restarted.
|
||||
///
|
||||
/// Conditions that cause an immediate `false` (frontend fallback):
|
||||
/// - Paused playback — user can press play on the new device via the cold path.
|
||||
/// - Radio stream — live, non-seekable; frontend handles reconnect.
|
||||
/// - No current URL — nothing was playing.
|
||||
/// - HTTP track whose download was only partial (cache/spill absent) — frontend
|
||||
/// re-fetches from the server via the seekFallbackVisualTarget path.
|
||||
pub(crate) async fn try_resume_after_device_change(
|
||||
app: &tauri::AppHandle,
|
||||
snap: &ResumeSnapshot,
|
||||
) -> bool {
|
||||
// Only resume actively-playing (not paused) tracks.
|
||||
if !snap.is_playing {
|
||||
return false;
|
||||
}
|
||||
let url = match snap.url.as_deref() {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Skip radio — live streams don't have a resume position.
|
||||
if engine.radio_state.lock().unwrap().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build a PlayInput without re-downloading:
|
||||
// - psysonic-local:// → seekable file
|
||||
// - HTTP, fully cached → in-memory bytes (stream_completed_cache)
|
||||
// - HTTP, spilled → bytes read from spill file
|
||||
// - HTTP, partial → return false (frontend will re-fetch)
|
||||
let play_input: PlayInput = if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] cannot open local file: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HTTP track — use completed in-memory cache or spill file.
|
||||
// If the download was only partial, fall back to the frontend path
|
||||
// which will re-fetch from the server.
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard.as_ref().filter(|t| t.url == url).map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard.as_ref().filter(|s| s.url == url).map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] spill read failed: {e}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
None => return false, // not fully cached yet — frontend will re-fetch
|
||||
}
|
||||
};
|
||||
PlayInput::Bytes(bytes)
|
||||
};
|
||||
|
||||
// Bump generation so the old progress task exits cleanly.
|
||||
let gen = engine.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
engine.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
*engine.chained_info.lock().unwrap() = None;
|
||||
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false; // raced with another audio_play
|
||||
}
|
||||
|
||||
let format_hint = url_format_hint(url);
|
||||
let stream_format_suffix: Option<String> = url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
engine.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000;
|
||||
// Resume re-plays the current track → scope its analysis writes to the
|
||||
// pinned playback server (empty → legacy '').
|
||||
let resume_server = crate::helpers::current_playback_server_id_str(&engine);
|
||||
|
||||
let ps: PlaybackSource = match build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
app,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] source build failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false;
|
||||
}
|
||||
|
||||
engine
|
||||
.current_is_seekable
|
||||
.store(ps.is_seekable, Ordering::SeqCst);
|
||||
engine
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
engine
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = match super::engine::ensure_output_stream_open(&engine) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
swap_in_new_sink(
|
||||
&engine,
|
||||
SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs: ps.built.duration_secs,
|
||||
volume: snap.base_volume,
|
||||
gain_linear: snap.gain_linear,
|
||||
fadeout_trigger: ps.built.fadeout_trigger,
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Seek to the saved position for seekable sources (local files, ranged HTTP).
|
||||
if ps.is_seekable && snap.current_time_secs > 0.5 {
|
||||
let seek_sink = engine.current.lock().unwrap().sink.as_ref().map(Arc::clone);
|
||||
if let Some(sk) = seek_sink {
|
||||
let target = std::time::Duration::from_secs_f64(snap.current_time_secs.max(0.0));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(sk.try_seek(target).map_err(|e| e.to_string()));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(700)) {
|
||||
Ok(Ok(())) => {
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.seek_offset = snap.current_time_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
engine.samples_played.store(
|
||||
crate::playback_rate::raw_counter_samples_for_content_position(
|
||||
snap.current_time_secs,
|
||||
engine.current_sample_rate.load(Ordering::Relaxed),
|
||||
engine.current_channels.load(Ordering::Relaxed),
|
||||
&engine.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::app_eprintln!("[device-resume] seek failed: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[device-resume] seek timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inform the frontend of the new duration (keeps seekbar range correct).
|
||||
app.emit("audio:playing", ps.built.duration_secs).ok();
|
||||
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
engine.generation.clone(),
|
||||
engine.current.clone(),
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
engine.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
engine.samples_played.clone(),
|
||||
engine.current_sample_rate.clone(),
|
||||
engine.current_channels.clone(),
|
||||
engine.gapless_switch_at.clone(),
|
||||
engine.current_playback_url.clone(),
|
||||
engine.stream_playback_armed.clone(),
|
||||
engine.playback_rate.clone(),
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}",
|
||||
snap.current_time_secs,
|
||||
ps.is_seekable
|
||||
);
|
||||
true
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_resume::{try_resume_after_device_change, ResumeSnapshot};
|
||||
use super::engine::AudioEngine;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use super::dev_io::output_enumeration_includes_pinned;
|
||||
|
||||
/// What to tell the frontend after a successful stream reopen.
|
||||
pub(crate) enum ReopenNotify {
|
||||
/// Normal path — same as `audio_set_device`.
|
||||
DeviceChanged,
|
||||
/// Pinned device unplugged (Windows/macOS only); Rust cleared the pin — clear Settings + restart playback.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
DeviceReset,
|
||||
}
|
||||
|
||||
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
|
||||
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
|
||||
///
|
||||
/// If the interrupted track is a seekable local file or a fully-cached HTTP download
|
||||
/// (in-memory or spill file), the function replays it internally from the saved position —
|
||||
/// no frontend round-trip, no audible restart. On success it emits
|
||||
/// `audio:device-changed` / `audio:device-reset` with a `null` payload so the frontend
|
||||
/// knows Rust already handled playback.
|
||||
/// For radio, partially-buffered HTTP tracks, or paused playback, it falls back to the
|
||||
/// previous behaviour: emit with the captured `current_time_secs` so the frontend calls
|
||||
/// `playTrack`.
|
||||
pub(crate) async fn reopen_output_stream(
|
||||
app: &tauri::AppHandle,
|
||||
device_name: Option<String>,
|
||||
notify: ReopenNotify,
|
||||
) -> bool {
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
// Snapshot state we need BEFORE the blocking stream reopen (while the old sink
|
||||
// is still live and position() is still valid).
|
||||
let snapshot = {
|
||||
let cur = current.lock().unwrap();
|
||||
let is_playing = cur.play_started.is_some() && cur.paused_at.is_none();
|
||||
ResumeSnapshot {
|
||||
url: engine.current_playback_url.lock().unwrap().clone(),
|
||||
current_time_secs: cur.position(),
|
||||
duration_secs: cur.duration_secs,
|
||||
base_volume: cur.base_volume,
|
||||
gain_linear: cur.replay_gain_linear,
|
||||
analysis_track_id: engine.current_analysis_track_id.lock().unwrap().clone(),
|
||||
is_playing,
|
||||
}
|
||||
};
|
||||
|
||||
let app_for_open = app.clone();
|
||||
let device_name_for_open = device_name.clone();
|
||||
let opened = tauri::async_runtime::spawn_blocking(move || {
|
||||
let engine = app_for_open.state::<AudioEngine>();
|
||||
super::engine::open_output_stream_blocking(
|
||||
&engine,
|
||||
open_rate,
|
||||
false,
|
||||
device_name_for_open,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !opened {
|
||||
return false;
|
||||
}
|
||||
// When we're not actively playing (paused/stopped), bump the generation
|
||||
// before stopping the old sink so the still-running progress task sees the
|
||||
// mismatch and bails out instead of emitting a spurious `audio:ended` —
|
||||
// which would otherwise trigger a frontend restart of paused playback
|
||||
// (#1094). The active-playback path bumps inside
|
||||
// `try_resume_after_device_change`, so only guard the non-playing case here.
|
||||
if !snapshot.is_playing {
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = fading_out.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
// Attempt a Rust-side internal replay (no frontend involvement).
|
||||
// Falls back gracefully to the frontend path if conditions aren't met.
|
||||
let resumed = try_resume_after_device_change(app, &snapshot).await;
|
||||
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
// null → Rust already resumed; frontend skips playTrack
|
||||
// f64 → fallback; frontend calls playTrack + seek
|
||||
if resumed {
|
||||
app.emit("audio:device-changed", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-changed", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
ReopenNotify::DeviceReset => {
|
||||
if resumed {
|
||||
app.emit("audio:device-reset", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-reset", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let selected_device = engine.selected_device.clone();
|
||||
let samples_played = engine.samples_played.clone();
|
||||
let current = engine.current.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut pinned_miss_count: u32 = 0;
|
||||
// Fallback recovery when OS sleep/resume notifications are missed: if playback is
|
||||
// "running" but sample counter is flat for too long, reopen output stream.
|
||||
// To avoid false positives during normal playback, arm this watchdog only
|
||||
// after a suspiciously long poll gap (e.g. process resumed after sleep).
|
||||
let mut last_samples_seen: u64 = 0;
|
||||
let mut stalled_since: Option<Instant> = None;
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let now = Instant::now();
|
||||
let poll_gap = now.saturating_duration_since(last_poll_at);
|
||||
last_poll_at = now;
|
||||
if poll_gap >= Duration::from_secs(15) {
|
||||
let armed_until = now + Duration::from_secs(120);
|
||||
watchdog_armed_until = Some(armed_until);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog armed for 120s (poll gap {:?}, likely sleep/resume)",
|
||||
poll_gap
|
||||
);
|
||||
}
|
||||
let watchdog_armed = watchdog_armed_until.is_some_and(|until| now < until);
|
||||
|
||||
// ── Fallback stall detector (works even if sleep/resume signal was missed) ──
|
||||
let mut should_recover_stall = false;
|
||||
let mut stall_for = Duration::ZERO;
|
||||
{
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
let cur = current.lock().unwrap();
|
||||
let active = cur
|
||||
.sink
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_paused() && !s.empty());
|
||||
|
||||
if !watchdog_armed {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog disarmed, clearing stall candidate"
|
||||
);
|
||||
}
|
||||
last_samples_seen = samples_now;
|
||||
} else if !active || samples_now != last_samples_seen {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate cleared (active={active}, samples_delta={})",
|
||||
samples_now as i128 - last_samples_seen as i128
|
||||
);
|
||||
}
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_now;
|
||||
} else {
|
||||
let since = stalled_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_millis(100) {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate started (samples={}, active={active})",
|
||||
samples_now
|
||||
);
|
||||
}
|
||||
stall_for = since.elapsed();
|
||||
let cooldown_ok = last_stall_recover_at
|
||||
.map(|t| t.elapsed() >= Duration::from_secs(20))
|
||||
.unwrap_or(true);
|
||||
if stall_for >= Duration::from_secs(8) && cooldown_ok {
|
||||
should_recover_stall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_recover_stall {
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: output stalled for {:?} (samples={}) — reopening stream, pinned={:?}",
|
||||
stall_for,
|
||||
samples_now,
|
||||
pinned
|
||||
);
|
||||
if reopen_output_stream(&app, pinned, ReopenNotify::DeviceChanged).await {
|
||||
last_stall_recover_at = Some(Instant::now());
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output recovery succeeded"
|
||||
);
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output reopen timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The full `output_devices()` + per-device `description()` scan is the
|
||||
// CoreAudio HAL call that contends with the audio render thread and
|
||||
// produces a brief dropout once per poll interval (issue #996: stutter
|
||||
// every ~3s, cadence tracking the poll exactly). It is only needed to
|
||||
// detect a *pinned* output device disappearing. With no pin — system
|
||||
// default, the common case — only the current default is needed, a
|
||||
// single cheap query, so the full enumeration is skipped entirely.
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let need_full_enum = pinned.is_some();
|
||||
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
// audio:device-reset (UI jumped back to system default) when switching
|
||||
// to external USB / class-compliant interfaces.
|
||||
if need_full_enum && available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
|
||||
// whole session — any miss counter eventually tripped audio:device-reset.
|
||||
// Clearing the pin is left to the user (Settings → System Default) or
|
||||
// to a future explicit error signal from the output stream.
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(ref dev_name) = pinned {
|
||||
if !output_enumeration_includes_pinned(&available, dev_name) {
|
||||
pinned_miss_count += 1;
|
||||
if pinned_miss_count < 3 {
|
||||
continue;
|
||||
}
|
||||
crate::app_eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
|
||||
pinned_miss_count = 0;
|
||||
*selected_device.lock().unwrap() = None;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let reopened = reopen_output_stream(&app, None, ReopenNotify::DeviceReset).await;
|
||||
if !reopened {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out (pinned disconnect)");
|
||||
}
|
||||
|
||||
last_default = current_default;
|
||||
} else {
|
||||
pinned_miss_count = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 1: no pinned device, system default changed ──────────────
|
||||
if current_default == last_default {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
||||
//! Deduped emits for normalization UI and partial loudness analysis.
|
||||
use serde::Serialize;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PartialLoudnessPayload {
|
||||
pub(crate) track_id: Option<String>,
|
||||
pub(crate) gain_db: f32,
|
||||
pub(crate) target_lufs: f32,
|
||||
pub(crate) is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NormalizationStatePayload {
|
||||
pub(crate) engine: String,
|
||||
pub(crate) current_gain_db: Option<f32>,
|
||||
pub(crate) target_lufs: f32,
|
||||
}
|
||||
|
||||
/// Last `audio:normalization-state` emit, kept so we can suppress duplicate
|
||||
/// payloads. The frontend already debounces this event, but on Windows
|
||||
/// (WebView2) the IPC pipe is the bottleneck — every echo we skip here is
|
||||
/// renderer-thread time we don't pay.
|
||||
pub(crate) static LAST_NORM_STATE_EMIT: OnceLock<Mutex<Option<NormalizationStatePayload>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn norm_state_lock() -> &'static Mutex<Option<NormalizationStatePayload>> {
|
||||
LAST_NORM_STATE_EMIT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub(crate) fn norm_state_changed(prev: &NormalizationStatePayload, next: &NormalizationStatePayload) -> bool {
|
||||
if prev.engine != next.engine { return true; }
|
||||
if (prev.target_lufs - next.target_lufs).abs() >= 0.02 { return true; }
|
||||
match (prev.current_gain_db, next.current_gain_db) {
|
||||
(None, None) => false,
|
||||
(Some(a), Some(b)) => (a - b).abs() >= 0.05,
|
||||
_ => true, // None ↔ Some transition is significant
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_emit_normalization_state(app: &AppHandle, payload: NormalizationStatePayload) {
|
||||
let mut guard = norm_state_lock().lock().unwrap();
|
||||
let should_emit = match guard.as_ref() {
|
||||
Some(prev) => norm_state_changed(prev, &payload),
|
||||
None => true,
|
||||
};
|
||||
if !should_emit { return; }
|
||||
*guard = Some(payload.clone());
|
||||
drop(guard);
|
||||
let _ = app.emit("audio:normalization-state", payload);
|
||||
}
|
||||
|
||||
/// Last `analysis:loudness-partial` gain emitted per track-identity, used to
|
||||
/// suppress emits whose gain hasn't moved meaningfully (≥ 0.1 dB). The partial
|
||||
/// heuristic in `emit_partial_loudness_from_bytes` and the ranged-progress curve
|
||||
/// both produce values that drift by hundredths of a dB even on identical input,
|
||||
/// so the time-based throttle alone is not enough to keep the loop quiet.
|
||||
pub(crate) static LAST_PARTIAL_LOUDNESS_EMIT: OnceLock<Mutex<std::collections::HashMap<String, f32>>> = OnceLock::new();
|
||||
pub(crate) const PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB: f32 = 0.1;
|
||||
|
||||
pub(crate) fn partial_loudness_should_emit(track_key: &str, gain_db: f32) -> bool {
|
||||
let mut guard = LAST_PARTIAL_LOUDNESS_EMIT
|
||||
.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
.lock()
|
||||
.unwrap();
|
||||
let prev = guard.get(track_key).copied();
|
||||
if let Some(p) = prev {
|
||||
if (p - gain_db).abs() < PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
guard.insert(track_key.to_string(), gain_db);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload(engine: &str, gain: Option<f32>, target: f32) -> NormalizationStatePayload {
|
||||
NormalizationStatePayload {
|
||||
engine: engine.to_string(),
|
||||
current_gain_db: gain,
|
||||
target_lufs: target,
|
||||
}
|
||||
}
|
||||
|
||||
// ── norm_state_changed ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn norm_state_unchanged_for_identical_payloads() {
|
||||
let p = payload("loudness", Some(-3.0), -14.0);
|
||||
assert!(!norm_state_changed(&p, &p.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_engine_differs() {
|
||||
let a = payload("off", Some(0.0), -14.0);
|
||||
let b = payload("loudness", Some(0.0), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_ignores_micro_target_lufs_drift_below_two_centibels() {
|
||||
let a = payload("loudness", Some(-3.0), -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -14.01);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_target_lufs_moves_at_least_2_centibels() {
|
||||
let a = payload("loudness", Some(-3.0), -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -13.97);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_ignores_micro_gain_drift_below_5_centibels() {
|
||||
let a = payload("loudness", Some(-3.00), -14.0);
|
||||
let b = payload("loudness", Some(-3.04), -14.0);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_gain_moves_at_least_5_centibels() {
|
||||
let a = payload("loudness", Some(-3.00), -14.0);
|
||||
let b = payload("loudness", Some(-3.06), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_gain_appears_or_disappears() {
|
||||
let a = payload("loudness", None, -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
assert!(norm_state_changed(&b, &a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_unchanged_when_both_gains_none() {
|
||||
let a = payload("off", None, -14.0);
|
||||
let b = payload("off", None, -14.0);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
// ── partial_loudness_should_emit ──────────────────────────────────────────
|
||||
//
|
||||
// Note: this function reads/writes a process-global static map. Tests share
|
||||
// that state, so each test uses a unique track-key to avoid cross-test
|
||||
// pollution. (Don't run tests in parallel that share keys.)
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_emits_on_first_call_for_a_track_key() {
|
||||
let key = "test-emits-first-call";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_suppresses_micro_drift_below_threshold() {
|
||||
let key = "test-emits-micro-drift";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
assert!(
|
||||
!partial_loudness_should_emit(key, -3.05),
|
||||
"delta < 0.1 dB is suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_emits_again_when_threshold_is_crossed() {
|
||||
let key = "test-emits-after-threshold";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
assert!(partial_loudness_should_emit(key, -3.5), "delta >= 0.1 dB re-emits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_treats_each_track_key_independently() {
|
||||
assert!(partial_loudness_should_emit("track-A-independent", -3.0));
|
||||
assert!(
|
||||
partial_loudness_should_emit("track-B-independent", -3.0),
|
||||
"different track keys do not share suppression state"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//! `psysonic-audio` — Symphonia decode, rodio output, HTTP radio/streaming,
|
||||
//! gapless, previews. Submodules (`sources`, `decode`, `stream`, `commands`, …)
|
||||
//! preserve the historical single `audio.rs` partitioning.
|
||||
|
||||
// Re-export the logging facade so submodules can keep using
|
||||
// `crate::app_eprintln!()` / `crate::app_deprintln!()`.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
|
||||
pub mod autoeq_commands;
|
||||
mod analysis_dispatch;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
mod decode;
|
||||
mod dev_io;
|
||||
pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod playback_rate;
|
||||
mod sink_swap;
|
||||
mod source_build;
|
||||
mod preserve_worker;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
pub mod radio_commands;
|
||||
pub mod transport_commands;
|
||||
mod device_resume;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
mod stream_idle;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
mod state;
|
||||
mod stream;
|
||||
|
||||
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
|
||||
pub use device_watcher::start_device_watcher;
|
||||
pub use stream_idle::start_stream_idle_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::{
|
||||
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
|
||||
take_stream_completed_spill_for_url,
|
||||
};
|
||||
|
||||
/// Register platform-specific listeners so the output stream is reopened after sleep/resume
|
||||
/// when the device name may be unchanged (Windows WASAPI, Linux PipeWire, …).
|
||||
pub fn register_post_sleep_audio_recovery(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
power_notify_win::register(app);
|
||||
#[cfg(target_os = "linux")]
|
||||
power_notify_linux::register(app);
|
||||
// macOS intentionally falls through for now: we only ship native resume hooks
|
||||
// where we have verified regressions (Windows WASAPI, Linux logind/PipeWire).
|
||||
// macOS currently relies on the generic device watcher path.
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
not(target_os = "linux")
|
||||
))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
pub use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer, stop_audio_engine};
|
||||
@@ -1,316 +0,0 @@
|
||||
//! Audio-stage settings commands: volume, replay-gain / loudness normalization,
|
||||
//! 10-band EQ, crossfade, gapless.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let url_for_loudness = if norm_mode == 2 {
|
||||
state.current_playback_url.lock().unwrap().clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let logical_for_loudness = state
|
||||
.current_analysis_track_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
|
||||
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
|
||||
let server_for_loudness = crate::helpers::current_playback_server_id_str(&state);
|
||||
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
|
||||
resolve_loudness_gain_from_cache_impl(
|
||||
&app,
|
||||
u,
|
||||
target_lufs,
|
||||
logical_for_loudness.as_deref(),
|
||||
&server_for_loudness,
|
||||
ResolveLoudnessCacheOpts {
|
||||
touch_waveform: false,
|
||||
log_soft_misses: false,
|
||||
},
|
||||
)
|
||||
});
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
match url_for_loudness.as_deref() {
|
||||
Some(_u) => loudness_gain_db_after_resolve(
|
||||
cache_loudness,
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
true,
|
||||
loudness_gain_db,
|
||||
),
|
||||
None => {
|
||||
loudness_gain_db.or(Some(loudness_gain_placeholder_until_cache(
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loudness_gain_db
|
||||
};
|
||||
let (gain_linear, effective) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
|
||||
normalization_engine_name(norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
loudness_gain_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
||||
// ramp A back to full gain before the handoff swap.
|
||||
return;
|
||||
}
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
|
||||
for (i, &gain) in gains.iter().enumerate() {
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
||||
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
||||
#[tauri::command]
|
||||
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
||||
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let Some(sink) = cur.sink.as_ref() else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(true, Ordering::Relaxed);
|
||||
cancel_sink_volume_ramp();
|
||||
let from = sink_volume_now(sink);
|
||||
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
||||
}
|
||||
|
||||
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
||||
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
||||
/// (only when the next track is actually playable). When `false`, the engine's
|
||||
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
|
||||
#[tauri::command]
|
||||
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state
|
||||
.autodj_suppress_autocrossfade
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
strategy: String,
|
||||
speed: f32,
|
||||
pitch_semitones: f32,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
use crate::playback_rate::{
|
||||
content_position_from_samples, is_effect_active, rate_change_needs_restamp,
|
||||
raw_counter_samples_for_content_position, STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED, STRATEGY_VARISPEED,
|
||||
};
|
||||
|
||||
let clamped_speed = speed.clamp(0.5, 2.0);
|
||||
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
||||
let old_strat = state.playback_rate.load_strategy();
|
||||
let old_speed = state.playback_rate.load_speed();
|
||||
let was_active = is_effect_active(&state.playback_rate);
|
||||
let new_strat = match strategy.as_str() {
|
||||
"preserve_pitch" => STRATEGY_PRESERVE_PITCH,
|
||||
"speed_corrected" => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
};
|
||||
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
||||
|
||||
// Will the *new* config leave the rate effect active?
|
||||
let new_active = enabled
|
||||
&& match new_strat {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(clamped_speed - 1.0).abs() > 0.001 || clamped_pitch.abs() > 0.001
|
||||
}
|
||||
_ => (clamped_speed - 1.0).abs() > 0.001,
|
||||
};
|
||||
|
||||
// Preserve the content (song) position across any change to the
|
||||
// sample-counter ↔ position mapping: an active↔neutral toggle (enable /
|
||||
// disable, or speed crossing 1.0×) OR a speed change while active. Scoped to
|
||||
// the preserve-pitch DSP family and same strategy — varispeed has no
|
||||
// content/raw factor, and a strategy switch is out of scope here.
|
||||
//
|
||||
// The old condition only restamped active→active, so every enable/disable
|
||||
// toggle reinterpreted `samples_played` under the new factor and jumped the
|
||||
// position (≈ raw_secs × Δspeed — e.g. ±18 s at the 180 s mark on a ±10%
|
||||
// toggle; this is what broke Orbit drift correction).
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
let restamp_content = if sample_rate > 0
|
||||
&& channels > 0
|
||||
&& rate_change_needs_restamp(old_strat, new_strat, was_active, new_active, speed_changed)
|
||||
{
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.playback_rate
|
||||
.enabled
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.strategy
|
||||
.store(new_strat, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.speed
|
||||
.store(clamped_speed.to_bits(), Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.pitch_semitones
|
||||
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
||||
|
||||
if let Some(content_secs) = restamp_content {
|
||||
// Always re-derive the counter for the NEW config — including the
|
||||
// neutral case (raw_counter_… maps content == raw there), which is
|
||||
// exactly the active↔neutral transition the old is_effect_active gate
|
||||
// skipped.
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let mode = match engine.as_str() {
|
||||
"replaygain" => 1,
|
||||
"loudness" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
state.normalization_engine.store(mode, Ordering::Relaxed);
|
||||
let target = target_lufs.clamp(-30.0, -8.0);
|
||||
state
|
||||
.normalization_target_lufs
|
||||
.store(target.to_bits(), Ordering::Relaxed);
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.store(pre.to_bits(), Ordering::Relaxed);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}",
|
||||
engine,
|
||||
normalization_engine_name(mode),
|
||||
target,
|
||||
pre
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(mode).to_string(),
|
||||
// At mode-switch time the effective track gain may not be recalculated yet.
|
||||
// Emit `None` and let audio_play/audio_update_replay_gain publish actual value.
|
||||
current_gain_db: None,
|
||||
target_lufs: target,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
//! Source-selection logic for `audio_play`: given a URL + various caches +
|
||||
//! Subsonic hints, decide whether to play from in-memory bytes, a seekable
|
||||
//! local file, a seekable RangedHttpSource, or a non-seekable streaming reader.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||
same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
use super::stream::{
|
||||
ranged_download_task, track_download_task, AudioStreamReader,
|
||||
LocalFileSource, RangedHttpSource,
|
||||
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
||||
};
|
||||
|
||||
/// What `audio_play` will hand to `build_source` / `build_streaming_source`.
|
||||
pub(crate) enum PlayInput {
|
||||
Bytes(Vec<u8>),
|
||||
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
|
||||
/// `LocalFileSource` for `psysonic-local://` files. Goes through
|
||||
/// `build_streaming_source` (no iTunSMPB scan, since we don't have the
|
||||
/// bytes in memory; chained-track gapless trim still applies via the
|
||||
/// re-played `Bytes` path on the next start).
|
||||
SeekableMedia {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
|
||||
/// seekability through the probe so its seek path does not panic.
|
||||
random_access: bool,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
Streaming {
|
||||
reader: AudioStreamReader,
|
||||
format_hint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inputs `audio_play` has already computed before source selection.
|
||||
pub(super) struct PlayInputContext<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub duration_hint: f64,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub format_hint: Option<&'a str>,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
/// Playback server scope for the analysis-cache write key (empty/`None` →
|
||||
/// legacy `''`). Rides alongside `cache_id_for_tasks` into every seed path.
|
||||
pub server_id: Option<&'a str>,
|
||||
/// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes
|
||||
/// from the chained-info block.
|
||||
pub reuse_chained_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn spawn_playback_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
ctx: &PlayInputContext<'_>,
|
||||
origin: TrackAnalysisOrigin,
|
||||
bytes: Vec<u8>,
|
||||
) {
|
||||
let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
origin,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
bytes,
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the play input for `audio_play` honouring (in priority order):
|
||||
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
||||
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
||||
/// 3. Remote HTTP without preload/stream-cache hit — try ranged HTTP, fall
|
||||
/// back to non-seekable AudioStreamReader.
|
||||
/// 4. Preload/stream-cache hit — replay in-memory bytes via `fetch_data`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the operation was superseded by a later
|
||||
/// `audio_play` call (generation bump) — caller should bail out silently.
|
||||
pub(super) async fn select_play_input(
|
||||
ctx: PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
if let Some(d) = ctx.reuse_chained_bytes {
|
||||
if let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
d.clone(),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
return Ok(Some(PlayInput::Bytes(d)));
|
||||
}
|
||||
|
||||
let stream_cache_hit = {
|
||||
let streamed = state.stream_completed_cache.lock().unwrap();
|
||||
streamed
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let preloaded_hit = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let is_local = ctx.url.starts_with("psysonic-local://");
|
||||
|
||||
if is_local && !stream_cache_hit && !preloaded_hit {
|
||||
return Ok(Some(open_local_file_input(&ctx, state, app)?));
|
||||
}
|
||||
if !stream_cache_hit && !preloaded_hit && !is_local {
|
||||
return open_ranged_or_streaming_input(&ctx, state, app).await;
|
||||
}
|
||||
|
||||
// Preloaded or stream-cache hit → replay in-memory bytes.
|
||||
let data = match fetch_data(ctx.url, state, ctx.gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Ok(None), // superseded while downloading
|
||||
};
|
||||
spawn_playback_analysis_bytes(
|
||||
app,
|
||||
state,
|
||||
&ctx,
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
data.clone(),
|
||||
);
|
||||
Ok(Some(PlayInput::Bytes(data)))
|
||||
}
|
||||
|
||||
/// `psysonic-local://<path>` → seekable `LocalFileSource`. Spawns a
|
||||
/// background CPU-seed for the analysis cache when the file is small
|
||||
/// enough (skipped if the cache already has a row for this track).
|
||||
fn open_local_file_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlayInput, String> {
|
||||
let path = ctx.url.strip_prefix("psysonic-local://").unwrap();
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let local_hint = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase());
|
||||
crate::app_deprintln!(
|
||||
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
|
||||
len / 1024,
|
||||
local_hint
|
||||
);
|
||||
if let Some(seed_id) = ctx.cache_id_for_tasks {
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
seed_id.to_string(),
|
||||
std::path::PathBuf::from(path),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
let reader = LocalFileSource { file, len };
|
||||
Ok(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Manual or auto-advance starts that aren't already cached: try ranged HTTP
|
||||
/// (seekable) first, fall back to a non-seekable `AudioStreamReader` if the
|
||||
/// server doesn't advertise byte-range support or a length.
|
||||
async fn open_ranged_or_streaming_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
|
||||
let response = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||
return Ok(None); // superseded
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(ctx.stream_format_suffix))
|
||||
.or_else(|| ctx.format_hint.map(|s| s.to_string()));
|
||||
|
||||
let supports_range = response.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
match pr.bytes().await {
|
||||
Ok(bytes) if !bytes.is_empty() => {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
if stream_hint.is_some() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: format sniff from {} B prefix → hint={:?}",
|
||||
bytes.len(),
|
||||
stream_hint
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
state.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
let playback_armed = state.stream_playback_armed.clone();
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch =
|
||||
super::stream::mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| super::stream::RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
let loudness_hold_for_defer = (total_usize <= super::stream::TRACK_STREAM_PROMOTE_MAX_BYTES)
|
||||
.then_some(state.ranged_loudness_seed_hold.clone());
|
||||
tokio::spawn(ranged_download_task(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.duration_hint,
|
||||
ctx.url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers.clone(),
|
||||
loudness_hold_for_defer,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
// On-demand random-access fetcher: lets seeks (Ogg bisection, end-of-
|
||||
// stream probe, forward scrubs) pull arbitrary byte ranges over HTTP
|
||||
// Range instead of blocking until the linear filler reaches the target.
|
||||
// This is what makes seeking work on a still-downloading Opus/Ogg stream
|
||||
// (previously a contained no-op) without forcing a full pre-download.
|
||||
let on_demand = Some(Arc::new(super::stream::OnDemand::new(
|
||||
audio_http_client(state),
|
||||
tokio::runtime::Handle::current(),
|
||||
ctx.url.to_string(),
|
||||
buf.clone(),
|
||||
total,
|
||||
state.generation.clone(),
|
||||
ctx.gen,
|
||||
http_headers.clone(),
|
||||
)));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
on_demand,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
// The on-demand fetcher makes a seek-to-EOF during the probe cheap,
|
||||
// so Ogg can stay seekable through the probe (records its byte range
|
||||
// → real seeking) without forcing a full download.
|
||||
random_access: true,
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
|
||||
// Legacy non-seekable streaming reader fallback.
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range, total_size, stream_hint
|
||||
);
|
||||
let buffer_cap = total_size
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
|
||||
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
|
||||
let rb = HeapRb::<u8>::new(buffer_cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
state.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
let playback_armed = state.stream_playback_armed.clone();
|
||||
tokio::spawn(track_download_task(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.url.to_string(),
|
||||
response,
|
||||
prod,
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers,
|
||||
playback_armed,
|
||||
));
|
||||
|
||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
let reader = AudioStreamReader {
|
||||
read_timeout_secs: TRACK_READ_TIMEOUT_SECS,
|
||||
cons: Mutex::new(cons),
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now()
|
||||
+ Duration::from_secs(TRACK_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
source_tag: "track-stream",
|
||||
eof_when_empty: Some(done),
|
||||
pos: 0,
|
||||
};
|
||||
Ok(Some(PlayInput::Streaming {
|
||||
reader,
|
||||
format_hint: stream_hint,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
|
||||
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
/// don't latch onto random query-param substrings; only accept short
|
||||
/// alphanumeric tails that look like an actual audio extension.
|
||||
pub(crate) fn url_format_hint(url: &str) -> Option<String> {
|
||||
url.split('?').next()
|
||||
.and_then(|path| path.rsplit('.').next())
|
||||
.filter(|ext| {
|
||||
(1..=5).contains(&ext.len())
|
||||
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
&& matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4"
|
||||
| "aac" | "wav" | "wave" | "ape" | "wv" | "webm" | "mka"
|
||||
)
|
||||
})
|
||||
.map(|s| s.to_lowercase())
|
||||
}
|
||||
@@ -1,734 +0,0 @@
|
||||
//! Global playback speed / pitch strategies (varispeed, speed-corrected, preserve pitch).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::SeekError;
|
||||
use rodio::{ChannelCount, SampleRate, Source};
|
||||
|
||||
use crate::preserve_worker::PreserveOffload;
|
||||
|
||||
pub const STRATEGY_VARISPEED: u32 = 0;
|
||||
pub const STRATEGY_PRESERVE_PITCH: u32 = 1;
|
||||
pub const STRATEGY_SPEED_CORRECTED: u32 = 2;
|
||||
|
||||
pub(crate) const PRESERVE_MAKEUP_GAIN: f32 = 1.35;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybackRateAtomics {
|
||||
pub enabled: Arc<AtomicBool>,
|
||||
pub strategy: Arc<AtomicU32>,
|
||||
pub speed: Arc<AtomicU32>,
|
||||
pub pitch_semitones: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Default for PlaybackRateAtomics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: Arc::new(AtomicBool::new(false)),
|
||||
strategy: Arc::new(AtomicU32::new(STRATEGY_SPEED_CORRECTED)),
|
||||
speed: Arc::new(AtomicU32::new(1.0f32.to_bits())),
|
||||
pitch_semitones: Arc::new(AtomicU32::new(0.0f32.to_bits())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackRateAtomics {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn load_speed(&self) -> f32 {
|
||||
f32::from_bits(self.speed.load(Ordering::Relaxed)).clamp(0.5, 2.0)
|
||||
}
|
||||
|
||||
pub fn load_pitch(&self) -> f32 {
|
||||
f32::from_bits(self.pitch_semitones.load(Ordering::Relaxed)).clamp(-12.0, 12.0)
|
||||
}
|
||||
|
||||
pub fn load_strategy(&self) -> u32 {
|
||||
match self.strategy.load(Ordering::Relaxed) {
|
||||
STRATEGY_PRESERVE_PITCH => STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_preserve_dsp(strategy: u32) -> bool {
|
||||
strategy == STRATEGY_PRESERVE_PITCH || strategy == STRATEGY_SPEED_CORRECTED
|
||||
}
|
||||
|
||||
pub fn effective_pitch(atomics: &PlaybackRateAtomics) -> f32 {
|
||||
if atomics.load_strategy() == STRATEGY_PRESERVE_PITCH {
|
||||
atomics.load_pitch()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
|
||||
if !atomics.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
let speed = atomics.load_speed();
|
||||
match atomics.load_strategy() {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(speed - 1.0).abs() > 0.001 || atomics.load_pitch().abs() > 0.001
|
||||
}
|
||||
_ => (speed - 1.0).abs() > 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a playback-rate config change must restamp the sample counter to keep
|
||||
/// the content (song) position stable.
|
||||
///
|
||||
/// The counter ↔ position factor is `speed` while the preserve-pitch effect is
|
||||
/// active and `1.0` while neutral (see [`effective_position_secs`]). So any
|
||||
/// transition that flips active↔neutral, or changes speed while staying active,
|
||||
/// changes that factor and needs a restamp. Scoped to the preserve-pitch DSP
|
||||
/// family with an unchanged strategy: varispeed has no content/raw factor, and a
|
||||
/// strategy switch is handled elsewhere.
|
||||
pub(crate) fn rate_change_needs_restamp(
|
||||
old_strategy: u32,
|
||||
new_strategy: u32,
|
||||
was_active: bool,
|
||||
now_active: bool,
|
||||
speed_changed: bool,
|
||||
) -> bool {
|
||||
uses_preserve_dsp(old_strategy)
|
||||
&& new_strategy == old_strategy
|
||||
&& (was_active != now_active || (was_active && now_active && speed_changed))
|
||||
}
|
||||
|
||||
/// True when preserve-pitch DSP (background worker) should run for this track.
|
||||
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
|
||||
atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(atomics)
|
||||
}
|
||||
|
||||
/// Content timeline length for seek bar / duration labels (always the full track).
|
||||
pub fn effective_duration_secs(base_secs: f64, _atomics: &PlaybackRateAtomics) -> f64 {
|
||||
base_secs
|
||||
}
|
||||
|
||||
/// Map counter-derived seconds to timeline position for UI / near-end checks.
|
||||
pub fn effective_position_secs(raw_secs: f64, atomics: &PlaybackRateAtomics) -> f64 {
|
||||
if !is_effect_active(atomics) {
|
||||
return raw_secs;
|
||||
}
|
||||
if atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
return raw_secs;
|
||||
}
|
||||
// Preserve DSP outputs at the base sample rate; scale to content timeline.
|
||||
raw_secs * atomics.load_speed() as f64
|
||||
}
|
||||
|
||||
/// Sample-counter position mapped to the content timeline (seek bar / labels).
|
||||
pub(crate) fn content_position_from_samples(
|
||||
samples: u64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> f64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
effective_position_secs(samples as f64 / divisor, atomics)
|
||||
}
|
||||
|
||||
/// Counter value that matches `content_position_from_samples` after a content-timeline seek.
|
||||
pub(crate) fn raw_counter_samples_for_content_position(
|
||||
content_secs: f64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> u64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
let raw_secs = if is_effect_active(atomics)
|
||||
&& atomics.load_strategy() != STRATEGY_VARISPEED
|
||||
{
|
||||
content_secs / atomics.load_speed().max(0.001) as f64
|
||||
} else {
|
||||
content_secs
|
||||
};
|
||||
(raw_secs * divisor).round() as u64
|
||||
}
|
||||
|
||||
pub(crate) fn preserve_out_samples(speed: f32) -> usize {
|
||||
(128.0f32 / speed.clamp(0.5, 2.0)).round() as usize
|
||||
}
|
||||
|
||||
pub struct PlaybackRateSource<S: Source<Item = f32> + Send + 'static> {
|
||||
inner: Option<S>,
|
||||
base_sample_rate: SampleRate,
|
||||
base_channels: ChannelCount,
|
||||
atomics: PlaybackRateAtomics,
|
||||
offload: Option<PreserveOffload>,
|
||||
handback_rx: Option<mpsc::Receiver<S>>,
|
||||
handback_requested: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> PlaybackRateSource<S> {
|
||||
pub fn new(inner: S, atomics: PlaybackRateAtomics) -> Self {
|
||||
let base_sample_rate = inner.sample_rate();
|
||||
let base_channels = inner.channels();
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
base_sample_rate,
|
||||
base_channels,
|
||||
atomics,
|
||||
offload: None,
|
||||
handback_rx: None,
|
||||
handback_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_handback(&mut self) {
|
||||
let Some(rx) = &self.handback_rx else {
|
||||
return;
|
||||
};
|
||||
if let Ok(inner) = rx.try_recv() {
|
||||
self.inner = Some(inner);
|
||||
self.handback_rx = None;
|
||||
self.handback_requested = false;
|
||||
if let Some(offload) = self.offload.take() {
|
||||
offload.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_handback_if_needed(&mut self) {
|
||||
if self.inner.is_some() || self.handback_requested {
|
||||
return;
|
||||
}
|
||||
if let Some(offload) = &self.offload {
|
||||
offload.request_handback();
|
||||
self.handback_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_offload(&mut self) {
|
||||
if self.offload.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(inner) = self.inner.take() {
|
||||
let (handback_tx, handback_rx) = mpsc::sync_channel(1);
|
||||
self.handback_rx = Some(handback_rx);
|
||||
self.offload = Some(PreserveOffload::spawn(
|
||||
inner,
|
||||
self.atomics.clone(),
|
||||
self.base_sample_rate.get(),
|
||||
self.base_channels.get(),
|
||||
handback_tx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_sample_rate(&self) -> SampleRate {
|
||||
self.inner
|
||||
.as_ref()
|
||||
.map(Source::sample_rate)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
}
|
||||
|
||||
fn try_recover_inner_from_offload(&mut self) {
|
||||
if self.inner.is_some() || self.offload.is_none() {
|
||||
return;
|
||||
}
|
||||
self.request_handback_if_needed();
|
||||
self.poll_handback();
|
||||
}
|
||||
|
||||
fn next_from_inner_or_pad(&mut self) -> Option<f32> {
|
||||
self.try_recover_inner_from_offload();
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
return inner.next();
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Iterator for PlaybackRateSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if !is_effect_active(&self.atomics) {
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
if let Some(s) = offload.pop() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
return self.next_from_inner_or_pad();
|
||||
}
|
||||
|
||||
if uses_preserve_dsp(self.atomics.load_strategy()) {
|
||||
self.ensure_offload();
|
||||
if let Some(s) = self.offload.as_mut().and_then(|o| o.pop()) {
|
||||
return Some(s);
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Varispeed: decoder must stay in `inner` (never in the preserve worker).
|
||||
if self.offload.is_some() {
|
||||
self.try_recover_inner_from_offload();
|
||||
}
|
||||
self.next_from_inner_or_pad()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Source for PlaybackRateSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.as_ref()?.current_span_len()
|
||||
}
|
||||
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.base_channels
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
if is_effect_active(&self.atomics) && self.atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
let factor = self.atomics.load_speed().max(0.001);
|
||||
SampleRate::new((self.base_sample_rate().get() as f32 * factor).max(1.0) as u32)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
} else {
|
||||
self.base_sample_rate()
|
||||
}
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.inner.as_ref()?.total_duration()
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
// UI / transport always pass content-timeline seconds (0..full track).
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.try_seek(pos)?;
|
||||
}
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
offload.request_seek(pos);
|
||||
offload.drain();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
|
||||
#[test]
|
||||
fn passthrough_when_disabled() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_at_unity() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_when_speed_not_one() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_duration_is_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
for strat in [
|
||||
STRATEGY_VARISPEED,
|
||||
STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_PRESERVE_PITCH,
|
||||
] {
|
||||
a.strategy.store(strat, Ordering::Relaxed);
|
||||
assert!(
|
||||
(effective_duration_secs(200.0, &a) - 200.0).abs() < 0.001,
|
||||
"strategy {strat}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_varispeed_uses_counter() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(20.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_preserve_scales_with_speed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(10.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_inactive_is_raw() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!((effective_position_secs(15.0, &a) - 15.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(120.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 120.0).abs() < 0.05, "roundtrip at 2x preserve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_varispeed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(90.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 90.0).abs() < 0.05, "roundtrip at 2x varispeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_seek_uses_content_timeline() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct SeekSpy {
|
||||
rate: SampleRate,
|
||||
last_seek_secs: Arc<AtomicU64>,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for SeekSpy {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SeekSpy {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
ChannelCount::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.rate
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(200))
|
||||
}
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
self.last_seek_secs
|
||||
.store(pos.as_secs_f64().to_bits(), AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let last = Arc::new(AtomicU64::new(f64::NAN.to_bits()));
|
||||
let spy = SeekSpy {
|
||||
rate: SampleRate::new(44_100).unwrap(),
|
||||
last_seek_secs: last.clone(),
|
||||
remaining: 44_100,
|
||||
};
|
||||
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(spy, a);
|
||||
src.try_seek(Duration::from_secs(120)).unwrap();
|
||||
let got = f64::from_bits(last.load(AtomicOrdering::Relaxed));
|
||||
assert!(
|
||||
(got - 120.0).abs() < 0.001,
|
||||
"varispeed seek must not scale content position, got {got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_out_samples_clamped() {
|
||||
assert_eq!(preserve_out_samples(2.0), 64);
|
||||
assert_eq!(preserve_out_samples(0.5), 256);
|
||||
}
|
||||
|
||||
struct FixedRateSource {
|
||||
rate: u32,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for FixedRateSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for FixedRateSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_corrected_uses_preserve_dsp_path() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(uses_preserve_dsp(atomics.load_strategy()));
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_respects_manual_pitch() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.pitch_semitones.store(3.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_switch_preserve_to_varispeed_does_not_end_early() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 50_000,
|
||||
},
|
||||
atomics.clone(),
|
||||
);
|
||||
for _ in 0..5_000 {
|
||||
assert!(src.next().is_some());
|
||||
}
|
||||
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
|
||||
let mut got = 0usize;
|
||||
for _ in 0..2_000 {
|
||||
if src.next().is_some() {
|
||||
got += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got > 100,
|
||||
"varispeed should continue after preserve strategy switch, got {got} samples"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_scales_reported_sample_rate() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
let src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
assert_eq!(src.sample_rate().get(), 66_150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_propagates_through_dyn_source() {
|
||||
use crate::sources::DynSource;
|
||||
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let rate_src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 48_000,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
let dyn_src = DynSource::new(rate_src);
|
||||
assert_eq!(dyn_src.sample_rate().get(), 96_000);
|
||||
}
|
||||
|
||||
fn rms_f32(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_makeup_keeps_level_reasonable() {
|
||||
let sr = 44_100f32;
|
||||
let mut input = [0.0f32; 128];
|
||||
for (i, s) in input.iter_mut().enumerate() {
|
||||
*s = (i as f32 * 0.12).sin() * 0.75;
|
||||
}
|
||||
let in_rms = rms_f32(&input);
|
||||
let mut shifter: Shifter<Box<[f32; TOTAL_F32]>> =
|
||||
Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
for _ in 0..24 {
|
||||
shifter.shift(&input, 4.0, 128, sr);
|
||||
}
|
||||
let dry = shifter.shift(&input, 4.0, 128, sr);
|
||||
let boosted: Vec<f32> = dry
|
||||
.iter()
|
||||
.map(|&s| (s * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0))
|
||||
.collect();
|
||||
let out_rms = rms_f32(&boosted);
|
||||
assert!(out_rms > in_rms * 0.8, "out_rms={out_rms} in_rms={in_rms}");
|
||||
assert!(out_rms < in_rms * 1.25, "out_rms={out_rms} in_rms={in_rms}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_speed_change_represerves_content_position() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let samples = raw_counter_samples_for_content_position(30.0, 44_100, 2, &atomics);
|
||||
let content = content_position_from_samples(samples, 44_100, 2, &atomics);
|
||||
assert!((content - 30.0).abs() < 0.05);
|
||||
|
||||
atomics.speed.store(1.8f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped =
|
||||
raw_counter_samples_for_content_position(content, 44_100, 2, &atomics);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
|
||||
assert!((after - 30.0).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_covers_active_neutral_toggles() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
// Both directions of an active↔neutral toggle need a restamp.
|
||||
assert!(rate_change_needs_restamp(sc, sc, false, true, true));
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, false, true));
|
||||
// Active→active with a speed change needs one too.
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, true, true));
|
||||
// Active→active with no speed change, and neutral→neutral, do not.
|
||||
assert!(!rate_change_needs_restamp(sc, sc, true, true, false));
|
||||
assert!(!rate_change_needs_restamp(sc, sc, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_skips_varispeed_and_strategy_switch() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
let vs = STRATEGY_VARISPEED;
|
||||
// Varispeed has no content/raw factor → never restamp.
|
||||
assert!(!rate_change_needs_restamp(vs, vs, false, true, true));
|
||||
// A strategy switch is out of scope for the restamp path.
|
||||
assert!(!rate_change_needs_restamp(sc, vs, true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restamp_keeps_position_across_active_neutral_toggle() {
|
||||
// The bug: toggling the effect on/off must not move the song position.
|
||||
// Start active at 1.10×, sitting at 180 s of content.
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.strategy.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(1.10f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(180.0, 44_100, 2, &a);
|
||||
assert!((content_position_from_samples(samples, 44_100, 2, &a) - 180.0).abs() < 0.05);
|
||||
|
||||
// Toggle to neutral (disabled). Without a restamp the position would
|
||||
// jump ~18 s (180 × 0.10); with it, the position is preserved.
|
||||
let old_content = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
a.enabled.store(false, Ordering::Relaxed);
|
||||
let restamped = raw_counter_samples_for_content_position(old_content, 44_100, 2, &a);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
assert!((after - 180.0).abs() < 0.05, "position jumped to {after}");
|
||||
|
||||
// Back to active at 0.90× — still stable.
|
||||
let old_content2 = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(0.90f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped2 = raw_counter_samples_for_content_position(old_content2, 44_100, 2, &a);
|
||||
let after2 = content_position_from_samples(restamped2, 44_100, 2, &a);
|
||||
assert!((after2 - 180.0).abs() < 0.05, "position jumped to {after2}");
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Linux: subscribe to logind `PrepareForSleep` on the system bus — `start == false` means resume
|
||||
//! completed (systemd says the boolean is true when going to sleep, false when waking).
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
let res = std::thread::Builder::new()
|
||||
.name("psysonic-logind-sleep".into())
|
||||
.spawn(move || run_listener(app));
|
||||
|
||||
if let Err(e) = res {
|
||||
crate::app_eprintln!("[psysonic] could not spawn logind listener: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_listener(app: AppHandle) {
|
||||
use zbus::blocking::{Connection, MessageIterator};
|
||||
use zbus::message::Type;
|
||||
use zbus::MatchRule;
|
||||
|
||||
let conn = match Connection::system() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] D-Bus system bus unavailable — post-sleep audio recovery disabled: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let rule: zbus::MatchRule = match (|| -> zbus::Result<_> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(Type::Signal)
|
||||
.path("/org/freedesktop/login1")?
|
||||
.interface("org.freedesktop.login1.Manager")?
|
||||
.member("PrepareForSleep")?
|
||||
.build())
|
||||
})() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] MatchRule for logind PrepareForSleep failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut iter = match MessageIterator::for_match_rule(rule, &conn, Some(32)) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind signal subscription failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::app_eprintln!("[psysonic] logind PrepareForSleep listener registered (post-sleep audio recovery)");
|
||||
|
||||
loop {
|
||||
let Some(result) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind message stream error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let start: bool = match msg.body().deserialize() {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if start {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Windows: `PowerRegisterSuspendResumeNotification` — resume from sleep without a default-device rename.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use windows::Win32::{
|
||||
Foundation::{ERROR_SUCCESS, HANDLE},
|
||||
System::Power::{PowerRegisterSuspendResumeNotification, DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS},
|
||||
UI::WindowsAndMessaging::{
|
||||
DEVICE_NOTIFY_CALLBACK, PBT_APMRESUMEAUTOMATIC, PBT_APMRESUMESUSPEND, PBT_APMRESUMESTANDBY,
|
||||
},
|
||||
};
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
unsafe extern "system" fn power_suspend_resume_callback(
|
||||
context: *const c_void,
|
||||
event_type: u32,
|
||||
_setting: *const c_void,
|
||||
) -> u32 {
|
||||
if context.is_null() {
|
||||
return 0;
|
||||
}
|
||||
if !matches!(
|
||||
event_type,
|
||||
PBT_APMRESUMEAUTOMATIC | PBT_APMRESUMESUSPEND | PBT_APMRESUMESTANDBY
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let app = unsafe { &*(context as *const AppHandle) }.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
// Intentionally leaked for process lifetime: Win32 callback receives this pointer
|
||||
// on each suspend/resume notification and may outlive this function scope.
|
||||
let app_leak = Box::into_raw(Box::new(app));
|
||||
|
||||
let params = Box::new(DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS {
|
||||
Callback: Some(power_suspend_resume_callback),
|
||||
Context: app_leak as *mut c_void,
|
||||
});
|
||||
// Intentionally leaked for process lifetime: the power subsystem keeps the
|
||||
// subscribe-parameters pointer after successful registration.
|
||||
let params_ptr = Box::into_raw(params);
|
||||
|
||||
let mut registration: *mut c_void = std::ptr::null_mut();
|
||||
let err = unsafe {
|
||||
PowerRegisterSuspendResumeNotification(
|
||||
DEVICE_NOTIFY_CALLBACK,
|
||||
HANDLE(params_ptr as *mut _),
|
||||
&mut registration as *mut *mut c_void,
|
||||
)
|
||||
};
|
||||
|
||||
if err != ERROR_SUCCESS {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] PowerRegisterSuspendResumeNotification failed: {:?} — post-sleep audio recovery disabled",
|
||||
err
|
||||
);
|
||||
unsafe {
|
||||
drop(Box::from_raw(params_ptr));
|
||||
drop(Box::from_raw(app_leak));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::app_eprintln!("[psysonic] Windows power suspend/resume notifications registered for audio");
|
||||
// `registration` is an opaque handle returned by Win32 API. It does not own
|
||||
// Rust resources, so dropping the local copy is fine; callback context is
|
||||
// intentionally leaked above for process-lifetime notifications.
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Reopen CPAL/rodio output after system sleep/resume when the old stream can be silent
|
||||
//! while the reported default device name is unchanged (Windows WASAPI, Linux PipeWire/ALSA, etc.).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::{request_stream_release, AudioEngine};
|
||||
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Returns false if this resume should be ignored (coalesce bursts from the OS).
|
||||
pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
let mut g = RESUME_REOPEN_DEBOUNCE.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if let Some(t) = *g {
|
||||
if now.duration_since(t) < DEBOUNCE {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*g = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Delay so the audio stack re-enumerates before we open a new stream.
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
return;
|
||||
};
|
||||
let engine = state.inner();
|
||||
|
||||
if !output_stream_is_needed(engine) {
|
||||
if engine.stream_handle.lock().unwrap().is_some() {
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
let _ = request_stream_release(engine);
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let device_name = engine.selected_device.lock().unwrap().clone();
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio: stream reopen failed or timed out after system resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
//! Background audio_preload: fetch the next track's bytes ahead of time
|
||||
//! and seed the analysis cache. Distinct from `audio_chain_preload`
|
||||
//! (which constructs the gapless source chain) and `audio_play` (which
|
||||
//! starts playback). All three live in this audio submodule.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PreloadEventPayload {
|
||||
url: String,
|
||||
track_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn seed_preload_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
data: &[u8],
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
app,
|
||||
TrackAnalysisOrigin::PrefetchOrCacheFile,
|
||||
&sid,
|
||||
&track_id,
|
||||
data.to_vec(),
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_preload_analysis_file(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
file_path: PathBuf,
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file analysis track_id={} path={}",
|
||||
track_id,
|
||||
file_path.display()
|
||||
);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
track_id,
|
||||
file_path,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-ready",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-cancelled",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
eager: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let track_id_for_events = logical_trim.clone();
|
||||
|
||||
let is_local = url.starts_with("psysonic-local://");
|
||||
|
||||
// Hot/offline cache: playback reads from disk — seed analysis from the file
|
||||
// (512 MiB cap) without copying into the RAM preload slot.
|
||||
if is_local {
|
||||
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
|
||||
if !path.is_file() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file missing path={}",
|
||||
path.display()
|
||||
);
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
seed_preload_analysis_file(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
path,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
);
|
||||
emit_preload_ready(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
|
||||
{
|
||||
let cached = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, &url))
|
||||
.map(|p| p.data.clone())
|
||||
};
|
||||
if let Some(data) = cached {
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
|
||||
// when the current track is long-settled) skip the wait so the RAM slot
|
||||
// fills in time for the fade to fire. If the user skips during the wait the
|
||||
// generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
if !eager.unwrap_or(false) {
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let response = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
emit_preload_ready(&app, url_for_emit, track_id_for_events);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
//! Background worker for preserve-pitch DSP (phase vocoder is too heavy for cpal callback).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
use ringbuf::traits::{Consumer, Observer, Producer, Split};
|
||||
use ringbuf::{HeapCons, HeapProd, HeapRb};
|
||||
use rodio::Source;
|
||||
|
||||
use crate::playback_rate::{
|
||||
effective_pitch, is_effect_active, preserve_out_samples, PlaybackRateAtomics, PRESERVE_MAKEUP_GAIN,
|
||||
uses_preserve_dsp,
|
||||
};
|
||||
|
||||
const FRAME_BLOCK: usize = 128;
|
||||
const PRESERVE_OUT_MAX: usize = 1023;
|
||||
const PRESERVE_PARAM_EPS_PITCH: f32 = 0.05;
|
||||
const PRESERVE_PARAM_EPS_SPEED: f32 = 0.001;
|
||||
const RB_MIN_CAPACITY: usize = 44_100 * 2 * 2; // ~2 s stereo @ 44.1 kHz
|
||||
const RB_TARGET_FILL: f32 = 0.6;
|
||||
const RB_FILL_HIGH: f32 = 0.88;
|
||||
const FORWARD_BATCH: usize = 4096;
|
||||
const WORKER_IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
enum WorkerCmd {
|
||||
Seek(Duration),
|
||||
Handback,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
struct PreserveWorkerEnv {
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
capacity: usize,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreserveOffload {
|
||||
cons: HeapCons<f32>,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_tx: SyncSender<WorkerCmd>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PreserveOffload {
|
||||
pub(crate) fn spawn<S: Source<Item = f32> + Send + 'static>(
|
||||
inner: S,
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
handback_tx: SyncSender<S>,
|
||||
) -> Self {
|
||||
let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY);
|
||||
let rb = HeapRb::<f32>::new(cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<WorkerCmd>(8);
|
||||
let stop_worker = stop.clone();
|
||||
let done_worker = done.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("psysonic-preserve-pitch".into())
|
||||
.spawn(move || {
|
||||
worker_main(
|
||||
inner,
|
||||
prod,
|
||||
PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity: cap,
|
||||
stop: stop_worker,
|
||||
done: done_worker,
|
||||
cmd_rx,
|
||||
},
|
||||
handback_tx,
|
||||
);
|
||||
})
|
||||
.expect("spawn preserve-pitch worker");
|
||||
|
||||
Self {
|
||||
cons,
|
||||
stop,
|
||||
done,
|
||||
cmd_tx,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pop(&mut self) -> Option<f32> {
|
||||
self.cons.try_pop()
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.done.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn request_seek(&self, pos: Duration) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Seek(pos));
|
||||
}
|
||||
|
||||
pub(crate) fn request_handback(&self) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Handback);
|
||||
}
|
||||
|
||||
pub(crate) fn drain(&mut self) {
|
||||
while self.cons.try_pop().is_some() {}
|
||||
}
|
||||
|
||||
pub(crate) fn join(mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PreserveOffload {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveChannelState {
|
||||
shifter: Shifter<Box<[f32; TOTAL_F32]>>,
|
||||
frame: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PreserveChannelState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
shifter: Shifter::new(Box::new([0.0; TOTAL_F32])),
|
||||
frame: Vec::with_capacity(FRAME_BLOCK),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
self.frame.clear();
|
||||
}
|
||||
|
||||
fn reset_shifter(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveState {
|
||||
channels: Vec<PreserveChannelState>,
|
||||
pending: VecDeque<f32>,
|
||||
channel_idx: usize,
|
||||
last_pitch: f32,
|
||||
last_speed: f32,
|
||||
}
|
||||
|
||||
impl PreserveState {
|
||||
fn for_channels(count: u16) -> Self {
|
||||
let n = count.max(1) as usize;
|
||||
Self {
|
||||
channels: (0..n).map(|_| PreserveChannelState::new()).collect(),
|
||||
pending: VecDeque::new(),
|
||||
channel_idx: 0,
|
||||
last_pitch: f32::NAN,
|
||||
last_speed: f32::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, channels: u16) {
|
||||
let n = channels.max(1) as usize;
|
||||
if self.channels.len() != n {
|
||||
self.channels = (0..n).map(|_| PreserveChannelState::new()).collect();
|
||||
} else {
|
||||
for ch in &mut self.channels {
|
||||
ch.reset();
|
||||
}
|
||||
}
|
||||
self.pending.clear();
|
||||
self.channel_idx = 0;
|
||||
self.last_pitch = f32::NAN;
|
||||
self.last_speed = f32::NAN;
|
||||
}
|
||||
|
||||
fn reset_if_params_changed(&mut self, pitch: f32, speed: f32) {
|
||||
if self.last_pitch.is_nan() {
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
return;
|
||||
}
|
||||
if (pitch - self.last_pitch).abs() > PRESERVE_PARAM_EPS_PITCH
|
||||
|| (speed - self.last_speed).abs() > PRESERVE_PARAM_EPS_SPEED
|
||||
{
|
||||
for ch in &mut self.channels {
|
||||
ch.reset_shifter();
|
||||
}
|
||||
self.pending.clear();
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_block(&mut self, speed: f32, pitch: f32, sample_rate: f32) {
|
||||
self.reset_if_params_changed(pitch, speed);
|
||||
let out_n = preserve_out_samples(speed).clamp(1, PRESERVE_OUT_MAX);
|
||||
let ch_count = self.channels.len();
|
||||
let mut outs: Vec<&[f32]> = Vec::with_capacity(ch_count);
|
||||
for ch in &mut self.channels {
|
||||
if ch.frame.len() == FRAME_BLOCK {
|
||||
let out = ch.shifter.shift(&ch.frame, pitch, out_n, sample_rate);
|
||||
outs.push(out);
|
||||
ch.frame.clear();
|
||||
}
|
||||
}
|
||||
if outs.len() != ch_count {
|
||||
return;
|
||||
}
|
||||
for i in 0..out_n {
|
||||
for out_slice in &outs {
|
||||
if let Some(&sample) = out_slice.get(i) {
|
||||
self.pending
|
||||
.push_back((sample * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ring_fill(prod: &HeapProd<f32>, capacity: usize) -> f32 {
|
||||
1.0 - prod.vacant_len() as f32 / capacity as f32
|
||||
}
|
||||
|
||||
fn push_pending(prod: &mut HeapProd<f32>, pending: &mut VecDeque<f32>, stop: &AtomicBool) {
|
||||
while let Some(&s) = pending.front() {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if prod.try_push(s).is_ok() {
|
||||
pending.pop_front();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_passthrough<S: Source<Item = f32>>(
|
||||
inner: &mut S,
|
||||
prod: &mut HeapProd<f32>,
|
||||
capacity: usize,
|
||||
stop: &AtomicBool,
|
||||
) -> bool {
|
||||
let target = (capacity as f32 * RB_TARGET_FILL) as usize;
|
||||
let mut pushed = 0usize;
|
||||
while prod.occupied_len() < target && pushed < FORWARD_BATCH {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Some(s) = inner.next() else {
|
||||
return false;
|
||||
};
|
||||
if prod.try_push(s).is_err() {
|
||||
break;
|
||||
}
|
||||
pushed += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn worker_main<S: Source<Item = f32> + Send>(
|
||||
mut inner: S,
|
||||
mut prod: HeapProd<f32>,
|
||||
env: PreserveWorkerEnv,
|
||||
handback_tx: SyncSender<S>,
|
||||
) {
|
||||
let PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity,
|
||||
stop,
|
||||
done,
|
||||
cmd_rx,
|
||||
} = env;
|
||||
let ch_count = channels.max(1) as usize;
|
||||
let mut preserve = PreserveState::for_channels(channels);
|
||||
let sr = sample_rate as f32;
|
||||
|
||||
'run: while !stop.load(Ordering::Acquire) {
|
||||
if let Ok(cmd) = cmd_rx.try_recv() {
|
||||
match cmd {
|
||||
WorkerCmd::Shutdown => break,
|
||||
WorkerCmd::Handback => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
WorkerCmd::Seek(pos) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let use_preserve = atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(&atomics);
|
||||
|
||||
if !use_preserve {
|
||||
preserve.reset(channels);
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
if !forward_passthrough(&mut inner, &mut prod, capacity, &stop) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
|
||||
if !preserve.pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match inner.next() {
|
||||
Some(s) => {
|
||||
let ch = preserve.channel_idx;
|
||||
preserve.channels[ch].frame.push(s);
|
||||
preserve.channel_idx = (ch + 1) % ch_count;
|
||||
if preserve
|
||||
.channels
|
||||
.iter()
|
||||
.all(|c| c.frame.len() >= FRAME_BLOCK)
|
||||
{
|
||||
preserve.process_block(
|
||||
atomics.load_speed(),
|
||||
effective_pitch(&atomics),
|
||||
sr,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
done.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::playback_rate::STRATEGY_PRESERVE_PITCH;
|
||||
use rodio::{ChannelCount, SampleRate};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
struct SineSource {
|
||||
remaining: usize,
|
||||
rate: u32,
|
||||
}
|
||||
|
||||
impl Iterator for SineSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.25)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SineSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(2).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<StdDuration> {
|
||||
Some(StdDuration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_prefills_ring_before_done() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.speed.store(1.25f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let src = SineSource {
|
||||
remaining: 44_100 * 2,
|
||||
rate: 44_100,
|
||||
};
|
||||
let (tx, _rx) = mpsc::sync_channel(1);
|
||||
let mut offload = PreserveOffload::spawn(src, atomics, 44_100, 2, tx);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let mut got = 0usize;
|
||||
for _ in 0..10_000 {
|
||||
if let Some(s) = offload.pop() {
|
||||
got += 1;
|
||||
if got > 500 {
|
||||
break;
|
||||
}
|
||||
let _ = s;
|
||||
} else if offload.is_done() {
|
||||
break;
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
assert!(got > 500, "expected prefetched samples, got {got}");
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
//! Per-generation progress + ended-detection task. Spawned once per
|
||||
//! `audio_play` / `audio_play_radio` invocation, the task ticks at 100 ms,
|
||||
//! emits `audio:progress` (throttled), handles the gapless transition
|
||||
//! when the current source exhausts and a chained successor is queued,
|
||||
//! and finally emits `audio:ended` when no successor exists.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
use super::engine::AudioCurrent;
|
||||
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
|
||||
use super::playback_rate::{effective_duration_secs, effective_position_secs, PlaybackRateAtomics};
|
||||
use super::state::ChainedInfo;
|
||||
|
||||
/// Sink for the three progress events the task emits. Production wraps an
|
||||
/// `AppHandle<R>` (any Tauri runtime) via the blanket impl below; tests pass
|
||||
/// a `MockProgressEmitter` that records every call.
|
||||
///
|
||||
/// Pulled out of `spawn_progress_task` so the timer-driven loop can be
|
||||
/// exercised against a mock emitter under `#[tokio::test(start_paused = true)]`
|
||||
/// without a live Tauri app.
|
||||
pub trait ProgressEmitter: Send + Sync + 'static {
|
||||
fn emit_progress(&self, payload: ProgressPayload);
|
||||
fn emit_track_switched(&self, duration_secs: f64);
|
||||
fn emit_ended(&self);
|
||||
}
|
||||
|
||||
impl<R: Runtime> ProgressEmitter for AppHandle<R> {
|
||||
fn emit_progress(&self, payload: ProgressPayload) {
|
||||
let _ = Emitter::emit(self, "audio:progress", payload);
|
||||
}
|
||||
fn emit_track_switched(&self, duration_secs: f64) {
|
||||
let _ = Emitter::emit(self, "audio:track_switched", duration_secs);
|
||||
}
|
||||
fn emit_ended(&self) {
|
||||
let _ = Emitter::emit(self, "audio:ended", ());
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the per-generation progress + ended-detection task.
|
||||
///
|
||||
/// The task owns a local `done: Arc<AtomicBool>` reference that starts as
|
||||
/// the current track's done flag. When the progress task detects that the
|
||||
/// done flag is set AND `chained_info` has data, it swaps `done` to the
|
||||
/// chained source's flag and transitions state — all without creating a new
|
||||
/// task or changing the generation counter.
|
||||
///
|
||||
/// Key changes from the previous implementation:
|
||||
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
|
||||
/// • Position from atomic sample counter (no wall-clock drift)
|
||||
/// • Immediate `audio:track_switched` event at decoder boundary
|
||||
/// • `audio:ended` only fires when no chained successor exists
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current_arc: Arc<Mutex<AudioCurrent>>,
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
autodj_suppress_arc: Arc<AtomicBool>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate_arc: Arc<AtomicU32>,
|
||||
channels_arc: Arc<AtomicU32>,
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
stream_playback_armed: Arc<AtomicBool>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
) {
|
||||
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
|
||||
// Linux; mirrors the quantum policy used for stream open/reopen plus a small
|
||||
// scheduler/mixer cushion so the UI doesn't run ahead. Other platforms have
|
||||
// their own latency reporting paths and don't need the compensation here.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 {
|
||||
let rate = sample_rate_hz.max(1.0);
|
||||
let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 };
|
||||
(frames / rate) + 0.012
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn estimated_output_latency_secs(_sample_rate_hz: f64) -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
// Keep near-end detection at 100 ms, but throttle progress IPC to webview.
|
||||
const PROGRESS_EMIT_MIN_MS: u64 = 1500;
|
||||
const PROGRESS_EMIT_MIN_DELTA_SECS: f64 = 0.9;
|
||||
|
||||
// Watchdog ceiling for the duration-hint near-end timer. Without crossfade,
|
||||
// audio:ended fires from the sample-accurate `current_done` signal (see the
|
||||
// exhaustion branch below), so this timer only matters as a fallback for a
|
||||
// source that never signals exhaustion (stalled or malformed decoder). ~8 s
|
||||
// past the point where near-end counting starts — far longer than any
|
||||
// healthy track runs past its (floored) duration hint, so it never clips a
|
||||
// real tail.
|
||||
const END_WATCHDOG_TICKS: u32 = 80;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
// Local done-flag reference; swapped on gapless transition.
|
||||
let mut current_done = initial_done;
|
||||
// Local sample counter; swapped to chained source's counter on transition.
|
||||
let mut samples_played = samples_played;
|
||||
let mut last_progress_emit_at = Instant::now() - Duration::from_millis(PROGRESS_EMIT_MIN_MS);
|
||||
let mut last_progress_emit_pos = -1.0f64;
|
||||
let mut last_progress_emit_paused = false;
|
||||
|
||||
loop {
|
||||
// 100 ms tick keeps near-end detection timely for crossfade/gapless
|
||||
// handoff while frontend still interpolates smoothly via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Gapless transition detection ─────────────────────────────────
|
||||
// If the current source is exhausted AND we have a chained track
|
||||
// ready, transition seamlessly: swap tracking state, emit
|
||||
// audio:track_switched for the new track, and continue the loop.
|
||||
if current_done.load(Ordering::SeqCst) {
|
||||
// Radio (dur == 0): stream exhausted / connection dropped → stop.
|
||||
let cur_dur = current_arc.lock().unwrap().duration_secs;
|
||||
if cur_dur <= 0.0 {
|
||||
crate::app_eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)");
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
|
||||
let chained = chained_arc.lock().unwrap().take();
|
||||
if let Some(info) = chained {
|
||||
if let Some(app) = analysis_app.clone() {
|
||||
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
|
||||
}
|
||||
|
||||
// Swap to the chained source's done flag.
|
||||
current_done = info.source_done;
|
||||
|
||||
// Swap to the chained source's sample counter.
|
||||
// The chained CountingSource increments its own Arc,
|
||||
// so we must rebind our local reference to it —
|
||||
// a one-time value copy would go stale immediately.
|
||||
samples_played = info.sample_counter;
|
||||
|
||||
// Update tracking state and apply the chained track's
|
||||
// effective volume. Deferred from `audio_chain_preload`
|
||||
// (which runs ~30 s before the current track ends) to
|
||||
// avoid changing loudness of the still-playing current
|
||||
// track. `Sink::set_volume` affects the whole Sink, so it
|
||||
// must only be called at the boundary, not at preload.
|
||||
{
|
||||
let mut cur = current_arc.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = info.replay_gain_linear;
|
||||
cur.base_volume = info.base_volume;
|
||||
cur.duration_secs = info.duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
if let Some(sink) = &cur.sink {
|
||||
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
}
|
||||
|
||||
*current_playback_url.lock().unwrap() = Some(info.url.clone());
|
||||
|
||||
// Record the gapless switch timestamp for ghost-command guard.
|
||||
let switch_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
|
||||
|
||||
// Emit the new track_switched event — this is immediate,
|
||||
// not delayed by 500 ms like the old audio:playing was.
|
||||
emitter.emit_track_switched(info.duration_secs);
|
||||
near_end_ticks = 0;
|
||||
continue;
|
||||
}
|
||||
// Current source exhausted and no chain queued — this is the
|
||||
// real, sample-accurate end of the track. Emit audio:ended now.
|
||||
// The duration_hint-based near-end timer below would otherwise
|
||||
// clip up to ~1 s off the tail: the Subsonic hint is floored to
|
||||
// whole seconds while the decoded audio runs slightly longer.
|
||||
// The timer stays only as the crossfade trigger and as a
|
||||
// watchdog for sources that never signal exhaustion.
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Position from atomic sample counter ──────────────────────────
|
||||
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
|
||||
let ch = channels_arc.load(Ordering::Relaxed) as f64;
|
||||
let samples = samples_played.load(Ordering::Relaxed) as f64;
|
||||
let divisor = (rate * ch).max(1.0);
|
||||
|
||||
// Read playback snapshot under a single lock to minimize contention
|
||||
// with seek/play/pause commands that also touch `current`.
|
||||
let (base_dur, paused_at) = {
|
||||
let cur = current_arc.lock().unwrap();
|
||||
(cur.duration_secs, cur.paused_at)
|
||||
};
|
||||
let dur = effective_duration_secs(base_dur, &playback_rate);
|
||||
let is_paused = paused_at.is_some();
|
||||
|
||||
let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) {
|
||||
0.0
|
||||
} else if let Some(p) = paused_at {
|
||||
p
|
||||
} else {
|
||||
effective_position_secs(samples / divisor, &playback_rate)
|
||||
.min(dur.max(0.001))
|
||||
};
|
||||
let progress_latency = if is_paused {
|
||||
0.0
|
||||
} else {
|
||||
estimated_output_latency_secs(rate)
|
||||
};
|
||||
let pos = (pos_raw - progress_latency).max(0.0);
|
||||
|
||||
let now = Instant::now();
|
||||
let should_emit_progress = is_paused != last_progress_emit_paused
|
||||
|| now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS)
|
||||
|| (pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS;
|
||||
if should_emit_progress {
|
||||
let buffering = !stream_playback_armed.load(Ordering::Relaxed);
|
||||
emitter.emit_progress(ProgressPayload {
|
||||
current_time: pos,
|
||||
duration: dur,
|
||||
buffering,
|
||||
});
|
||||
last_progress_emit_at = now;
|
||||
last_progress_emit_pos = pos;
|
||||
last_progress_emit_paused = is_paused;
|
||||
}
|
||||
|
||||
if is_paused {
|
||||
continue;
|
||||
}
|
||||
|
||||
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
|
||||
// every advance (gated on the next track being playable). Treat it
|
||||
// like crossfade-off here: only emit `audio:ended` on real source
|
||||
// exhaustion (above) or the watchdog — never the early timer.
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
|
||||
&& !autodj_suppress_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
if dur > end_threshold && pos_raw >= dur - end_threshold {
|
||||
near_end_ticks += 1;
|
||||
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
||||
if near_end_ticks >= 10 {
|
||||
// If a gapless chain is pending, the source hasn't
|
||||
// exhausted yet — duration_hint (integer seconds from
|
||||
// Subsonic) is shorter than the actual audio content.
|
||||
// Don't emit audio:ended; let the gapless transition
|
||||
// handle it when current_done fires.
|
||||
let has_chain = chained_arc.lock().unwrap().is_some();
|
||||
if has_chain {
|
||||
continue;
|
||||
}
|
||||
// With crossfade, audio:ended must fire *early* (cf_secs
|
||||
// before the real end, source not yet exhausted) so the
|
||||
// frontend can start the next track and fade between them
|
||||
// — the timer is the intended trigger here. Without
|
||||
// crossfade, the real end is detected sample-accurately
|
||||
// via `current_done` (handled in the exhaustion branch
|
||||
// above), so the timer only acts as a watchdog for a
|
||||
// source that never signals exhaustion — emitting on the
|
||||
// hint alone would clip up to ~1 s off the tail.
|
||||
if cf_enabled || near_end_ticks >= END_WATCHDOG_TICKS {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
near_end_ticks = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// In-memory `ProgressEmitter` that records every event for assertion.
|
||||
#[derive(Default)]
|
||||
struct MockEmitter {
|
||||
progress: Mutex<Vec<ProgressPayload>>,
|
||||
track_switched: Mutex<Vec<f64>>,
|
||||
ended: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockEmitter {
|
||||
fn progress_count(&self) -> usize {
|
||||
self.progress.lock().unwrap().len()
|
||||
}
|
||||
fn ended_count(&self) -> usize {
|
||||
self.ended.load(Ordering::SeqCst)
|
||||
}
|
||||
fn track_switched_count(&self) -> usize {
|
||||
self.track_switched.lock().unwrap().len()
|
||||
}
|
||||
fn last_progress_time(&self) -> Option<f64> {
|
||||
self.progress
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.map(|p| p.current_time)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressEmitter for Arc<MockEmitter> {
|
||||
fn emit_progress(&self, payload: ProgressPayload) {
|
||||
self.progress.lock().unwrap().push(payload);
|
||||
}
|
||||
fn emit_track_switched(&self, duration_secs: f64) {
|
||||
self.track_switched.lock().unwrap().push(duration_secs);
|
||||
}
|
||||
fn emit_ended(&self) {
|
||||
self.ended.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle of every Arc<…> the spawn function needs, with sane defaults.
|
||||
struct TaskHarness {
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current: Arc<Mutex<AudioCurrent>>,
|
||||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled: Arc<AtomicBool>,
|
||||
crossfade_secs: Arc<AtomicU32>,
|
||||
autodj_suppress: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
channels: Arc<AtomicU32>,
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
playback_url: Arc<Mutex<Option<String>>>,
|
||||
stream_playback_armed: Arc<AtomicBool>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
}
|
||||
|
||||
impl TaskHarness {
|
||||
fn new(duration_secs: f64) -> Self {
|
||||
let current = AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 1.0,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
};
|
||||
Self {
|
||||
gen: 1,
|
||||
gen_counter: Arc::new(AtomicU64::new(1)),
|
||||
current: Arc::new(Mutex::new(current)),
|
||||
chained: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
autodj_suppress: Arc::new(AtomicBool::new(false)),
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
playback_url: Arc::new(Mutex::new(None)),
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
playback_rate: PlaybackRateAtomics::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_with(&self, emitter: Arc<MockEmitter>) {
|
||||
spawn_progress_task(
|
||||
self.gen,
|
||||
self.gen_counter.clone(),
|
||||
self.current.clone(),
|
||||
self.chained.clone(),
|
||||
self.crossfade_enabled.clone(),
|
||||
self.crossfade_secs.clone(),
|
||||
self.autodj_suppress.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
self.samples_played.clone(),
|
||||
self.sample_rate.clone(),
|
||||
self.channels.clone(),
|
||||
self.gapless_switch_at.clone(),
|
||||
self.playback_url.clone(),
|
||||
self.stream_playback_armed.clone(),
|
||||
self.playback_rate.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn progress_emits_buffering_while_stream_not_armed() {
|
||||
let h = TaskHarness::new(240.0);
|
||||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||||
"progress payload must flag HTTP stream buffering before armed"
|
||||
);
|
||||
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn legacy_stream_holds_progress_at_zero_until_armed() {
|
||||
let h = TaskHarness::new(240.0);
|
||||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.last_progress_time().unwrap_or(0.0) < 0.01,
|
||||
"progress must stay at 0 while legacy stream is buffering"
|
||||
);
|
||||
assert!(
|
||||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||||
"progress payload must flag legacy stream buffering"
|
||||
);
|
||||
|
||||
h.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.last_progress_time().unwrap_or(0.0) > 4.0,
|
||||
"progress should follow samples once armed (got {:?})",
|
||||
emitter.last_progress_time()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn task_breaks_immediately_when_generation_already_changed() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// Bump the generation BEFORE spawn — the first 100 ms tick will see
|
||||
// the mismatch and exit the loop without emitting anything.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.progress_count(), 0);
|
||||
assert_eq!(emitter.ended_count(), 0);
|
||||
assert_eq!(emitter.track_switched_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn radio_with_dur_zero_emits_ended_when_done_flag_flips() {
|
||||
// Radio streams have duration_secs == 0; the "done" flag is the only
|
||||
// exhaustion signal. Loop must emit audio:ended and bump the
|
||||
// generation counter.
|
||||
//
|
||||
// Multi-thread runtime with real time — start_paused under
|
||||
// current_thread doesn't reliably drive the spawned task's loop body
|
||||
// after tokio::time::advance, even with repeated yields. Real 100 ms
|
||||
// sleeps are tolerable because the test only waits one tick.
|
||||
let h = TaskHarness::new(0.0);
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire");
|
||||
assert_eq!(emitter.progress_count(), 0, "no progress emit before exhaustion");
|
||||
assert!(
|
||||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||||
"generation counter must bump so following commands see the new gen"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn task_emits_progress_payload_with_duration_after_first_tick() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// 5 s of playback at 44.1 kHz × 2 ch.
|
||||
let played = (5.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let first_payload = {
|
||||
let payloads = emitter.progress.lock().unwrap();
|
||||
assert!(!payloads.is_empty(), "first tick must emit progress");
|
||||
payloads[0].clone()
|
||||
};
|
||||
assert_eq!(first_payload.duration, 120.0, "duration_secs propagates verbatim");
|
||||
// current_time is computed from samples_played but possibly trimmed by
|
||||
// platform output latency — accept anything in [0, duration].
|
||||
assert!(first_payload.current_time >= 0.0 && first_payload.current_time <= 120.0);
|
||||
|
||||
// Stop the task so the test runtime can end.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn done_with_chained_info_swaps_to_chain_and_emits_track_switched() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// Mark current source exhausted AND queue a chained successor.
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
let chain_url = "psysonic-local:///next/track.flac".to_string();
|
||||
let chained_done = Arc::new(AtomicBool::new(false));
|
||||
let chained_samples = Arc::new(AtomicU64::new(0));
|
||||
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
||||
url: chain_url.clone(),
|
||||
analysis_track_id: None,
|
||||
server_id: None,
|
||||
raw_bytes: Arc::new(Vec::new()),
|
||||
duration_secs: 200.0,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 1.0,
|
||||
source_done: chained_done,
|
||||
sample_counter: chained_samples,
|
||||
});
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
assert_eq!(
|
||||
emitter.track_switched_count(),
|
||||
1,
|
||||
"audio:track_switched must fire on gapless transition"
|
||||
);
|
||||
let switched_dur = emitter.track_switched.lock().unwrap()[0];
|
||||
assert_eq!(switched_dur, 200.0, "duration of the chained track");
|
||||
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"audio:ended must NOT fire when a chain is present"
|
||||
);
|
||||
assert_eq!(
|
||||
*h.playback_url.lock().unwrap(),
|
||||
Some(chain_url),
|
||||
"current_playback_url updated to the chained URL"
|
||||
);
|
||||
assert!(
|
||||
h.gapless_switch_at.load(Ordering::SeqCst) > 0,
|
||||
"gapless_switch_at timestamp recorded for ghost-command guard"
|
||||
);
|
||||
|
||||
// Stop the task.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn done_without_chain_emits_ended_immediately() {
|
||||
// Real track (duration_secs > 0), source exhausted, no chained
|
||||
// successor: audio:ended must fire on the sample-accurate done flag —
|
||||
// not be deferred to (or clipped by) the duration-hint near-end timer.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire on source exhaustion");
|
||||
assert_eq!(emitter.track_switched_count(), 0, "no chain → no track switch");
|
||||
assert!(
|
||||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||||
"generation counter must bump so following commands see the new gen"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn near_end_without_crossfade_waits_for_source_done() {
|
||||
// Position playback past the (floored) duration hint with the source
|
||||
// NOT yet exhausted and crossfade off. The duration-hint timer must
|
||||
// NOT emit audio:ended — doing so would clip the real tail, since the
|
||||
// decoded audio routinely runs slightly longer than the integer hint.
|
||||
let h = TaskHarness::new(120.0);
|
||||
// samples → pos_raw clamps to dur (120 s), well inside `dur - 1`.
|
||||
let played = (120.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
// > 10 ticks: the timer's near-end counter is well past its 1 s mark.
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"audio:ended must NOT fire from the duration-hint timer before the source is done"
|
||||
);
|
||||
|
||||
// Source actually exhausts → audio:ended fires now.
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires once the source is exhausted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn near_end_with_crossfade_emits_ended_on_timer() {
|
||||
// With crossfade enabled, audio:ended must still fire from the timer
|
||||
// ~cf_secs before the real end (the source is NOT exhausted yet) so the
|
||||
// frontend can start the next track and fade between them.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
// 10 ticks ≈ 1 s to cross the near-end debounce.
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
1,
|
||||
"crossfade still relies on the timer to fire audio:ended early"
|
||||
);
|
||||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn autodj_suppress_does_not_fire_crossfade_timer() {
|
||||
// AutoDJ suppression on: even with crossfade enabled and the position
|
||||
// inside the crossfade window, the autonomous timer must NOT emit
|
||||
// audio:ended (JS drives the advance, gated on the next track being
|
||||
// ready). The real end is still reached via source exhaustion.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
h.autodj_suppress.store(true, Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"suppressed AutoDJ must not fire the autonomous crossfade timer"
|
||||
);
|
||||
|
||||
// Source exhausts → audio:ended fires (clean sequential end).
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! Live internet-radio playback. Distinct from main track playback: no
|
||||
//! gapless chain, no seek, no replay-gain, no preload.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use rodio::{Player, Source};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::playback_rate::PlaybackRateAtomics;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::sources::{
|
||||
CountingSource, DynSource, EqSource, EqualPowerFadeIn, NotifyingSource,
|
||||
PriorityBoostSource, TriggeredFadeOut,
|
||||
};
|
||||
use super::stream::{
|
||||
radio_download_task, AudioStreamReader, RadioLiveState, RadioSharedFlags,
|
||||
RADIO_BUF_CAPACITY, RADIO_READ_TIMEOUT_SECS,
|
||||
};
|
||||
|
||||
/// Play a live internet radio stream.
|
||||
///
|
||||
/// Sends `Icy-MetaData: 1` to request inline ICY metadata.
|
||||
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
|
||||
/// and `radio:metadata` whenever the StreamTitle changes.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play_radio(
|
||||
url: String,
|
||||
volume: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any active preview so it doesn't keep playing alongside radio.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Abort any previous radio task before stopping the sink.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
}
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
|
||||
|
||||
// ── Open initial HTTP connection ──────────────────────────────────────────
|
||||
let response = audio_http_client(&state)
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let m = format!("radio: connection failed: {e}");
|
||||
app.emit("audio:error", &m).ok();
|
||||
m
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let m = format!("radio: HTTP {}", response.status());
|
||||
app.emit("audio:error", &m).ok();
|
||||
return Err(m);
|
||||
}
|
||||
|
||||
let fmt_hint = content_type_to_hint(
|
||||
response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
|
||||
// ── Build 4 MB lock-free SPSC ring buffer ─────────────────────────────────
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (prod, cons) = rb.split();
|
||||
|
||||
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
let flags = Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(false),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(new_cons_tx),
|
||||
});
|
||||
|
||||
// ── Spawn download task ───────────────────────────────────────────────────
|
||||
let task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
Some(response),
|
||||
audio_http_client(&state),
|
||||
url.clone(),
|
||||
prod,
|
||||
flags.clone(),
|
||||
app.clone(),
|
||||
));
|
||||
|
||||
*state.radio_state.lock().unwrap() = Some(RadioLiveState {
|
||||
url: url.clone(),
|
||||
gen,
|
||||
task,
|
||||
flags: flags.clone(),
|
||||
});
|
||||
|
||||
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
||||
let reader = AudioStreamReader {
|
||||
read_timeout_secs: RADIO_READ_TIMEOUT_SECS,
|
||||
cons: Mutex::new(cons),
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen,
|
||||
source_tag: "radio",
|
||||
eof_when_empty: None,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
let dyn_src = DynSource::new(decoder);
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(boosted);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = 0.0; // sentinel: live stream
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = 1.0;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
state.current_sample_rate.store(sample_rate.get(), Ordering::Relaxed);
|
||||
state.current_channels.store(channels.get() as u32, Ordering::Relaxed);
|
||||
|
||||
app.emit("audio:playing", 0.0f64).ok();
|
||||
|
||||
state.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.current.clone(),
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
PlaybackRateAtomics::default(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
|
||||
//! into `state.current` (handing off the old one to a crossfade tail or a hard
|
||||
//! stop), and the legacy non-seekable path that holds a sink paused until the
|
||||
//! download task arms playback. Split out of `play_input.rs` so source
|
||||
//! selection and source building stay focused on their own concerns.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::engine::{AudioCurrent, AudioEngine};
|
||||
|
||||
/// Args for [`spawn_legacy_stream_start_when_armed`].
|
||||
pub(super) struct LegacyStreamStartWhenArmed {
|
||||
pub gen: u64,
|
||||
pub gen_arc: Arc<AtomicU64>,
|
||||
pub playback_armed: Arc<AtomicBool>,
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
pub app: AppHandle,
|
||||
pub duration_secs: f64,
|
||||
pub hold_paused: bool,
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
|
||||
let LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
gen_arc,
|
||||
playback_armed,
|
||||
samples_played,
|
||||
current,
|
||||
app,
|
||||
duration_secs,
|
||||
hold_paused,
|
||||
} = args;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
if hold_paused {
|
||||
sink.pause();
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
if cur.paused_at.is_none() {
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
cur.seek_offset = 0.0;
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
|
||||
);
|
||||
} else {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
let Some(old) = old_sink else {
|
||||
return;
|
||||
};
|
||||
if fade_secs <= 0.0 {
|
||||
old.stop();
|
||||
return;
|
||||
}
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink.clone());
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
if start_paused {
|
||||
sink.pause();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
} else {
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
} else if let Some(old) = old_sink {
|
||||
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
|
||||
// so clamp the handoff sink or A blasts over B's fade-in.
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
old.set_volume(0.0);
|
||||
}
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
//! Source-building pipeline for `audio_play`: turn a resolved [`PlayInput`]
|
||||
//! into a fully wrapped rodio source, including the ranged-stream probe
|
||||
//! fallback (wait for / fetch a full download and retry from in-memory bytes
|
||||
//! when a partial ranged buffer can't be probed yet). Split out of
|
||||
//! `play_input.rs` so source *selection* stays separate from source *building*.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
|
||||
use super::play_input::PlayInput;
|
||||
use super::stream::TRACK_READ_TIMEOUT_SECS;
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::Instant;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(
|
||||
Box::new(reader),
|
||||
stream_hint.as_deref(),
|
||||
"track-stream",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
//! ICY (Shoutcast/Icecast) inline-metadata state machine.
|
||||
//!
|
||||
//! Streams embed metadata every `metaint` audio bytes:
|
||||
//!
|
||||
//! ┌──────────────────────┬───┬─────────────┐
|
||||
//! │ audio × metaint │ N │ meta × N×16 │ (repeating)
|
||||
//! └──────────────────────┴───┴─────────────┘
|
||||
//!
|
||||
//! N = 0 → no metadata this block. Metadata bytes are stripped so only
|
||||
//! pure audio reaches the ring buffer and Symphonia never sees text bytes.
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub(crate) enum IcyState {
|
||||
/// Forwarding audio bytes; `remaining` counts down to the next boundary.
|
||||
ReadingAudio { remaining: usize },
|
||||
/// Next byte is the metadata length multiplier N.
|
||||
ReadingLengthByte,
|
||||
/// Accumulating N×16 metadata bytes.
|
||||
ReadingMetadata { remaining: usize, buf: Vec<u8> },
|
||||
}
|
||||
|
||||
pub(crate) struct IcyInterceptor {
|
||||
state: IcyState,
|
||||
metaint: usize,
|
||||
}
|
||||
|
||||
impl IcyInterceptor {
|
||||
pub(crate) fn new(metaint: usize) -> Self {
|
||||
Self { metaint, state: IcyState::ReadingAudio { remaining: metaint } }
|
||||
}
|
||||
|
||||
/// Feed a raw HTTP chunk.
|
||||
/// Appends only audio bytes to `audio_out`.
|
||||
/// Returns `Some(IcyMeta)` when a StreamTitle is extracted.
|
||||
pub(crate) fn process(&mut self, input: &[u8], audio_out: &mut Vec<u8>) -> Option<IcyMeta> {
|
||||
let mut extracted: Option<IcyMeta> = None;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match &mut self.state {
|
||||
IcyState::ReadingAudio { remaining } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
audio_out.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
self.state = IcyState::ReadingLengthByte;
|
||||
}
|
||||
}
|
||||
IcyState::ReadingLengthByte => {
|
||||
let len_n = input[i] as usize;
|
||||
i += 1;
|
||||
self.state = if len_n == 0 {
|
||||
IcyState::ReadingAudio { remaining: self.metaint }
|
||||
} else {
|
||||
IcyState::ReadingMetadata {
|
||||
remaining: len_n * 16,
|
||||
buf: Vec::with_capacity(len_n * 16),
|
||||
}
|
||||
};
|
||||
}
|
||||
IcyState::ReadingMetadata { remaining, buf } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
buf.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
let bytes = std::mem::take(buf);
|
||||
extracted = parse_icy_meta(&bytes);
|
||||
self.state = IcyState::ReadingAudio { remaining: self.metaint };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extracted
|
||||
}
|
||||
}
|
||||
|
||||
/// ICY metadata parsed from a raw metadata block.
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct IcyMeta {
|
||||
pub title: String,
|
||||
/// `true` when `StreamUrl='0'` — indicates a CDN-injected ad/promo.
|
||||
pub is_ad: bool,
|
||||
}
|
||||
|
||||
/// Extract `StreamTitle` and `StreamUrl` from a raw ICY metadata block.
|
||||
/// Tolerates null padding and non-UTF-8 bytes (lossy conversion).
|
||||
fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
|
||||
let s = String::from_utf8_lossy(raw);
|
||||
let s = s.trim_end_matches('\0');
|
||||
|
||||
const TITLE_TAG: &str = "StreamTitle='";
|
||||
let title_start = s.find(TITLE_TAG)? + TITLE_TAG.len();
|
||||
let title_rest = &s[title_start..];
|
||||
// find (not rfind) — rfind would skip past StreamUrl and corrupt the title
|
||||
let title_end = title_rest.find("';")?;
|
||||
let title = title_rest[..title_end].trim().to_string();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const URL_TAG: &str = "StreamUrl='";
|
||||
let stream_url = s.find(URL_TAG).map(|pos| {
|
||||
let rest = &s[pos + URL_TAG.len()..];
|
||||
let end = rest.find("';").unwrap_or(rest.len());
|
||||
rest[..end].trim().to_string()
|
||||
}).unwrap_or_default();
|
||||
|
||||
Some(IcyMeta { title, is_ad: stream_url == "0" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_icy_meta ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_extracts_title_from_canonical_block() {
|
||||
let raw = b"StreamTitle='Pink Floyd - Time';StreamUrl='https://www.example';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert_eq!(m.title, "Pink Floyd - Time");
|
||||
assert!(!m.is_ad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_marks_is_ad_when_stream_url_is_zero() {
|
||||
let raw = b"StreamTitle='Sponsored';StreamUrl='0';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert!(m.is_ad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_tag_missing() {
|
||||
assert!(parse_icy_meta(b"StreamUrl='abc';").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_unterminated() {
|
||||
// Missing the closing `';` after StreamTitle.
|
||||
assert!(parse_icy_meta(b"StreamTitle='no-end").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_is_empty() {
|
||||
assert!(parse_icy_meta(b"StreamTitle='';StreamUrl='x';").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tolerates_trailing_null_padding() {
|
||||
let mut raw = b"StreamTitle='Track';StreamUrl='https://x';".to_vec();
|
||||
raw.extend_from_slice(&[0u8; 32]);
|
||||
let m = parse_icy_meta(&raw).unwrap();
|
||||
assert_eq!(m.title, "Track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tolerates_non_utf8_bytes() {
|
||||
// Latin-1 0xA9 (©) — String::from_utf8_lossy replaces with U+FFFD
|
||||
// and trim() leaves the title intact.
|
||||
let raw = b"StreamTitle='\xA9 Track';StreamUrl='x';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert!(m.title.contains("Track"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_uses_first_title_quote_pair_not_stream_url_pair() {
|
||||
// The body uses `find` not `rfind` so the title stops at its own `';`
|
||||
// even though a later `';` exists for StreamUrl.
|
||||
let raw = b"StreamTitle='Real Title';StreamUrl='Long URL with quotes';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert_eq!(m.title, "Real Title");
|
||||
}
|
||||
|
||||
// ── IcyInterceptor ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn interceptor_passes_audio_through_when_no_metadata_block_yet() {
|
||||
let mut icy = IcyInterceptor::new(8);
|
||||
let mut audio = Vec::new();
|
||||
let result = icy.process(b"abcd", &mut audio);
|
||||
assert_eq!(audio, b"abcd");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_strips_zero_length_metadata_block() {
|
||||
// metaint = 4, then 1 length byte = 0 → no metadata, then more audio.
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
// Audio (4) + length=0 + audio (4) = 9 bytes input
|
||||
let input: Vec<u8> = b"AAAA\x00BBBB".to_vec();
|
||||
let result = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AAAABBBB");
|
||||
assert!(result.is_none(), "zero-length metadata block produces no IcyMeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_strips_metadata_bytes_from_audio_stream() {
|
||||
// metaint = 4, length=1 (×16=16 bytes of metadata).
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
let mut input = b"AAAA\x01".to_vec();
|
||||
// Pad metadata to exactly 16 bytes with a parsable StreamTitle.
|
||||
let mut meta = b"StreamTitle='X';".to_vec();
|
||||
meta.resize(16, 0);
|
||||
input.extend_from_slice(&meta);
|
||||
input.extend_from_slice(b"BBBB");
|
||||
|
||||
let result = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AAAABBBB", "metadata bytes do not leak into audio");
|
||||
let meta = result.expect("StreamTitle present");
|
||||
assert_eq!(meta.title, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_handles_input_split_across_multiple_calls() {
|
||||
// Same scenario as above, fed in 1-byte chunks.
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
let mut full = b"AAAA\x01".to_vec();
|
||||
let mut meta = b"StreamTitle='Y';".to_vec();
|
||||
meta.resize(16, 0);
|
||||
full.extend_from_slice(&meta);
|
||||
full.extend_from_slice(b"BBBB");
|
||||
|
||||
let mut last_meta = None;
|
||||
for byte in &full {
|
||||
if let Some(m) = icy.process(&[*byte], &mut audio) {
|
||||
last_meta = Some(m);
|
||||
}
|
||||
}
|
||||
assert_eq!(audio, b"AAAABBBB");
|
||||
assert_eq!(last_meta.unwrap().title, "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_treats_subsequent_blocks_independently() {
|
||||
// Two metaint cycles, both with parsable metadata. Titles must be
|
||||
// single-character so `StreamTitle='X';` fits in the 16-byte block
|
||||
// (length byte = 1 → 16 bytes of metadata).
|
||||
let mut icy = IcyInterceptor::new(2);
|
||||
let mut audio = Vec::new();
|
||||
// First block: AA + length=1 + 16-byte meta
|
||||
let mut input = b"AA\x01".to_vec();
|
||||
input.extend_from_slice(b"StreamTitle='1';"); // exactly 16 bytes
|
||||
// Second block: BB + length=1 + 16-byte meta
|
||||
input.extend_from_slice(b"BB\x01");
|
||||
input.extend_from_slice(b"StreamTitle='2';"); // exactly 16 bytes
|
||||
// Trailing audio
|
||||
input.extend_from_slice(b"CC");
|
||||
|
||||
let _ = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AABBCC", "all audio bytes survive across two cycles");
|
||||
|
||||
// Title verification with split input: a single process() returns at
|
||||
// most one IcyMeta, so feed the two metadata blocks in separate calls.
|
||||
let mut icy2 = IcyInterceptor::new(2);
|
||||
let mut audio2 = Vec::new();
|
||||
let split_at = 2 + 1 + 16; // end of first block
|
||||
let mut titles = Vec::new();
|
||||
if let Some(m) = icy2.process(&input[..split_at], &mut audio2) {
|
||||
titles.push(m.title);
|
||||
}
|
||||
if let Some(m) = icy2.process(&input[split_at..], &mut audio2) {
|
||||
titles.push(m.title);
|
||||
}
|
||||
assert_eq!(titles, vec!["1".to_string(), "2".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! `LocalFileSource` — seekable `MediaSource` backed directly by `std::fs::File`.
|
||||
//!
|
||||
//! Used for `psysonic-local://` URLs (offline library + hot playback cache hits).
|
||||
//! Lets Symphonia read on-demand from disk during the probe (~64 KB) instead of
|
||||
//! the previous behaviour of `tokio::fs::read` blocking until the entire file
|
||||
//! (often 100+ MB for hi-res FLAC) was loaded into RAM. Track-start is instant.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
pub(crate) struct LocalFileSource {
|
||||
pub(crate) file: std::fs::File,
|
||||
pub(crate) len: u64,
|
||||
}
|
||||
|
||||
impl Read for LocalFileSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.file.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for LocalFileSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.file.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for LocalFileSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
//! HTTP-backed and file-backed `MediaSource` implementations plus their
|
||||
//! background download tasks.
|
||||
//!
|
||||
//! Submodule layout:
|
||||
//! - `icy` — Shoutcast/Icecast inline-metadata state machine
|
||||
//! - `reader` — `AudioStreamReader` (ringbuf → `std::io::Read` shim)
|
||||
//! - `local_file` — `LocalFileSource` (file-backed, seekable)
|
||||
//! - `ranged_http` — `RangedHttpSource` (seekable HTTP) + `ranged_download_task`
|
||||
//! - `radio` — radio session state + `radio_download_task`
|
||||
//! - `track_stream` — `track_download_task` (one-shot non-ranged HTTP)
|
||||
|
||||
mod icy;
|
||||
mod local_file;
|
||||
mod mp4;
|
||||
mod radio;
|
||||
mod ranged_http;
|
||||
mod reader;
|
||||
mod track_stream;
|
||||
|
||||
pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
|
||||
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
|
||||
/// Opus, Speex, FLAC-in-Ogg).
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
|
||||
/// construction time, but only when the source reports `is_seekable()` *during
|
||||
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
|
||||
/// `phys_byte_range_end` stays `None` and the first real seek panics with
|
||||
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
|
||||
/// seek to EOF must therefore stay seekable through the probe for Ogg.
|
||||
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"ogg" | "oga" | "ogx" | "opus" | "spx"
|
||||
)
|
||||
}
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{OnDemand, RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
// ── Shared tuning constants ──────────────────────────────────────────────────
|
||||
|
||||
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
|
||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||
/// large enough to absorb brief network hiccups without stuttering.
|
||||
pub(crate) const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
/// Minimum ring buffer for on-demand track streaming starts.
|
||||
pub(crate) const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||
/// Cap ring buffer growth when content-length is known.
|
||||
pub(crate) const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||
/// Max bytes kept in RAM (`stream_completed_cache`) for fast replay; larger completed
|
||||
/// ranged streams are spilled under app-data `stream-spill/` for hot-cache promote.
|
||||
pub(crate) const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the
|
||||
/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB;
|
||||
/// using the stream-promote cap here skipped analysis entirely (empty seekbar).
|
||||
pub(crate) const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
/// Consecutive body-stream failures tolerated for track streaming before abort.
|
||||
pub(crate) const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
pub(crate) const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// Live radio: if no audio bytes arrive for this long → EOF.
|
||||
pub(crate) const RADIO_READ_TIMEOUT_SECS: u64 = 15;
|
||||
/// On-demand tracks (`track-stream`, `RangedHttpSource`): allow long gaps while a
|
||||
/// large file is still downloading (format probe may read/seek ahead of the filler).
|
||||
pub(crate) const TRACK_READ_TIMEOUT_SECS: u64 = 120;
|
||||
/// HTTP track paths (`AudioStreamReader`, `RangedHttpSource`): minimum linear
|
||||
/// download before audible playback and seekbar progress (demux probe may read
|
||||
/// far ahead of the play cursor).
|
||||
pub(crate) const TRACK_STREAM_PLAY_START_BYTES: u64 = 384 * 1024;
|
||||
|
||||
/// Arm deferred playback / progress once enough of the file is buffered.
|
||||
pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::sync::atomic::AtomicBool) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if !playback_armed.load(Ordering::Relaxed) && downloaded >= TRACK_STREAM_PLAY_START_BYTES {
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
crate::app_deprintln!(
|
||||
"[stream] playback armed after {} KiB buffered",
|
||||
downloaded / 1024
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Held until `RangedHttpSource` has moov metadata for Symphonia probe (tail prefetch
|
||||
/// or fast-start moov in the linear prefix).
|
||||
pub(crate) struct RangedMp4ProbeGate {
|
||||
pub(crate) tail_ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
pub(crate) buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
pub(crate) downloaded_to: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub(crate) gen_arc: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
pub(crate) format_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Block until moov is reachable: tail prefetch completed or moov already in the
|
||||
/// downloaded prefix (fast-start). Avoids Symphonia probing moov-at-end M4A before
|
||||
/// the tail range is filled (format probe failed: end of stream).
|
||||
pub(crate) async fn wait_for_ranged_mp4_probe_ready(gate: &RangedMp4ProbeGate) -> Result<(), String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PREFIX_SCAN_MIN: usize = 64 * 1024;
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
|
||||
loop {
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
if gate.tail_ready.load(Ordering::Relaxed) {
|
||||
crate::app_deprintln!("[stream] ranged: moov metadata ready (tail prefetch)");
|
||||
return Ok(());
|
||||
}
|
||||
let dl = gate.downloaded_to.load(Ordering::Relaxed);
|
||||
if dl >= PREFIX_SCAN_MIN {
|
||||
let guard = gate.buf.lock().unwrap();
|
||||
let n = dl.min(guard.len());
|
||||
if !mp4::mp4_needs_tail_prefetch(&guard[..n], gate.format_hint.as_deref()) {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: moov metadata ready (fast-start, {} KiB prefix)",
|
||||
n / 1024
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(
|
||||
"ranged-stream: timed out waiting for moov metadata (tail prefetch)".into(),
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep interval when ring buffer is empty (prevents CPU spin).
|
||||
pub(crate) const RADIO_YIELD_MS: u64 = 2;
|
||||
@@ -1,243 +0,0 @@
|
||||
//! MP4/M4A layout helpers for HTTP streaming path selection.
|
||||
|
||||
/// True when the Subsonic / sniffed container hint is ISO-BMFF (m4a, mp4, …).
|
||||
pub(crate) fn container_hint_is_mp4(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"m4a" | "m4af" | "mp4" | "m4b" | "mov" | "mp4a" | "isom"
|
||||
)
|
||||
}
|
||||
|
||||
/// Walk top-level atoms in `prefix` and return true when `mdat` appears before `moov`
|
||||
/// (classic non–fast-start layout — Symphonia must read the `moov` near EOF).
|
||||
pub(crate) fn mp4_moov_follows_mdat(prefix: &[u8]) -> bool {
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
return saw_mdat;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True when we should prefetch the file tail before linear fill (moov-at-end).
|
||||
pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool {
|
||||
if !container_hint_is_mp4(hint) {
|
||||
return false;
|
||||
}
|
||||
if prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if mp4_moov_follows_mdat(prefix) {
|
||||
return true;
|
||||
}
|
||||
// mdat seen but no moov in the scanned prefix — moov is likely at EOF.
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
let mut saw_moov = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
saw_moov = true;
|
||||
break;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
saw_mdat && !saw_moov
|
||||
}
|
||||
|
||||
/// Scan `[scan_start, scan_end)` for a top-level atom fourcc (e.g. `moov`).
|
||||
fn find_atom_fourcc(data: &[u8], atom: &[u8; 4], scan_start: usize, scan_end: usize) -> Option<usize> {
|
||||
let end = scan_end.min(data.len());
|
||||
let start = scan_start.min(end);
|
||||
for i in start..end.saturating_sub(8) {
|
||||
if data[i + 4..i + 8] == *atom {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `moov` in the last 8 MiB (moov-at-end) or anywhere in the first 32 MiB (fast-start).
|
||||
pub(crate) fn mp4_has_moov_atom(data: &[u8]) -> bool {
|
||||
if data.len() < 16 {
|
||||
return false;
|
||||
}
|
||||
const TAIL_SCAN: usize = 8 * 1024 * 1024;
|
||||
const PREFIX_SCAN: usize = 32 * 1024 * 1024;
|
||||
if find_atom_fourcc(data, b"moov", data.len().saturating_sub(TAIL_SCAN), data.len()).is_some() {
|
||||
return true;
|
||||
}
|
||||
find_atom_fourcc(data, b"moov", 0, PREFIX_SCAN.min(data.len())).is_some()
|
||||
}
|
||||
|
||||
/// `ftyp` in the first few KB — minimal sanity check for ISO-BMFF from ranged assembly.
|
||||
pub(crate) fn mp4_has_ftyp_atom(data: &[u8]) -> bool {
|
||||
find_atom_fourcc(data, b"ftyp", 0, data.len().min(8192)).is_some()
|
||||
}
|
||||
|
||||
/// After a full ranged download, the buffer should contain `ftyp` and `moov`.
|
||||
pub(crate) fn isobmff_buffer_looks_complete(data: &[u8]) -> bool {
|
||||
mp4_has_ftyp_atom(data) && mp4_has_moov_atom(data)
|
||||
}
|
||||
|
||||
/// Heuristic: large runs of zero bytes between `ftyp` and `moov` suggest a sparse/holey
|
||||
/// ranged buffer (tail prefetch + incomplete linear fill) rather than real audio.
|
||||
pub(crate) fn mp4_suspect_zero_holes(data: &[u8]) -> bool {
|
||||
if data.len() < 256 * 1024 {
|
||||
return false;
|
||||
}
|
||||
let moov_off = find_atom_fourcc(data, b"moov", data.len().saturating_sub(8 * 1024 * 1024), data.len())
|
||||
.or_else(|| find_atom_fourcc(data, b"moov", 0, data.len().min(32 * 1024 * 1024)));
|
||||
let Some(moov_off) = moov_off else {
|
||||
return false;
|
||||
};
|
||||
let scan_end = moov_off.min(data.len());
|
||||
let scan_start = 64 * 1024; // skip tiny header
|
||||
if scan_end <= scan_start + 64 * 1024 {
|
||||
return false;
|
||||
}
|
||||
const BLOCK: usize = 64 * 1024;
|
||||
let mut zero_blocks = 0usize;
|
||||
let mut total_blocks = 0usize;
|
||||
let mut pos = scan_start;
|
||||
while pos + BLOCK <= scan_end {
|
||||
total_blocks += 1;
|
||||
if data[pos..pos + BLOCK].iter().all(|&b| b == 0) {
|
||||
zero_blocks += 1;
|
||||
}
|
||||
pos += BLOCK;
|
||||
}
|
||||
total_blocks > 4 && zero_blocks * 100 / total_blocks >= 10
|
||||
}
|
||||
|
||||
/// Log why Symphonia may reject a buffer (for support / debugging).
|
||||
pub(crate) fn log_isobmff_buffer_diagnostic(data: &[u8], hint: Option<&str>, label: &str) {
|
||||
let prefix_hex: String = data
|
||||
.iter()
|
||||
.take(16)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let looks_json = data.starts_with(b"{") || data.starts_with(b"[");
|
||||
crate::app_eprintln!(
|
||||
"[stream] ISO-BMFF diagnostic ({label}): hint={:?} bytes={} prefix=[{}] json_like={} ftyp={} moov={} zero_holes={}",
|
||||
hint,
|
||||
data.len(),
|
||||
prefix_hex,
|
||||
looks_json,
|
||||
mp4_has_ftyp_atom(data),
|
||||
mp4_has_moov_atom(data),
|
||||
mp4_suspect_zero_holes(data),
|
||||
);
|
||||
}
|
||||
|
||||
fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option<u64> {
|
||||
if pos + 8 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let size32 = u32::from_be_bytes(data[pos..pos + 4].try_into().ok()?) as u64;
|
||||
if size32 == 1 {
|
||||
if pos + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(u64::from_be_bytes(data[pos + 8..pos + 16].try_into().ok()?))
|
||||
} else {
|
||||
Some(size32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atom(typ: &[u8; 4], payload_len: usize) -> Vec<u8> {
|
||||
let size = (8 + payload_len) as u32;
|
||||
let mut v = Vec::with_capacity(8 + payload_len);
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(typ);
|
||||
v.resize(8 + payload_len, 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_after_mdat_detected() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
assert!(mp4_moov_follows_mdat(&buf));
|
||||
assert!(mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_before_mdat_no_tail_prefetch() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
assert!(!mp4_moov_follows_mdat(&buf));
|
||||
assert!(!mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_with_m4a_hint_needs_tail_prefetch() {
|
||||
assert!(mp4_needs_tail_prefetch(&[], Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_without_mp4_hint_skips_tail_prefetch() {
|
||||
assert!(!mp4_needs_tail_prefetch(&[], Some("mp3")));
|
||||
assert!(!mp4_needs_tail_prefetch(&[], None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isobmff_complete_detects_ftyp_and_moov() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 200));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
assert!(isobmff_buffer_looks_complete(&buf));
|
||||
assert!(!mp4_suspect_zero_holes(&buf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isobmff_incomplete_without_moov() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 200));
|
||||
assert!(!isobmff_buffer_looks_complete(&buf));
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
//! Live internet-radio session state and the async HTTP download task.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 'outer loop — reconnect on TCP drop (up to MAX_CONSECUTIVE_FAILURES)
|
||||
//! 'inner loop — read HTTP chunks → ICY interceptor → push audio to ring buffer
|
||||
//!
|
||||
//! Hard-pause detection: if push_slice() returns 0 (buffer full) AND sink is
|
||||
//! paused AND that condition persists for `RADIO_HARD_PAUSE_SECS` → disconnect.
|
||||
//! Sets `is_hard_paused = true` so audio_resume knows it must reconnect.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::{Observer, Producer};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::icy::IcyInterceptor;
|
||||
use super::{RADIO_BUF_CAPACITY, RADIO_HARD_PAUSE_SECS};
|
||||
|
||||
pub(crate) struct RadioSharedFlags {
|
||||
/// Set by audio_pause; cleared by audio_resume.
|
||||
pub(crate) is_paused: AtomicBool,
|
||||
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
||||
pub(crate) is_hard_paused: AtomicBool,
|
||||
/// Delivers a fresh HeapCons<u8> to AudioStreamReader on reconnect.
|
||||
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapCons<u8>>>,
|
||||
}
|
||||
|
||||
/// Live state for the current radio session, stored in AudioEngine.
|
||||
/// Dropping this struct aborts the HTTP download task immediately.
|
||||
pub(crate) struct RadioLiveState {
|
||||
pub url: String,
|
||||
pub gen: u64,
|
||||
pub task: tokio::task::JoinHandle<()>,
|
||||
pub flags: Arc<RadioSharedFlags>,
|
||||
}
|
||||
|
||||
impl Drop for RadioLiveState {
|
||||
fn drop(&mut self) { self.task.abort(); }
|
||||
}
|
||||
|
||||
/// Pure: extract the `icy-metaint` header value from a HeaderMap. Returns
|
||||
/// `None` when the header is absent, non-ASCII, or doesn't parse as `usize`.
|
||||
pub(crate) fn parse_icy_metaint_from_headers(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
) -> Option<usize> {
|
||||
headers
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
/// Pure: should the radio download task disconnect because the consumer has
|
||||
/// been stuck on a full ring buffer for too long while paused?
|
||||
pub(crate) fn should_hard_pause(
|
||||
is_paused: bool,
|
||||
stall_since: Option<std::time::Instant>,
|
||||
now: std::time::Instant,
|
||||
threshold: Duration,
|
||||
) -> bool {
|
||||
is_paused
|
||||
&& stall_since.is_some_and(|since| now.duration_since(since) >= threshold)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn radio_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
mut initial_response: Option<reqwest::Response>,
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
mut prod: HeapProd<u8>,
|
||||
flags: Arc<RadioSharedFlags>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let mut bytes_total: u64 = 0;
|
||||
// Counts consecutive failures (reset on each successful chunk).
|
||||
// laut.fm and similar CDNs force-reconnect every ~700 KB; this is normal.
|
||||
let mut reconnect_count: u32 = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 5;
|
||||
let mut audio_scratch: Vec<u8> = Vec::with_capacity(65_536);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Obtain response (initial or reconnect) ────────────────────────────
|
||||
let response = match initial_response.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
|
||||
crate::app_eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
match http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
crate::app_eprintln!("[radio] reconnected ({bytes_total} B so far)");
|
||||
r
|
||||
}
|
||||
Ok(r) => {
|
||||
crate::app_eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
||||
break 'outer;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[radio] reconnect error: {e} — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ICY metaint from each response (consistent across reconnects).
|
||||
let metaint = parse_icy_metaint_from_headers(response.headers());
|
||||
let mut icy = metaint.map(IcyInterceptor::new);
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
// Stall timer: tracks how long push_slice() returns 0 while paused.
|
||||
let mut stall_since: Option<std::time::Instant> = None;
|
||||
|
||||
'inner: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Back-pressure + hard-pause detection ──────────────────────────
|
||||
if prod.is_full() {
|
||||
let now = std::time::Instant::now();
|
||||
let is_paused = flags.is_paused.load(Ordering::Relaxed);
|
||||
if is_paused {
|
||||
stall_since.get_or_insert(now);
|
||||
} else {
|
||||
stall_since = None;
|
||||
}
|
||||
if should_hard_pause(
|
||||
is_paused,
|
||||
stall_since,
|
||||
now,
|
||||
Duration::from_secs(RADIO_HARD_PAUSE_SECS),
|
||||
) {
|
||||
let fill_pct = ((1.0
|
||||
- prod.vacant_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||
* 100.0) as u32;
|
||||
crate::app_eprintln!(
|
||||
"[radio] hard pause: {fill_pct}% full, \
|
||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
||||
);
|
||||
flags.is_hard_paused.store(true, Ordering::Release);
|
||||
return; // Drop HeapProd → TCP connection released.
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue 'inner;
|
||||
}
|
||||
stall_since = None;
|
||||
|
||||
// ── Read HTTP chunk ───────────────────────────────────────────────
|
||||
match byte_stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
// Successful data → reset consecutive-failure counter.
|
||||
reconnect_count = 0;
|
||||
audio_scratch.clear();
|
||||
|
||||
if let Some(ref mut interceptor) = icy {
|
||||
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
||||
let label = if meta.is_ad { "[Ad]" } else { "" };
|
||||
crate::app_eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title);
|
||||
let _ = app.emit("radio:metadata", &meta);
|
||||
}
|
||||
} else {
|
||||
audio_scratch.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Push with per-chunk back-pressure: yield 5 ms if full mid-chunk.
|
||||
let mut offset = 0;
|
||||
while offset < audio_scratch.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
let pushed = prod.push_slice(&audio_scratch[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
offset += pushed;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
None => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
}
|
||||
} // 'inner
|
||||
|
||||
// Do NOT swap the ring buffer here. The remaining bytes in the buffer
|
||||
// are still valid audio and will drain naturally during reconnect.
|
||||
// Clearing it would cause an immediate underrun/glitch.
|
||||
// The buffer is kept small (RADIO_BUF_CAPACITY) so stale audio drains
|
||||
// within a few seconds rather than minutes.
|
||||
} // 'outer
|
||||
|
||||
crate::app_eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_icy_metaint_from_headers ────────────────────────────────────────
|
||||
|
||||
fn make_headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
reqwest::header::HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_parses_valid_integer() {
|
||||
let h = make_headers(&[("icy-metaint", "16384")]);
|
||||
assert_eq!(parse_icy_metaint_from_headers(&h), Some(16384));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_when_header_absent() {
|
||||
let h = make_headers(&[]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_for_non_numeric_value() {
|
||||
let h = make_headers(&[("icy-metaint", "not-a-number")]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_for_empty_string() {
|
||||
let h = make_headers(&[("icy-metaint", "")]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
// ── should_hard_pause ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_not_paused() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled = now - Duration::from_secs(60);
|
||||
// Not paused → never disconnect even after long stalls.
|
||||
assert!(!should_hard_pause(false, Some(stalled), now, Duration::from_secs(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_no_stall_recorded() {
|
||||
let now = std::time::Instant::now();
|
||||
// No stall recorded → no disconnect even when paused.
|
||||
assert!(!should_hard_pause(true, None, now, Duration::from_secs(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_stall_below_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_recent = now - Duration::from_secs(2);
|
||||
assert!(!should_hard_pause(
|
||||
true,
|
||||
Some(stalled_recent),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_true_when_paused_and_stall_at_or_past_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_long = now - Duration::from_secs(10);
|
||||
assert!(should_hard_pause(
|
||||
true,
|
||||
Some(stalled_long),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_inclusive_at_exact_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_exact = now - Duration::from_secs(5);
|
||||
// `>= threshold` — exactly at threshold counts.
|
||||
assert!(should_hard_pause(
|
||||
true,
|
||||
Some(stalled_exact),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
//! `AudioStreamReader` — bridges an SPSC ring buffer (`HeapCons<u8>`) into the
|
||||
//! synchronous `std::io::Read` interface that Symphonia requires.
|
||||
//!
|
||||
//! Designed to run inside `tokio::task::spawn_blocking`.
|
||||
//!
|
||||
//! - Empty buffer: sleeps `RADIO_YIELD_MS` ms, retries. Never busy-spins.
|
||||
//! - Timeout: after `RADIO_READ_TIMEOUT_SECS` with no data → `TimedOut`.
|
||||
//! - Generation: if `gen_arc` != `self.gen` → `Ok(0)` (EOF; new track started).
|
||||
//! - Reconnect: `audio_resume` sends a fresh `HeapCons` via `new_cons_rx`.
|
||||
//! On the next read() we drain the channel (keep latest) and swap.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::traits::{Consumer, Observer};
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
use super::{RADIO_YIELD_MS};
|
||||
|
||||
pub(crate) struct AudioStreamReader {
|
||||
pub(crate) read_timeout_secs: u64,
|
||||
pub(crate) cons: Mutex<HeapCons<u8>>,
|
||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||
/// No real contention: only the audio thread ever calls read().
|
||||
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
|
||||
pub(crate) deadline: std::time::Instant,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// Diagnostic tag for logs ("radio" or "track-stream").
|
||||
pub(crate) source_tag: &'static str,
|
||||
/// Optional completion marker: when true and the ring buffer is empty,
|
||||
/// return EOF immediately (used by one-shot track streaming).
|
||||
pub(crate) eof_when_empty: Option<Arc<AtomicBool>>,
|
||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||
pub(crate) pos: u64,
|
||||
}
|
||||
|
||||
impl Read for AudioStreamReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
// EOF guard: new track started.
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
// Drain reconnect channel; keep only the most recently delivered consumer
|
||||
// so a double-tap of resume doesn't leave stale data in place.
|
||||
let mut newest: Option<HeapCons<u8>> = None;
|
||||
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
||||
newest = Some(c);
|
||||
}
|
||||
if let Some(c) = newest {
|
||||
*self.cons.lock().unwrap() = c;
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
}
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
let available = self.cons.lock().unwrap().occupied_len();
|
||||
if available > 0 {
|
||||
let n = buf.len().min(available);
|
||||
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
|
||||
self.pos += read as u64;
|
||||
// Reset deadline: data arrived, so connection is alive.
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
return Ok(read);
|
||||
}
|
||||
if self
|
||||
.eof_when_empty
|
||||
.as_ref()
|
||||
.is_some_and(|done| done.load(Ordering::SeqCst))
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
if std::time::Instant::now() >= self.deadline {
|
||||
crate::app_eprintln!(
|
||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||
self.source_tag,
|
||||
self.read_timeout_secs
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("{}: no data received", self.source_tag),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for AudioStreamReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
match pos {
|
||||
SeekFrom::Current(0) => Ok(self.pos),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!("{} stream is not seekable", self.source_tag),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for AudioStreamReader {
|
||||
fn is_seekable(&self) -> bool { false }
|
||||
fn byte_len(&self) -> Option<u64> { None }
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! One-shot HTTP downloader for non-ranged track streaming.
|
||||
//!
|
||||
//! Pushes response chunks into an SPSC ring buffer consumed by `AudioStreamReader`.
|
||||
//! Terminates when:
|
||||
//! - generation changes (track superseded),
|
||||
//! - response stream ends, or
|
||||
//! - response emits an error.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::engine::PlaybackHttpHeaders;
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn track_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
mut prod: HeapProd<u8>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_engine: Arc<AtomicU32>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let mut capture: Vec<u8> = Vec::new();
|
||||
let mut capture_over_limit = false;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
} else {
|
||||
let mut req = http_client.get(&url);
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
req = http_headers.apply(&url, req);
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||
reconnects, err
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||
response.status()
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if downloaded == 0 && !response.status().is_success() {
|
||||
crate::app_eprintln!("[audio] streaming HTTP {}", response.status());
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] track-stream dl superseded by skip: track_id={:?} gen={}→{}",
|
||||
cache_track_id, gen, gen_arc.load(Ordering::SeqCst)
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error after {} reconnects: {}",
|
||||
reconnects, e
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||
reconnects,
|
||||
TRACK_STREAM_MAX_RECONNECTS,
|
||||
e
|
||||
);
|
||||
next_response = None;
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
reconnects = 0;
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let pushed = prod.push_slice(&chunk[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
if !capture_over_limit {
|
||||
if capture.len().saturating_add(pushed) <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let from = offset;
|
||||
let to = offset + pushed;
|
||||
capture.extend_from_slice(&chunk[from..to]);
|
||||
} else {
|
||||
capture.clear();
|
||||
capture_over_limit = true;
|
||||
}
|
||||
}
|
||||
if !capture_over_limit
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
if normalization_engine.load(Ordering::Relaxed) == 2 {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_db = f32::from_bits(
|
||||
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0);
|
||||
crate::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
|
||||
}
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
maybe_arm_stream_playback(downloaded, &playback_armed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||
track_id,
|
||||
capture.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
|
||||
&app,
|
||||
server_id.as_deref(),
|
||||
);
|
||||
let priority = crate::analysis_dispatch::analysis_priority_for_app(&app, &sid, &track_id, None);
|
||||
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
capture.clone(),
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: capture,
|
||||
});
|
||||
}
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
//! Release the CPAL/rodio output stream after playback has been idle so the OS
|
||||
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// Wall-clock idle period before closing the output device handle.
|
||||
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
|
||||
|
||||
const IDLE_POLL_SECS: u64 = 5;
|
||||
|
||||
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
|
||||
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
|
||||
if engine.preview_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
if engine.fading_out_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cur = engine.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.empty() {
|
||||
return false;
|
||||
}
|
||||
if cur.play_started.is_some() && cur.paused_at.is_none() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
|
||||
if !rs.flags.is_paused.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
|
||||
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
|
||||
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
if let Some(s) = cur.sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
cur.play_started = None;
|
||||
}
|
||||
|
||||
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
|
||||
super::engine::request_stream_release(engine)?;
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release the output device after the idle timer (pause with no other active audio).
|
||||
pub(crate) fn release_output_stream(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio output stream released after {}s idle",
|
||||
OUTPUT_STREAM_IDLE_RELEASE_SECS
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
|
||||
pub(crate) fn release_output_stream_on_stop(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
|
||||
// still tied to the open device before closing the handle.
|
||||
if engine.preview_sink.lock().unwrap().is_some()
|
||||
|| engine.fading_out_sink.lock().unwrap().is_some()
|
||||
{
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
}
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!("[psysonic] audio output stream released on stop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
|
||||
/// so it takes only the `AppHandle` — no engine reference is needed here.
|
||||
pub fn start_stream_idle_watcher(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut idle_since: Option<Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
idle_since = None;
|
||||
continue;
|
||||
};
|
||||
let engine = state.inner();
|
||||
let stream_open = engine.stream_handle.lock().unwrap().is_some();
|
||||
if !stream_open {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
if output_stream_is_needed(engine) {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
let since = *idle_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
|
||||
continue;
|
||||
}
|
||||
let _ = release_output_stream(engine, &app);
|
||||
idle_since = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use rodio::source::Zero;
|
||||
use rodio::{ChannelCount, Player, SampleRate};
|
||||
|
||||
use super::super::engine::AudioCurrent;
|
||||
use super::super::playback_rate::PlaybackRateAtomics;
|
||||
use super::super::stream::{RadioLiveState, RadioSharedFlags};
|
||||
|
||||
/// A device-less rodio `Player` with one infinite source appended, so
|
||||
/// `empty()` reports `false` without ever opening an output device.
|
||||
/// Returns the queue output too — keep it alive for the test's duration.
|
||||
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
|
||||
let (player, out) = Player::new();
|
||||
player.append(Zero::new(
|
||||
ChannelCount::new(2).unwrap(),
|
||||
SampleRate::new(44_100).unwrap(),
|
||||
));
|
||||
(Arc::new(player), out)
|
||||
}
|
||||
|
||||
fn radio_session(is_paused: bool) -> RadioLiveState {
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
RadioLiveState {
|
||||
url: "http://example.test/stream".to_string(),
|
||||
gen: 0,
|
||||
// Detached no-op task; never polled. Drop just aborts it.
|
||||
task: tokio::spawn(async {}),
|
||||
flags: Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(is_paused),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(tx),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_engine() -> AudioEngine {
|
||||
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
|
||||
AudioEngine {
|
||||
stream_handle: Arc::new(Mutex::new(None)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
device_default_rate: 48_000,
|
||||
stream_thread_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0)),
|
||||
playback_rate: PlaybackRateAtomics::new(),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
stream_completed_spill: Arc::new(Mutex::new(None)),
|
||||
current_is_seekable: Arc::new(AtomicBool::new(true)),
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0)),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
|
||||
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
current_playback_server_id: Arc::new(Mutex::new(None)),
|
||||
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
|
||||
preview_sink: Arc::new(Mutex::new(None)),
|
||||
preview_gen: Arc::new(AtomicU64::new(0)),
|
||||
preview_main_resume: Arc::new(AtomicBool::new(false)),
|
||||
preview_song_id: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_no_sink_and_no_preview() {
|
||||
let engine = minimal_engine();
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_empty() {
|
||||
// A live but drained main sink (track finished) must not pin the device.
|
||||
let (player, _out) = Player::new(); // no source appended → empty()
|
||||
let player = Arc::new(player);
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(player);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_sink_playing() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None; // actively playing
|
||||
}
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_paused() {
|
||||
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = Some(12.0);
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_preview_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.preview_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_fading_out_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn needed_when_radio_playing() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_when_radio_paused() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//! Transport-control Tauri commands: pause / resume / stop / seek.
|
||||
//! These don't drive playback startup — they mutate state on an already-running
|
||||
//! sink (or coordinate radio reconnect for cold-resume).
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, TryLockError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::HeapRb;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::playback_rate::{
|
||||
content_position_from_samples, raw_counter_samples_for_content_position,
|
||||
};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
)
|
||||
.min(cur.duration_secs.max(0.001));
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
// Notify the download task so it can start measuring the hard-pause stall timer.
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume playback.
|
||||
///
|
||||
/// **Warm resume** (`is_hard_paused = false`): download task is still running,
|
||||
/// buffer has buffered audio. `sink.play()` suffices.
|
||||
///
|
||||
/// **Cold resume** (`is_hard_paused = true`): TCP was dropped. A fresh 4 MB
|
||||
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// If a preview is running, cancel it first — otherwise sink.play() on the
|
||||
// main sink would mix on top of the preview sink.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Detect radio hard-disconnect.
|
||||
let reconnect_info = {
|
||||
let guard = state.radio_state.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|rs| rs.flags.is_hard_paused.load(Ordering::Acquire))
|
||||
.map(|rs| (rs.url.clone(), rs.gen, rs.flags.clone()))
|
||||
};
|
||||
|
||||
if let Some((url, gen, flags)) = reconnect_info {
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (new_prod, new_cons) = rb.split();
|
||||
|
||||
// Send new consumer to AudioStreamReader (non-blocking; unbounded channel).
|
||||
let ok = flags.new_cons_tx.lock().unwrap().send(new_cons).is_ok();
|
||||
|
||||
if ok {
|
||||
let new_task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
None, // task performs its own fresh GET
|
||||
audio_http_client(&state),
|
||||
url,
|
||||
new_prod,
|
||||
flags.clone(),
|
||||
app,
|
||||
));
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_mut() {
|
||||
let old = std::mem::replace(&mut rs.task, new_task);
|
||||
old.abort(); // ensure any lingering old task is gone
|
||||
rs.flags.is_hard_paused.store(false, Ordering::Release);
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
// Resume the rodio Sink (works for both warm and cold resume).
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||
*state.current_playback_server_id.lock().unwrap() = None;
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
|
||||
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
|
||||
// re-download on replay. The slot is only consumed on `take`/overwrite for another URL.
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
drop(cur);
|
||||
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let state = state.inner();
|
||||
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
|
||||
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
||||
{
|
||||
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
||||
if switch_ms > 0 {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
if now_ms.saturating_sub(switch_ms) < 500 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reject seek up-front for non-seekable streaming sources so the frontend's
|
||||
// restart-fallback engages instead of rolling the dice on the format reader
|
||||
// (which can consume the ring buffer to EOF for forward seeks → next song).
|
||||
if !state.current_is_seekable.load(Ordering::SeqCst) {
|
||||
crate::app_deprintln!("[seek] rejected → not-seekable source (legacy stream)");
|
||||
return Err("source is not seekable".into());
|
||||
}
|
||||
crate::app_deprintln!("[seek] target={:.2}s", seconds);
|
||||
|
||||
let lock_current_with_timeout = |timeout_ms: u64| {
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
match state.current.try_lock() {
|
||||
Ok(guard) => break Ok(guard),
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
if Instant::now() >= deadline {
|
||||
break Err("audio seek busy".to_string());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(TryLockError::Poisoned(_)) => {
|
||||
break Err("audio state lock poisoned".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Seeking back invalidates any pending gapless chain.
|
||||
let cur_pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
);
|
||||
if seconds < cur_pos - 1.0 {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
let seek_seconds = seconds.max(0.0);
|
||||
let seek_duration = Duration::from_secs_f64(seek_seconds);
|
||||
let seek_generation = state.generation.load(Ordering::SeqCst);
|
||||
let sink = {
|
||||
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
match cur.sink.as_ref() {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = sink.try_seek(seek_duration).map_err(|e| e.to_string());
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(AUDIO_SEEK_TIMEOUT_MS)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
return Err("audio seek timeout".into());
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err("audio seek worker disconnected".into());
|
||||
}
|
||||
}
|
||||
|
||||
// If playback switched while seek was in flight, skip timestamp updates.
|
||||
if state.generation.load(Ordering::SeqCst) != seek_generation {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
if cur.sink.is_none() { return Ok(()); }
|
||||
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seek_seconds);
|
||||
} else {
|
||||
cur.seek_offset = seek_seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
seek_seconds,
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -1,301 +0,0 @@
|
||||
//! Cover disk cache layout — **single place** to change directory naming.
|
||||
//!
|
||||
//! Callers pass `cache_kind` (`album` | `artist`) and `cache_entity_id` (server ids:
|
||||
//! Navidrome `album.id` is often a bare hash/snowflake; `coverArt` may use `al-*`.
|
||||
//! Rarely `mf-*` / `dc-*` on disk when UI enables per-disc art. Path shape:
|
||||
//!
|
||||
//! `{root}/{server_segment}/{kind}/{entity_id}/128.webp`
|
||||
//!
|
||||
//! `server_segment` is derived from the frontend's `serverIndexKeyFromUrl` (host + path,
|
||||
//! no scheme). On Windows that key would otherwise drop a `:` straight into the filesystem
|
||||
//! whenever the user runs Navidrome on a `:port` URL — `CreateDirectory` then rejects the
|
||||
//! whole path with `ERROR_INVALID_NAME`. [`cover_server_dir`] sanitizes the key before it
|
||||
//! hits disk; every caller that wants a server-scoped cover directory goes through it.
|
||||
//!
|
||||
//! Bump [`LAYOUT_STAMP`] when the on-disk format changes (app wipes legacy dirs on startup).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
|
||||
|
||||
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
|
||||
pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
let id = id.trim();
|
||||
id.starts_with("mf-")
|
||||
|| id.starts_with("tr-")
|
||||
|| id.starts_with("pl-")
|
||||
|| id.starts_with("dc-")
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Windows reserved device names (case-insensitive) — invalid as path components.
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
/// Also used for media layout artist/album/title segments from server metadata.
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
let cleaned: String = trimmed
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_control() || FORBIDDEN.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return "_".to_string();
|
||||
}
|
||||
let upper = cleaned.to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
|
||||
return format!("_{cleaned}");
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
pub fn cover_entity_relative_dir(cache_kind: &str, cache_entity_id: &str) -> PathBuf {
|
||||
let kind = sanitize_path_segment(cache_kind);
|
||||
let entity = sanitize_path_segment(cache_entity_id);
|
||||
PathBuf::from(kind).join(entity)
|
||||
}
|
||||
|
||||
/// Per-server cache root (`{root}/{server_segment}/`). Sanitizes the index key so
|
||||
/// `host:port` and embedded URL paths survive on Windows. Every caller that wants the
|
||||
/// server bucket — list/count/clear/backfill — must go through this helper.
|
||||
pub fn cover_server_dir(root: &Path, server_index_key: &str) -> PathBuf {
|
||||
root.join(sanitize_path_segment(server_index_key))
|
||||
}
|
||||
|
||||
/// Absolute directory for one cover entity (`…/album/al-…/` or `…/artist/ar-…/`).
|
||||
pub fn cover_dir(
|
||||
root: &Path,
|
||||
server_index_key: &str,
|
||||
cache_kind: &str,
|
||||
cache_entity_id: &str,
|
||||
) -> PathBuf {
|
||||
cover_server_dir(root, server_index_key)
|
||||
.join(cover_entity_relative_dir(cache_kind, cache_entity_id))
|
||||
}
|
||||
|
||||
/// Resolved cover identity — keep in sync with TS `src/cover/resolveEntry.ts`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CoverEntry {
|
||||
pub cache_kind: &'static str,
|
||||
pub cache_entity_id: String,
|
||||
pub fetch_cover_art_id: String,
|
||||
}
|
||||
|
||||
/// Album — one disk slot per album; per-disc ids only when `distinct_disc_covers`.
|
||||
pub fn resolve_album_cover(
|
||||
album_id: &str,
|
||||
cover_art_id: Option<&str>,
|
||||
distinct_disc_covers: bool,
|
||||
) -> Option<CoverEntry> {
|
||||
let album = album_id.trim();
|
||||
if album.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fetch = cover_art_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(album);
|
||||
let cache_entity_id = if distinct_disc_covers && fetch != album {
|
||||
fetch.to_string()
|
||||
} else {
|
||||
album.to_string()
|
||||
};
|
||||
Some(CoverEntry {
|
||||
cache_kind: "album",
|
||||
cache_entity_id,
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Segment roots under `{server_index_key}/` (canonical layout).
|
||||
pub const SEGMENT_KINDS: [&str; 2] = ["album", "artist"];
|
||||
|
||||
/// Progress / backfill “done” heuristic — matches `LIBRARY_COVER_CANONICAL_TIER` in the library crate.
|
||||
pub const CANONICAL_PROGRESS_TIER: u32 = 800;
|
||||
|
||||
fn tier_webp_ready(path: &Path) -> bool {
|
||||
path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// True when `{entity_dir}/{CANONICAL_PROGRESS_TIER}.webp` exists and is non-empty.
|
||||
pub fn entity_dir_has_canonical_tier(entity_dir: &Path) -> bool {
|
||||
tier_webp_ready(&entity_dir.join(format!("{CANONICAL_PROGRESS_TIER}.webp")))
|
||||
}
|
||||
|
||||
/// Distinct album/artist entity dirs with canonical tier (segment layout only).
|
||||
pub fn count_entities_with_canonical_tier(server_dir: &Path) -> i64 {
|
||||
let mut n = 0i64;
|
||||
for kind in SEGMENT_KINDS {
|
||||
let kind_dir = server_dir.join(kind);
|
||||
let Ok(entries) = std::fs::read_dir(&kind_dir) else {
|
||||
continue;
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
if ent.path().is_dir() && entity_dir_has_canonical_tier(&ent.path()) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn sum_webp_bytes_rec(dir: &Path) -> u64 {
|
||||
let mut bytes = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return bytes;
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
let p = ent.path();
|
||||
if p.is_dir() {
|
||||
bytes += sum_webp_bytes_rec(&p);
|
||||
} else if p.extension().and_then(|s| s.to_str()) == Some("webp") {
|
||||
if let Ok(meta) = ent.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// All `.webp` bytes under one server bucket + entity count (canonical tier, segment dirs).
|
||||
pub fn server_cover_disk_usage(server_dir: &Path) -> (u64, u64) {
|
||||
(
|
||||
sum_webp_bytes_rec(server_dir),
|
||||
count_entities_with_canonical_tier(server_dir) as u64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sum usage across every server subdirectory under `cover_root`.
|
||||
pub fn cover_root_disk_usage(cover_root: &Path) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(cover_root) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
let fname = ent.file_name();
|
||||
let name = fname.to_string_lossy();
|
||||
if name == ".storage-layout" || !ent.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let (b, c) = server_cover_disk_usage(&ent.path());
|
||||
bytes += b;
|
||||
count += c;
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
/// Artist — one disk slot per artist id.
|
||||
pub fn resolve_artist_cover(artist_id: &str, cover_art_id: Option<&str>) -> Option<CoverEntry> {
|
||||
let artist = artist_id.trim();
|
||||
if artist.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fetch = cover_art_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(artist);
|
||||
Some(CoverEntry {
|
||||
cache_kind: "artist",
|
||||
cache_entity_id: artist.to_string(),
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_paths_use_kind_and_entity_id() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
let dir = cover_dir(root, "srv", "album", "al-1");
|
||||
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_segment_sanitizes_port_colon_and_url_path() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
// Typical LAN URL key from `serverIndexKeyFromUrl`: `host:port/path`.
|
||||
// The `:` is invalid on Windows; the `/` would otherwise create a
|
||||
// nested directory rather than one bucket per server.
|
||||
let dir = cover_server_dir(root, "192.168.1.10:4533/music");
|
||||
assert_eq!(dir, root.join("192.168.1.10_4533_music"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_dir_passes_server_key_through_sanitizer() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
let dir = cover_dir(root, "host:4533", "album", "al-1");
|
||||
assert_eq!(dir, root.join("host_4533").join("album").join("al-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_and_artist_segments_differ() {
|
||||
let al = cover_entity_relative_dir("album", "al-1");
|
||||
let ar = cover_entity_relative_dir("artist", "ar-1");
|
||||
assert_ne!(al, ar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_disc_mf_entity_gets_own_dir() {
|
||||
let d = cover_entity_relative_dir("album", "mf-disc2_abc");
|
||||
assert_eq!(d, PathBuf::from("album").join("mf-disc2_abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_bare_navidrome_id() {
|
||||
let e = resolve_album_cover("0DurV2S7arIOBQVEknOPWX", Some("al-0Dur_abc"), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "0DurV2S7arIOBQVEknOPWX");
|
||||
assert_eq!(e.fetch_cover_art_id, "al-0Dur_abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_per_disc_changes_cache_entity() {
|
||||
let e = resolve_album_cover("al-box", Some("mf-d2"), true).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "mf-d2");
|
||||
}
|
||||
|
||||
fn test_server_dir(label: &str) -> std::path::PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_dot_and_reserved_names() {
|
||||
assert_eq!(sanitize_path_segment(".."), "_");
|
||||
assert_eq!(sanitize_path_segment("CON"), "_CON");
|
||||
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
let entity = server.join("album").join("al-1");
|
||||
std::fs::create_dir_all(&entity).unwrap();
|
||||
std::fs::write(entity.join("128.webp"), b"x").unwrap();
|
||||
assert_eq!(count_entities_with_canonical_tier(&server), 0);
|
||||
std::fs::write(entity.join("800.webp"), b"yy").unwrap();
|
||||
assert_eq!(count_entities_with_canonical_tier(&server), 1);
|
||||
let (bytes, count) = server_cover_disk_usage(&server);
|
||||
assert_eq!(count, 1);
|
||||
assert!(bytes >= 3);
|
||||
let _ = std::fs::remove_dir_all(&server);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//! `psysonic-core` — workspace-internal shared primitives.
|
||||
//!
|
||||
//! Hosts the runtime logging facade (with `app_eprintln!` / `app_deprintln!`
|
||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod server_http;
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
pub mod track_enrichment;
|
||||
pub mod user_agent;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user