Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions[bot] 60b5f3ee49 chore(nix): refresh lock + npmDepsHash for v1.46.0-rc.1 (#511)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 23:01:24 +02:00
github-actions[bot] a74a299d6b chore(release): bump next channel to 1.46.0-rc.1 2026-05-07 20:45:00 +00:00
2896 changed files with 112135 additions and 363111 deletions
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
// Architecture / layering guard for the feature-folder structure (PR #1225, group A1).
//
// Encodes the layering contract the restructure established:
//
// lib (feature-free infra: api clients, format, i18n, util, media, server, navigation)
// ▲
// store / ui (cross-cutting global stores; domain-agnostic primitives) — may import lib
// ▲
// cover / music-network (top-level domains — peers; may import lib, store, ui)
// ▲
// features/<x> (may import lib, store, ui, cover, music-network, other features via barrel only)
// ▲
// app (shell + bridges — may import anything)
//
// A lower layer may NOT import a higher one. Cross-feature access goes through the
// `@/features/<x>` barrel only, never a deep path. No import cycles anywhere.
//
// Ratchet: current known violations (residual legacy dirs + documented inversions) are
// captured in `.dependency-cruiser-known-violations.json` and ignored via `--ignore-known`
// (see `npm run dep:check`). Any NEW violation fails CI. As the drain (group E) removes an
// exception, regenerate the baseline so the count ratchets toward zero.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
comment: 'No import cycles anywhere under src/ — they make the module graph impossible to reason about.',
from: {},
to: { circular: true },
},
{
name: 'lib-is-the-floor',
severity: 'error',
comment:
'lib/** is feature-free infra and must not import a higher layer ' +
'(features, store, ui, app, cover, music-network).',
from: { path: '^src/lib/' },
to: { path: '^src/(features|store|ui|app|cover|music-network)/' },
},
{
name: 'no-core-to-feature',
severity: 'error',
comment:
'store/** and ui/** are cross-cutting core and must not import features or the app shell ' +
'(the inversions the seams removed — keep them out).',
from: { path: '^src/(store|ui)/' },
to: { path: '^src/(features|app)/' },
},
{
name: 'no-deep-cross-feature',
severity: 'error',
comment:
'A feature must reach another feature only through its `@/features/<x>` barrel (index), ' +
'never a deep path. Same-feature deep imports are fine.',
from: { path: '^src/features/([^/]+)/' },
to: {
path: '^src/features/([^/]+)/[^/]+',
pathNot: [
'^src/features/$1/', // same feature — allowed
'^src/features/[^/]+/index\\.(ts|tsx)$', // the barrel — allowed
],
},
},
],
options: {
doNotFollow: { path: 'node_modules' },
// Type-only edges are tolerated by the iron rule (erased at runtime), but the ratchet
// records whatever exists today regardless of kind; new edges of any kind fail.
tsPreCompilationDeps: true,
tsConfig: { fileName: 'tsconfig.json' },
enhancedResolveOptions: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
mainFields: ['module', 'main', 'types', 'typings'],
},
// The layering contract is about the production module graph. Tests, test helpers,
// ambient declarations and non-src trees are not part of it.
exclude: {
path: [
'\\.test\\.(ts|tsx)$',
'^src/test/',
'\\.d\\.ts$',
'^src/vite-env',
],
},
reporterOptions: {
text: { highlightFocused: true },
},
},
};
-138
View File
@@ -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.
-16
View File
@@ -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.
-46
View File
@@ -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"]
-48
View File
@@ -1,48 +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 14 coverage
# work lands.
#
# Paths follow the feature-folder layout (`src/features/**`, `src/lib/**`,
# `src/cover/**`) after the frontend restructure; the files below moved out
# of the old `src/utils/**` tree but their contents are unchanged.
#
# Deferred from the gate, with current coverage shown for reference:
# - src/features/playback/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/lib/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
# ── extracted helpers (already at or above threshold) ────────────────
src/cover/coverArtRegisteredSizes.ts
src/lib/server/serverDisplayName.ts
src/lib/server/serverMagicString.ts
src/lib/share/shareLink.ts
src/lib/dom/dynamicColors.ts
src/features/playback/utils/playback/resolvePlaybackUrl.ts
src/lib/share/copyEntityShareLink.ts
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
src/lib/util/shuffleArray.ts
src/features/playback/utils/audio/resolveReplayGainDb.ts
src/lib/media/songToTrack.ts
src/features/playback/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/features/playback/store/previewStore.ts
-42
View File
@@ -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
-188
View File
@@ -1,188 +0,0 @@
/**
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
*/
const FRONTEND_PATH_RE =
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.dependency-cruiser\.cjs$|\.dependency-cruiser-known-violations\.json$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
const FRONTEND_JOBS = [
'vitest run',
'tsc --noEmit',
'vitest --coverage (baseline + hot-path file gate)',
'eslint',
'dependency-cruiser',
];
const RUST_JOBS = [
'cargo test --workspace',
'cargo clippy --workspace',
'cargo llvm-cov (baseline + hot-path file gate)',
];
const POLL_MS = 30_000;
const TIMEOUT_MS = 90 * 60 * 1000;
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
export function pathTriggersFrontend(file) {
return FRONTEND_PATH_RE.test(file);
}
export function pathTriggersRust(file) {
return RUST_PATH_RE.test(file);
}
export function requiredJobNames(changedFiles) {
const names = [];
if (changedFiles.some(pathTriggersFrontend)) {
names.push(...FRONTEND_JOBS);
}
if (changedFiles.some(pathTriggersRust)) {
names.push(...RUST_JOBS);
}
return names;
}
export function newestChecksByName(checks, excludeRunId) {
const newest = new Map();
for (const check of checks) {
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
continue;
}
const key = check.name;
const prev = newest.get(key);
if (!prev) {
newest.set(key, check);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
if (curTime >= prevTime) {
newest.set(key, check);
}
}
return newest;
}
export function evaluateRequiredJobs(required, newestByName) {
const pending = [];
const failures = [];
for (const name of required) {
const latest = newestByName.get(name);
if (!latest) {
pending.push(`${name}: not started`);
continue;
}
if (latest.status !== 'completed') {
pending.push(`${name}: status=${latest.status}`);
continue;
}
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
failures.push(`${name}: conclusion=${latest.conclusion}`);
}
}
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
}
export async function listChangedFiles(github, context) {
const { owner, repo } = context.repo;
if (context.eventName === 'pull_request') {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
if (context.eventName === 'push') {
const before = context.payload.before;
const after = context.sha;
if (!before || /^0+$/.test(before)) {
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
return commit.data.files?.map((f) => f.filename) ?? [];
}
const compare = await github.rest.repos.compareCommits({
owner,
repo,
base: before,
head: after,
});
return compare.data.files?.map((f) => f.filename) ?? [];
}
if (context.eventName === 'workflow_run') {
const pr = context.payload.workflow_run.pull_requests?.[0];
if (pr?.number) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
const headSha = context.payload.workflow_run.head_sha;
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
return commit.data.files?.map((f) => f.filename) ?? [];
}
return [];
}
export function resolveTargetSha(context) {
if (context.eventName === 'pull_request') {
return context.payload.pull_request.head.sha;
}
if (context.eventName === 'workflow_run') {
return context.payload.workflow_run.head_sha;
}
return context.sha;
}
export async function runCiOkAggregate(github, context, core) {
const { owner, repo } = context.repo;
const sha = resolveTargetSha(context);
const excludeRunId = String(context.runId);
const changedFiles = await listChangedFiles(github, context);
const required = requiredJobNames(changedFiles);
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
if (required.length === 0) {
core.info('No path-filtered test workflows apply — ci-ok passes.');
return;
}
core.info(`Waiting for required job checks: ${required.join(', ')}`);
const deadline = Date.now() + TIMEOUT_MS;
while (Date.now() < deadline) {
const checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
const newestByName = newestChecksByName(checksAll, excludeRunId);
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
if (failures.length > 0) {
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
return;
}
if (done) {
core.info('All required checks are green.');
return;
}
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
}
-54
View File
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
evaluateRequiredJobs,
newestChecksByName,
pathTriggersFrontend,
pathTriggersRust,
requiredJobNames,
} from './ci-ok-aggregate.mjs';
test('pathTriggersFrontend matches frontend workflow paths', () => {
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
assert.equal(pathTriggersFrontend('README.md'), false);
});
test('pathTriggersRust matches rust workflow paths', () => {
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
assert.equal(pathTriggersRust('src/App.tsx'), false);
});
test('requiredJobNames unions frontend and rust jobs', () => {
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
assert.ok(names.includes('eslint'));
assert.ok(names.includes('cargo test --workspace'));
});
test('evaluateRequiredJobs fails on red conclusions', () => {
const newest = newestChecksByName([
{
name: 'eslint',
status: 'completed',
conclusion: 'failure',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
},
]);
const result = evaluateRequiredJobs(['eslint'], newest);
assert.equal(result.done, false);
assert.equal(result.failures.length, 1);
});
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
const checks = ['eslint', 'vitest run'].map((name) => ({
name,
status: 'completed',
conclusion: 'success',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
}));
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
assert.equal(result.done, true);
});
+4 -19
View File
@@ -1,30 +1,15 @@
name: ci-main
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_run:
workflows: [frontend-tests, rust-tests, eslint]
types: [completed]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
checks: read
pull-requests: read
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
- name: aggregate required checks
uses: actions/github-script@v9
with:
script: |
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
await runCiOkAggregate(github, context, core);
- name: ci sentinel
run: echo "main CI sentinel is green"
-66
View File
@@ -1,66 +0,0 @@
name: eslint
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.dependency-cruiser.cjs'
- '.dependency-cruiser-known-violations.json'
- '.github/workflows/eslint.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'
- 'eslint.config.mjs'
- '.dependency-cruiser.cjs'
- '.dependency-cruiser-known-violations.json'
- '.github/workflows/eslint.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:
eslint:
name: eslint
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: eslint
run: npm run lint
dependency-cruiser:
name: dependency-cruiser
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: layering + cycle guard
run: npm run dep:check
-86
View File
@@ -1,86 +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'
- 'eslint.config.mjs'
- '.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'
- 'eslint.config.mjs'
- '.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
+1 -1
View File
@@ -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
+74 -97
View File
@@ -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."
-92
View File
@@ -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
-21
View File
@@ -1,21 +0,0 @@
name: Publish to WinGet
on:
release:
types: [released]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Derive winget version from app-v* tag
id: ver
env:
TAG: ${{ github.event.release.tag_name }}
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
- name: Submit to WinGet Community Repository
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Psychotoxical.Psysonic
version: ${{ steps.ver.outputs.value }}
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
token: ${{ secrets.WINGET_TOKEN }}
-12
View File
@@ -32,17 +32,6 @@ 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).
# Ignore the contents (not the dir) so the committed tauri-specta FE↔BE contract
# snapshot below can be re-included — a `!` exception cannot escape an ignored dir.
src/generated/*
# ...except the committed tauri-specta contract snapshot, so CI diffs catch drift.
!src/generated/bindings.ts
# Documentation
CLAUDE.md
@@ -70,4 +59,3 @@ result-*
dev.sh
shell.nix
prod.sh
tsconfig.tsbuildinfo
+116 -2882
View File
File diff suppressed because it is too large Load Diff
-209
View File
@@ -1,209 +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), [`eslint.yml`](.github/workflows/eslint.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:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. 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.
- **Adding a new language (not just keys):** the Rust cluster-key normalizer (`src-tauri/crates/psysonic-library/src/identity/norm.rs`) folds diacritics/ligatures per shipped locale so library items match across servers. When a locale introduces a script or letters it does not yet cover, extend its decomposition table for that language and **bump `NORM_VERSION`** (existing `library-cluster.db` keys rebuild automatically). CJK locales are intentionally left verbatim. The module header carries the same checklist.
---
## 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.
---
## Frontend architecture and layering
The frontend uses a feature-folder architecture (introduced in #1225) with a layering contract that CI enforces through **`npm run dep:check`** (dependency-cruiser). The rule is simple: **a lower layer must never import a higher one.**
```
lib → store / ui → cover / music-network → features/<x> → app
```
- **`lib/**` is the floor** — feature-free infrastructure (API clients, formatting, i18n, util, media, server, navigation). It must **not** import from `store`, `ui`, `features`, `cover`, `music-network`, or `app`. If a `lib` helper needs auth/server state, pass it in as an argument or keep the helper in the layer that owns that state (`store` or `features`) — do not reach into a store from `lib`.
- **`store` / `ui`** may import `lib` only.
- **`cover` / `music-network`** are top-level domains and may import `lib`, `store`, `ui`.
- **`features/<x>`** may import `lib`, `store`, `ui`, `cover`, `music-network`, and other features — but cross-feature access goes **only** through the `@/features/<x>` barrel, never a deep path.
- **`app`** (shell + bridges) may import anything.
- **No import cycles** anywhere under `src/`.
The authoritative rules and rationale live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs) (its header documents the layers and the known-violation ratchet). Any **new** layering or cycle violation fails the `dependency-cruiser` job and blocks `ci-ok`, so run **`npm run dep:check`** locally before opening a frontend PR. The known-violations baseline in `.dependency-cruiser-known-violations.json` only ratchets **down** — don't regenerate it to silence a new violation; fix the import instead.
---
## 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, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `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.
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
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 run lint
npm run dep:check
npm test
npm run prebuild:release-notes
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.
+6 -12
View File
@@ -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
+69 -134
View File
@@ -14,14 +14,10 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **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"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></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, Hungarian and Polish.
More translations are added over time.
**No telemetry • Native performance • Navidrome-first • Community driven**
</div>
@@ -32,148 +28,104 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
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.
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**, and it leans into that on purpose: instead of being one more generic Subsonic client, it is **the Navidrome-first desktop client that does things no other client does.** Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
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.
---
# ⭐ Key Features
# Highlights
These are the things that set Psysonic apart. To our knowledge, no comparable self-hosted desktop client ships them.
## 🪐 Orbit — Shared Listening
**Listen together, in sync, over your own server.**
Orbit brings real-time synchronized group listening into Psysonic. Start a session, invite people with a link, and everyone hears the same thing at the same time — with host-controlled playback, a shared queue and guest song suggestions.
The clever part: Orbit rides entirely on **your own Navidrome**. There is no external relay, no third-party service and no extra accounts. The session lives on your server, where it belongs. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
<div align="left">
<img src="public/orbit.png" alt="Orbit shared listening" width="520"/>
</div>
## ⚡ Local Library — Instant, and Almost Offline
**A local index of your whole collection, so the app stays fast no matter what your connection does.**
Psysonic keeps a local library of your collection's metadata right on your machine. Because the app already knows your tracks inside out, browsing, searching and starting playback are instant — even a 500 MB FLAC starts the moment you hit play, because nothing has to be fetched or parsed first.
It also means the connection to your server stops being a bottleneck. Even on a slow, flaky or distant link, Psysonic stays responsive and **behaves almost like an offline player**, whatever the network is doing.
And it's the foundation everything else is built on: the local library is what makes on-device analysis, smart audio and snappy navigation possible in the first place.
## 🧠 On-Device Audio Analysis
**One of the most powerful things Psysonic does — entirely on your own machine.**
Built on top of the local library, Psysonic analyzes your tracks locally — **loudness, waveform and tempo** — with no cloud service and no required server-side plugin. That analysis is what powers content-aware AutoDJ transitions, LUFS-based loudness normalization and playback-speed control.
This is a deep, genuinely useful layer that most clients simply don't have, and because it runs locally, it works exactly the same whether you're fully online or barely connected.
## 🎧 AutoDJ — Content-Aware Crossfade
**A DJ that listens to the music, not a stopwatch.**
Most players do fixed-time crossfade: blend the last N seconds into the next N seconds, dead air and all. AutoDJ uses Psysonic's own audio analysis to **trim the silence at the edges of a track and blend out of the actual music** — for transitions that sound deliberate instead of mechanical. It is a standalone playback mode with smooth skip/interrupt handling and a configurable overlap.
## 🔗 Navidrome-Native, Deeply
**Not a generic Subsonic client wearing a Navidrome hat.**
Psysonic binds Navidrome's native capabilities directly: server-side smart-playlist create/edit, playback reporting and OpenSubsonic capability probing. Most clients in this space stay Subsonic-generic. Psysonic goes deeper, so Navidrome users get the features their server can actually deliver.
## 🎨 Community Theme Store
**A real marketplace for themes — installable and schedulable.**
Beyond a big set of built-in themes, Psysonic has a first-party theme registry: browse community themes, install them in-app, and let the **Theme Scheduler** switch looks automatically between day and night.
---
> ### Built to be trusted
>
> We take an enterprise-grade approach to development — continuously improving our automated testing and maintaining strict contracts between the backend and the frontend. Releases are cut from green CI, not vibes.
---
# ✨ More Highlights
Features that go well beyond the basics. Not all of these are unique to Psysonic, but few clients bring this many together.
## Audio & Loudness
## Playback & Queue
* Gapless playback
* Crossfade
* ReplayGain support
* LUFS-based Smart Loudness Normalization
* ReplayGain support and loudness-aware playback
* 10-band Equalizer with presets
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
* Infinite Queue
* Smart Radio sessions
* Fast and responsive playback handling
* Low memory usage compared to heavy web-first clients
## Audio Tools
* 10-band Equalizer
* Equalizer presets
* AutoEQ headphone correction
* Per-device EQ and output optimization
* Adjustable playback speed
* Per-device optimization
* Loudness-aware playback options
## Lyrics & Listening
## Library Management
* Synced lyrics with seek support, from multiple providers ([YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB, NetEase)
* Auto-scrolling sidebar lyrics and a fullscreen lyric mode
* Last.fm scrobbling, similar artists, loved tracks and listening stats
* Smart Radio sessions and an Infinite Queue
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support for sonic-similarity discovery (requires an AudioMuse-AI server)
## Artwork & Visuals
* Optional external artist imagery via **fanart.tv** — opt-in, shown on the artist page, fullscreen player and home hero (Navidrome stays the canonical cover-art source)
* Cover art surfaced across the app, OS media controls and Discord Rich Presence
## Library & Playlists
* Smart Playlists
* Drag & drop playlist management
* Fast search across large libraries
* Albums, artists, tracks and genres
* Ratings support
* Multi-select bulk actions
* Drag & drop playlist management
* Smart Playlists
* Built for large self-hosted collections
## Sharing
## Lyrics & Discovery
* Magic Strings sharing for albums, artists and queues
* Navidrome user-management helpers for fast account sharing
* Synced lyrics with seek support
* Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
* Auto-scrolling sidebar lyrics
* Fullscreen lyric mode
* Last.fm scrobbling
* Similar artists
* Loved tracks and listening stats
## Offline, Sync & Deployment
## Sharing & Social Listening
* Offline playback and downloads
* USB / portable sync
* LAN / remote auto-switching
* Custom HTTP headers for reverse-proxy-gated servers (e.g. Cloudflare Access, Pangolin)
* Backup and restore settings
* In-app auto updater
* 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
* Font customization and zoom controls
* Large theme collection
* Catppuccin and 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
## Power-User Extras
## Power User Extras
* CLI controls
* USB / portable sync
* Backup and restore settings
* In-app auto updater
* LAN / remote auto switching
---
# ✅ The Basics, Done Right
<div align="left">
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
</div>
The things you simply expect from a serious music player — and Psysonic does them well.
Orbit brings synchronized shared listening sessions directly into Psysonic.
* Gapless playback and crossfade
* Fast search across large libraries
* Browse albums, artists, tracks and genres
* Ratings
* Queue management
* Keyboard navigation
* Media key support
* Low memory usage and native performance compared to heavy web-first clients
* Built for large self-hosted collections
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.
---
@@ -181,10 +133,12 @@ The things you simply expect from a serious music player — and Psysonic does t
| OS | Support |
| ------- | --------------------------------------------------------------- |
| Windows | Native installer / WinGet |
| Windows | Native installer |
| macOS | Signed DMG |
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
Psysonic supports **8 languages** and growing.
---
# Install
@@ -197,18 +151,9 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
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
```
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
## macOS
@@ -218,8 +163,6 @@ Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxi
# Development
Contributor expectations (PRs, CI, Tauri boundary, UI): [CONTRIBUTING.md](CONTRIBUTING.md).
```bash
git clone https://github.com/Psychotoxical/psysonic.git
cd psysonic
@@ -244,14 +187,6 @@ Psysonic is built for self-hosted music collections. Your library is yours.
* 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.
---
# Reviews
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
---
# Community & Support
+1 -4
View File
@@ -21,14 +21,12 @@ 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`
- `release` version format: `X.Y.Z`
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
Rules:
1. Never edit versions manually in random commits.
@@ -51,7 +49,6 @@ Rules:
### 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
View File
@@ -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).
-66
View File
@@ -1,66 +0,0 @@
# Privacy & Telemetry
Privacy is not an afterthought in Psysonic. It has been part of the projects 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
- Fanart.tv
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.
-361
View File
@@ -1,361 +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.50.0]
## Highlights
### Multi-library filter — browse across your libraries
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
### Navidrome public share links — listen without logging in
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
### Fullscreen player — Minimal, Immersive, and Prism
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
### Lyrics that follow the singer, word by word
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
### Player bar — build your own, plus shuffle
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
### Track lists — optional album cover thumbnails
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
### Discord — Server cover art without exposing your login
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
### Artists browse — album artists or track credits
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
### Theme Store — what's new on each theme
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
### Italian and Bulgarian — now in your language
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
### Start minimized to tray
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
### Square corners — a sharper, boxier look
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
## Improved
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
## Fixed
### Playback and audio
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
### Offline, Now Playing, and Navidrome
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
### Themes and integrations
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
### Browse and library
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
### Other
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
- Modal dialogs now announce their title to screen readers when they open.
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
## [1.49.0]
## Highlights
### Play queue sync — pick up where you left off on another device
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 230 s) under **Settings → Audio → Track transitions**.
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
### 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.
### 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, Hungarian, and Polish — now in your language
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
### 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.
### Hi-Res playback — smoother transitions between sample rates
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
### Artist artwork — richer home, 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, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
### 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, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
### Orbit — everyone hears transitions the host chose
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
### Themes — follow your system's light and dark mode
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
### Servers behind a reverse proxy — custom HTTP headers
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
### Album details — every genre, not just the first
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
### Compact buttons — switch to icon-only controls
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
### Playlists — sort by date added
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
## Improved
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
## Fixed
### Playback and audio
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
### Offline, Now Playing, and Navidrome
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
### Browse and library
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
- The **Artists** AZ index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
### Other
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
## Under the hood
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
## [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.
+1 -1
View File
@@ -149,7 +149,7 @@ case ${sub[1]} in
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100, or ±N for relative change'
(( n == 1 )) && _message -e descriptions 'percent 0100'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
+1 -6
View File
@@ -165,17 +165,12 @@ _psysonic_complete() {
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek)
seek|volume)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
volume)
if (( n == 1 )); then
_psysonic_compreply_from_compgen '+5 +10 -5 -10 50' "$cur"
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
-45
View File
@@ -1,45 +0,0 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
// this config only — the strict config keeps the default reporting and stays 0/0.
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
-45
View File
@@ -1,45 +0,0 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
// `src/generated` holds machine-generated output (release-notes bundle,
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'error',
// Promote deps to error at strict stage (wave 6+)
'react-hooks/exhaustive-deps': 'error',
},
},
);
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github"
},
"original": {
+17 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-uWI/+FnIIrPO3sKB3CI/jdjDNOzoOFi6h6D12ZaaG/A="
"npmDepsHash": "sha256-0U+tI6wzMsvA2pmnYUhrH0Vo88lbFtmMK/gkkTBzuf4="
}
+10 -10
View File
@@ -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.
+387 -3508
View File
File diff suppressed because it is too large Load Diff
+17 -42
View File
@@ -1,25 +1,15 @@
{
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.46.0-rc.1",
"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 --config src-tauri/tauri.dev.conf.json",
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
"build:verify": "npm run build && npm run check:boot-chunks",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"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",
@@ -37,7 +27,7 @@
"@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.24",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
@@ -49,41 +39,26 @@
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.16.0",
"i18next": "^26.0.8",
"lucide-react": "^1.17.0",
"lucide-react": "^1.14.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^19.2.5",
"react-dom": "^19.2.6",
"react-dom": "^19.2.5",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.16.0",
"zustand": "^5.0.14"
"react-router-dom": "^7.15.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@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",
"@tauri-apps/cli": "^2",
"@types/md5": "^2.3.6",
"@types/node": "^25.6.0",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"dependency-cruiser": "^18.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.28.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
"overrides": {
"undici": "^7.28.0"
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.49.0
pkgver=1.45.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
-179
View File
@@ -1,179 +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();
function readStartMinimizedToTray() {
try {
var raw = localStorage.getItem('psysonic-auth');
if (!raw) return false;
var parsed = JSON.parse(raw);
var state = parsed && parsed.state;
if (!state || !state.startMinimizedToTray) return false;
return state.showTrayIcon !== false;
} catch (_err) {
return false;
}
}
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
var trayHandled = false;
try {
trayHandled = sessionStorage.getItem('psy-startup-tray-handled') === '1';
} catch (_err) {}
window.__psyStartMinimizedToTray = readStartMinimizedToTray() && !trayHandled;
})();
-57
View File
@@ -1,57 +0,0 @@
/**
* Show the native window after the inline startup splash has painted.
* When starting minimized to tray, hide the main window as early as possible
* (visible:false may still map briefly on some Linux WMs before this script).
* __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 tryHideMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|hide', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (window.__psyStartMinimizedToTray) {
if (tryHideMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
return;
}
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
}
if (window.__psyStartMinimizedToTray) {
// Mark this synchronously, before React mounts. This deliberately does
// not set the CSS animation-pause attribute: entrance animations may
// still mount while the native window is hidden.
window.__psyHidden = true;
try {
sessionStorage.setItem('psy-startup-tray-handled', '1');
} catch (_err) {}
reveal(0);
return;
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
});
});
})();
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env node
/**
* Post-build guard: boot-adjacent Vite chunks must not bundle lucide-react.
*
* When a store/utils barrel accidentally re-exports UI, Rollup may pull
* createLucideIcon into authStore/offline chunks and hit TDZ init-order bugs
* in production (Windows WebView2: "X is not a function" on splash).
*
* Run after `npm run build` — no Tauri compile needed.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const DIST = join(new URL('..', import.meta.url).pathname, 'dist/assets');
/** Chunk filename prefixes that must stay lucide-free. */
const BOOT_CHUNK_PREFIXES = ['authStore-', 'offline-'];
/** Minified bundles still embed lucide module paths or icon factory calls. */
const LUCIDE_SIGNALS = [
'lucide-react',
'createLucideIcon',
// Common preset/offline icons pulled through bad barrels (minified arg strings):
'("globe"',
'("settings"',
'("wifi-off"',
'("download"',
];
/** Subsonic client id must be a compile-time literal, not a cyclic package.json import. */
const CLIENT_ID_SIGNALS = [
'psysonic/undefined',
'psysonic/${',
];
let files;
try {
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
} catch {
console.error('check-boot-chunk-lucide: dist/assets not found — run `npm run build` first.');
process.exit(1);
}
const violations = [];
for (const file of files) {
if (!BOOT_CHUNK_PREFIXES.some(p => file.startsWith(p))) continue;
const text = readFileSync(join(DIST, file), 'utf8');
for (const signal of LUCIDE_SIGNALS) {
if (text.includes(signal)) {
violations.push({ file, signal });
}
}
for (const signal of CLIENT_ID_SIGNALS) {
if (text.includes(signal)) {
violations.push({ file, signal: `client-id: ${signal}` });
}
}
}
if (violations.length > 0) {
console.error('Lucide leaked into boot-critical chunks:\n');
for (const { file, signal } of violations) {
console.error(` • dist/assets/${file} — matched "${signal}"`);
}
console.error(
'\nFix: split UI from the feature root barrel; import UI from @/features/<x>/ui only in components.',
);
process.exit(1);
}
console.log('check-boot-chunk-lucide: ok');
-63
View File
@@ -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);
}
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env node
/**
* Guard: boot-critical feature barrels must not re-export UI modules.
*
* Re-exporting components/hooks that pull lucide-react through a barrel while
* the same barrel (or its stores) is imported from boot paths creates production
* init-order failures (`createLucideIcon is not a function` in minified chunks).
*
* Not every feature barrel is checked — album/artist/etc. export UI for lazy
* routes and are safe. Only barrels that stores/utils on the boot path import
* from are listed in BOOT_CRITICAL_BARRELS.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
/** Barrels whose non-UI surface is imported before or during first paint. */
const BOOT_CRITICAL_BARRELS = [
{ file: join(ROOT, 'src/features/offline/index.ts'), label: 'src/features/offline/index.ts' },
{ file: join(ROOT, 'src/music-network/index.ts'), label: 'src/music-network/index.ts' },
];
const FORBIDDEN_EXPORT_PATTERNS = [
/from\s+['"]\.\/components\//,
/from\s+['"]\.\/ui\//,
/export\s+\*\s+from\s+['"]\.\/components\//,
/export\s+\*\s+from\s+['"]\.\/ui\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/components\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/ui\//,
];
/** @param {string} file @param {string} label */
function checkBarrel(file, label) {
const text = readFileSync(file, 'utf8');
const hits = [];
for (const re of FORBIDDEN_EXPORT_PATTERNS) {
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
if (re.test(line)) hits.push(trimmed);
}
}
if (hits.length === 0) return [];
return hits.map(h => `${label}: ${h}`);
}
const errors = [];
for (const { file, label } of BOOT_CRITICAL_BARRELS) {
try {
statSync(file);
errors.push(...checkBarrel(file, label));
} catch {
errors.push(`${label}: missing barrel file`);
}
}
if (errors.length > 0) {
console.error('Boot-critical barrel UI re-export violations:\n');
for (const e of errors) console.error(`${e}`);
console.error(
'\nFix: remove UI exports from the root barrel; import from @/features/<x>/ui or deep paths.',
);
process.exit(1);
}
console.log('check-feature-barrel-ui: ok');
-105
View File
@@ -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
-110
View File
@@ -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
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env bash
# Compare two environment dumps from dump-environment.sh
# Usage: ./scripts/compare-environment.sh FILE_A FILE_B
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 FILE_A FILE_B" >&2
exit 1
fi
FILE_A="$1"
FILE_B="$2"
for f in "$FILE_A" "$FILE_B"; do
if [[ ! -f "$f" ]]; then
echo "Missing file: $f" >&2
exit 1
fi
done
parse_dump() {
local file="$1"
awk -F= '
/^\[/ {
section = substr($0, 2, length($0) - 2)
next
}
/^[[:space:]]*$/ { next }
/^#/ { next }
{
key = $1
$1 = ""
sub(/^=/, "", $0)
full = (section == "" ? key : section "." key)
print full "\t" $0
}
' "$file" | sort -u
}
TMP_A="$(mktemp)"
TMP_B="$(mktemp)"
TMP_JOIN="$(mktemp)"
trap 'rm -f "$TMP_A" "$TMP_B" "$TMP_JOIN"' EXIT
parse_dump "$FILE_A" >"$TMP_A"
parse_dump "$FILE_B" >"$TMP_B"
join -t $'\t' -a 1 -a 2 -e '—' -o '0,1.2,2.2' "$TMP_A" "$TMP_B" >"$TMP_JOIN"
ONLY_A=0
ONLY_B=0
DIFF=0
SAME=0
echo "Comparing:"
echo " A: $FILE_A"
echo " B: $FILE_B"
echo
while IFS=$'\t' read -r key val_a val_b; do
[[ -z "$key" ]] && continue
if [[ "$val_a" == "—" ]]; then
ONLY_B=$((ONLY_B + 1))
printf ' + %-40s B=%s\n' "$key" "$val_b"
elif [[ "$val_b" == "—" ]]; then
ONLY_A=$((ONLY_A + 1))
printf ' - %-40s A=%s\n' "$key" "$val_a"
elif [[ "$val_a" != "$val_b" ]]; then
DIFF=$((DIFF + 1))
printf ' ≠ %-40s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
else
SAME=$((SAME + 1))
fi
done <"$TMP_JOIN"
echo
echo "Summary: same=$SAME different=$DIFF only_in_A=$ONLY_A only_in_B=$ONLY_B"
# High-signal keys for the common "works on one NixOS box" case.
echo
echo "High-signal differences (if any):"
HIGH_SIGNAL=0
while IFS=$'\t' read -r key val_a val_b; do
[[ "$val_a" == "$val_b" ]] && continue
case "$key" in
meta.flake_lock_sha256|meta.nixpkgs_locked_rev|meta.npm_lock_sha256|meta.git_rev|\
installed_app.psysonic_realpath|installed_app.psysonic_closure_paths|installed_app.psysonic_drv|\
runtime_env.HTTP_PROXY|runtime_env.HTTPS_PROXY|runtime_env.http_proxy|runtime_env.https_proxy|\
runtime_env.NO_PROXY|runtime_env.no_proxy|runtime_env.SSL_CERT_FILE|runtime_env.NIX_SSL_CERT_FILE|\
toolchain_nix_develop.node_realpath|toolchain_nix_develop.rustc_realpath|\
host.nixos_version|host.uname|\
app_config_paths.data_dir|app_config_paths.localstorage_db_path|\
app_preferences.language|app_servers.active_server_id|app_servers.server_count|\
app_servers.server.*.url|app_servers.server.*.alternateUrl|\
app_servers.server.*.customHeaders_count|app_servers.server.*.customHeadersApplyTo|\
app_servers.server.*.password_sha256|app_servers.server.*.customHeaders.*.name|\
app_servers.server.*.customHeaders.*.value_sha256|\
app_network_probe.probe.*)
HIGH_SIGNAL=$((HIGH_SIGNAL + 1))
printf ' ! %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
;;
esac
done <"$TMP_JOIN"
if [[ "$HIGH_SIGNAL" -eq 0 && "$DIFF" -eq 0 && "$ONLY_A" -eq 0 && "$ONLY_B" -eq 0 ]]; then
echo " (none — environments match on recorded keys)"
elif [[ "$HIGH_SIGNAL" -eq 0 ]]; then
echo " (no high-signal keys differ; see full list above — may be npm patch-level deps only)"
fi
echo
echo "Server / network config differences:"
SERVER_DIFF=0
while IFS=$'\t' read -r key val_a val_b; do
[[ "$val_a" == "$val_b" ]] && continue
case "$key" in
app_servers.*|app_network_probe.*|app_config_paths.*|app_preferences.*)
SERVER_DIFF=$((SERVER_DIFF + 1))
printf ' • %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
;;
esac
done <"$TMP_JOIN"
if [[ "$SERVER_DIFF" -eq 0 ]]; then
echo " (none — server URLs, headers, and curl probes match)"
fi
echo
echo "Reminder: server offline is usually URL/network/headers, not Node patch versions."
echo "Next: curl the Navidrome URL from both hosts; compare Settings → Servers side by side."
if [[ "$DIFF" -gt 0 || "$ONLY_A" -gt 0 || "$ONLY_B" -gt 0 ]]; then
exit 1
fi
-254
View File
@@ -1,254 +0,0 @@
#!/usr/bin/env bash
# Dump Psysonic-related toolchain, Nix closure hints, app config, and env for cross-machine diff.
# Re-enters `nix develop` automatically when flake.nix is present (no manual dev shell needed).
# Usage: ./scripts/dump-environment.sh [-o FILE]
# Compare: ./scripts/compare-environment.sh a.txt b.txt
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/dump-environment.sh"
# Bootstrap: run the rest inside the flake dev shell so node/jq match the project.
if [[ -z "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]] && [[ -f "$REPO_ROOT/flake.nix" ]]; then
if ! command -v nix >/dev/null 2>&1; then
echo "$0: flake.nix found but nix is not on PATH — install Nix or run from a NixOS profile with flakes." >&2
exit 1
fi
export PSYSONIC_ENV_DUMP_IN_NIX=1
exec env REPO_ROOT="$REPO_ROOT" nix develop --command bash "$SCRIPT" "$@"
fi
cd "$REPO_ROOT"
if ! command -v node >/dev/null 2>&1; then
echo "$0: node not found after nix develop — check flake.nix devShell." >&2
exit 1
fi
OUTPUT_FILE=""
while getopts 'o:h' opt; do
case "$opt" in
o) OUTPUT_FILE="$OPTARG" ;;
h)
echo "Usage: $0 [-o FILE]" >&2
exit 0
;;
*)
echo "Usage: $0 [-o FILE]" >&2
exit 1
;;
esac
done
emit() {
if [[ -n "$OUTPUT_FILE" ]]; then
printf '%s\n' "$*" >>"$OUTPUT_FILE"
else
printf '%s\n' "$*"
fi
}
kv() {
local key="$1"
local value="${2-}"
value="${value//$'\n'/\\n}"
emit "${key}=${value}"
}
section() {
emit ""
emit "[$1]"
}
run_optional() {
"$@" 2>/dev/null || true
}
if [[ -n "$OUTPUT_FILE" ]]; then
: >"$OUTPUT_FILE"
fi
section meta
kv generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
kv in_nix_dev_shell "${PSYSONIC_ENV_DUMP_IN_NIX:-no}"
kv hostname "$(hostname 2>/dev/null || echo unknown)"
kv repo_root "$REPO_ROOT"
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
kv git_rev "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
kv git_branch "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
kv git_dirty "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
else
kv git_rev "n/a"
kv git_branch "n/a"
kv git_dirty "n/a"
fi
if [[ -f "$REPO_ROOT/package.json" ]]; then
kv package_json_version "$(node -p "require('./package.json').version" 2>/dev/null || sed -n 's/.*\"version\": \"\\([^\"]*\\)\".*/\\1/p' "$REPO_ROOT/package.json" | head -1)"
fi
if [[ -f "$REPO_ROOT/flake.lock" ]]; then
kv flake_lock_sha256 "$(sha256sum "$REPO_ROOT/flake.lock" | awk '{print $1}')"
if command -v jq >/dev/null 2>&1; then
kv nixpkgs_locked_rev "$(jq -r '.nodes.nixpkgs.locked.rev // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
kv nixpkgs_locked_narHash "$(jq -r '.nodes.nixpkgs.locked.narHash // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
fi
fi
if [[ -f "$REPO_ROOT/package-lock.json" ]]; then
kv npm_lockfile_version "$(jq -r '.lockfileVersion // "unknown"' "$REPO_ROOT/package-lock.json" 2>/dev/null || echo unknown)"
kv npm_lock_sha256 "$(sha256sum "$REPO_ROOT/package-lock.json" | awk '{print $1}')"
fi
section host
kv uname "$(uname -a 2>/dev/null || echo unknown)"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
source /etc/os-release
kv os_id "${ID:-unknown}"
kv os_version "${VERSION_ID:-unknown}"
kv os_pretty "${PRETTY_NAME:-unknown}"
fi
if command -v nixos-version >/dev/null 2>&1; then
kv nixos_version "$(nixos-version 2>/dev/null || echo unknown)"
fi
section nix
if command -v nix >/dev/null 2>&1; then
kv nix_version "$(nix --version 2>/dev/null | head -1)"
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
else
kv nix_version "not_installed"
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
fi
dump_toolchain_block() {
local label="$1"
shift
section "$label"
for tool in node npm rustc cargo clippy jq cmake pkg-config; do
if command -v "$tool" >/dev/null 2>&1; then
case "$tool" in
node) kv node_version "$("$tool" -v 2>/dev/null)" ;;
npm) kv npm_version "$("$tool" -v 2>/dev/null)" ;;
rustc) kv rustc_version "$("$tool" -V 2>/dev/null | head -1)" ;;
cargo) kv cargo_version "$("$tool" -V 2>/dev/null | head -1)" ;;
clippy) kv clippy_version "$("$tool" -V 2>/dev/null | head -1)" ;;
jq) kv jq_version "$("$tool" --version 2>/dev/null | head -1)" ;;
cmake) kv cmake_version "$("$tool" --version 2>/dev/null | head -1)" ;;
pkg-config) kv pkg_config_version "$("$tool" --version 2>/dev/null | head -1)" ;;
esac
kv "${tool}_path" "$(command -v "$tool")"
if [[ -L "$(command -v "$tool")" ]] || [[ -e "$(command -v "$tool")" ]]; then
kv "${tool}_realpath" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
fi
else
kv "${tool}_version" "missing"
fi
done
kv CARGO_TARGET_DIR "${CARGO_TARGET_DIR:-unset}"
kv LD_LIBRARY_PATH "${LD_LIBRARY_PATH:-unset}"
kv GST_PLUGIN_PATH "${GST_PLUGIN_PATH:-unset}"
kv GIO_EXTRA_MODULES "${GIO_EXTRA_MODULES:-unset}"
}
if [[ -n "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]]; then
dump_toolchain_block toolchain_nix_develop
elif command -v nix >/dev/null 2>&1 && [[ -f "$REPO_ROOT/flake.nix" ]]; then
# No bootstrap (should not happen when flake exists) — capture devShell separately.
NIX_DEV_DUMP="$(nix develop --command bash -lc '
set +e
cd "$REPO_ROOT" || exit 0
for tool in node npm rustc cargo clippy jq; do
if command -v "$tool" >/dev/null 2>&1; then
case "$tool" in
node) printf "node_version=%s\n" "$("$tool" -v)" ;;
npm) printf "npm_version=%s\n" "$("$tool" -v)" ;;
rustc) printf "rustc_version=%s\n" "$("$tool" -V | head -1)" ;;
cargo) printf "cargo_version=%s\n" "$("$tool" -V | head -1)" ;;
clippy) printf "clippy_version=%s\n" "$("$tool" -V | head -1)" ;;
jq) printf "jq_version=%s\n" "$("$tool" --version | head -1)" ;;
esac
printf "%s_path=%s\n" "$tool" "$(command -v "$tool")"
printf "%s_realpath=%s\n" "$tool" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
else
printf "%s_version=missing\n" "$tool"
fi
done
printf "CARGO_TARGET_DIR=%s\n" "${CARGO_TARGET_DIR:-unset}"
printf "LD_LIBRARY_PATH=%s\n" "${LD_LIBRARY_PATH:-unset}"
printf "GST_PLUGIN_PATH=%s\n" "${GST_PLUGIN_PATH:-unset}"
printf "GIO_EXTRA_MODULES=%s\n" "${GIO_EXTRA_MODULES:-unset}"
' REPO_ROOT="$REPO_ROOT" 2>/dev/null | tr -d '\r' || true)"
if [[ -n "$NIX_DEV_DUMP" ]]; then
section toolchain_nix_develop
while IFS= read -r line; do
[[ -n "$line" && "$line" == *=* ]] && emit "$line"
done <<<"$NIX_DEV_DUMP"
else
section toolchain_nix_develop
kv status "nix develop failed or unavailable"
fi
else
dump_toolchain_block toolchain_ambient
fi
section runtime_env
for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy GDK_BACKEND PSYSONIC_SKIP_WAYLAND_FONT_TUNING PSYSONIC_ALLOW_NATIVE_GDK SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE; do
kv "$var" "${!var-unset}"
done
kv navigator_online "n/a (browser-only)"
section installed_app
if command -v psysonic >/dev/null 2>&1; then
PSY_PATH="$(command -v psysonic)"
kv psysonic_path "$PSY_PATH"
kv psysonic_realpath "$(readlink -f "$PSY_PATH" 2>/dev/null || echo unknown)"
run_optional kv psysonic_version "$(psysonic --version 2>/dev/null | head -1)"
if command -v nix-store >/dev/null 2>&1; then
kv psysonic_closure_paths "$(nix-store -qR "$PSY_PATH" 2>/dev/null | wc -l | tr -d ' ')"
kv psysonic_drv "$(nix-store -q --deriver "$PSY_PATH" 2>/dev/null | sed 's/\.drv$//' | xargs -r basename 2>/dev/null || echo unknown)"
fi
else
kv psysonic_path "not_in_path"
fi
section npm_dependencies
if [[ -f "$REPO_ROOT/package-lock.json" ]] && command -v node >/dev/null 2>&1; then
REPO_ROOT="$REPO_ROOT" node <<'NODE' 2>/dev/null | while IFS= read -r line; do emit "$line"; done || true
const fs = require('fs');
const path = require('path');
const lockPath = path.join(process.env.REPO_ROOT || '.', 'package-lock.json');
let lock;
try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { process.exit(0); }
const root = lock.packages?.['']?.dependencies || {};
const deps = Object.keys(root).sort();
for (const name of deps) {
const entry = lock.packages?.[`node_modules/${name}`] || lock.packages?.[name];
const version = entry?.version || 'unknown';
console.log(`dep.${name}=${version}`);
}
NODE
else
kv status "node or package-lock.json unavailable"
fi
run_app_config_dump() {
local extractor="$REPO_ROOT/scripts/lib/extract-app-config.mjs"
[[ -f "$extractor" ]] || return 0
if node "$extractor" --repo-root "$REPO_ROOT" >>"${OUTPUT_FILE:-/dev/stdout}" 2>/dev/null; then
:
else
section app_config
kv status "extract-app-config failed (is Psysonic installed / has it been run once?)"
fi
}
run_app_config_dump
section network_probe_hint
kv note_1 "App server profiles and curl probes are in app_servers / app_network_probe sections above."
kv note_2 "Passwords and custom header values are redacted; compare password_sha256 and value_sha256 only."
kv note_3 "Compare two dumps with: scripts/compare-environment.sh a.txt b.txt"
if [[ -n "$OUTPUT_FILE" ]]; then
echo "Wrote $OUTPUT_FILE" >&2
fi
-91
View File
@@ -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();
-26
View File
@@ -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');
});
});
-57
View File
@@ -1,57 +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})`);
// Leaf module for boot-critical client id — must not import package.json at runtime
// in the authStore chunk (circular init → psysonic/undefined on Windows WebView2).
const appVersionTs = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const APP_VERSION = ${JSON.stringify(version)};
/** Subsonic REST \`c\` param and OpenSubsonic client id (\`psysonic/<version>\`). */
export const SUBSONIC_CLIENT_ID = ${JSON.stringify(`psysonic/${version}`)};
`;
writeFileSync(join(outDir, 'appVersion.ts'), appVersionTs, 'utf8');
console.log(`wrote src/generated/appVersion.ts (${version})`);
+8 -19
View File
@@ -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
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env node
/**
* Read Psysonic app config from WebKit localStorage + XDG dirs.
* Emits key=value lines (passwords/secrets redacted; header values hashed).
*
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
function parseArgs(argv) {
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--app-id' && argv[i + 1]) {
out.appId = argv[++i];
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
out.repoRoot = argv[++i];
}
}
return out;
}
function sha256(text) {
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
}
function emitSection(name) {
process.stdout.write(`\n[${name}]\n`);
}
function emit(key, value) {
const v = String(value ?? '').replace(/\n/g, '\\n');
process.stdout.write(`${key}=${v}\n`);
}
function readAppIdFromRepo(repoRoot) {
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
if (!fs.existsSync(conf)) return '';
try {
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
return typeof j.identifier === 'string' ? j.identifier : '';
} catch {
return '';
}
}
function xdgDataHome() {
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
}
function xdgConfigHome() {
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
}
function decodeWebKitValue(raw) {
if (raw == null) return null;
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
if (buf) {
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return new TextDecoder('utf-16le').decode(buf.subarray(2));
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return new TextDecoder('utf-16be').decode(buf.subarray(2));
}
// WebKit often stores UTF-16LE without BOM
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
return new TextDecoder('utf-16le').decode(buf);
}
return buf.toString('utf8');
}
if (typeof raw === 'string') return raw;
return String(raw);
}
function readLocalStorageRaw(dbPath, storageKey) {
try {
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
db.close();
if (!row?.value) return null;
return decodeWebKitValue(row.value);
} catch {
return null;
}
}
function readLocalStorageKey(dbPath, storageKey) {
const text = readLocalStorageRaw(dbPath, storageKey);
if (text == null) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function pickLocalStorageFile(dataDir) {
const dir = path.join(dataDir, 'localstorage');
if (!fs.existsSync(dir)) return null;
const files = fs
.readdirSync(dir)
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
.map(f => path.join(dir, f));
if (files.length === 0) return null;
// Prefer packaged app origin over vite dev (1420) when both exist.
const ranked = files.sort((a, b) => {
const score = p => {
const base = path.basename(p);
if (base.includes('tauri_localhost')) return 0;
if (base.includes('1420')) return 2;
return 1;
};
return score(a) - score(b);
});
for (const file of ranked) {
const auth = readLocalStorageKey(file, 'psysonic-auth');
if (auth?.state?.servers?.length) return file;
}
return ranked[0];
}
function probeHttpReachability(rawUrl) {
if (!rawUrl) return 'empty';
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
const r = spawnSync(
'curl',
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
{ encoding: 'utf8', timeout: 15000 },
);
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
if (r.status !== 0) return `curl_exit_${r.status}`;
return `http_${r.stdout.trim() || '000'}`;
}
function dumpNetworkProbes(servers) {
emitSection('app_network_probe');
const seen = new Set();
servers.forEach((srv, i) => {
for (const [kind, raw] of [
['url', srv.url],
['alternateUrl', srv.alternateUrl],
]) {
if (!raw || seen.has(raw)) continue;
seen.add(raw);
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
emit(`probe.${i}.${kind}_target`, raw);
}
});
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
}
function dumpServerProfiles(state) {
const servers = state?.servers ?? [];
emit('active_server_id', state?.activeServerId ?? '');
emit('server_count', servers.length);
servers.forEach((srv, i) => {
const p = `server.${i}`;
emit(`${p}.id`, srv.id ?? '');
emit(`${p}.name`, srv.name ?? '');
emit(`${p}.url`, srv.url ?? '');
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
emit(`${p}.username`, srv.username ?? '');
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
const headers = srv.customHeaders ?? [];
emit(`${p}.customHeaders_count`, headers.length);
headers.forEach((h, hi) => {
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
});
});
dumpNetworkProbes(servers);
}
function fileMeta(label, filePath) {
if (!filePath || !fs.existsSync(filePath)) {
emit(`${label}_exists`, 'no');
return;
}
const st = fs.statSync(filePath);
emit(`${label}_exists`, 'yes');
emit(`${label}_path`, filePath);
emit(`${label}_size`, st.size);
emit(`${label}_mtime`, st.mtime.toISOString());
}
function listDir(label, dirPath, max = 12) {
if (!fs.existsSync(dirPath)) {
emit(`${label}_exists`, 'no');
return;
}
emit(`${label}_exists`, 'yes');
emit(`${label}_path`, dirPath);
const entries = fs.readdirSync(dirPath).sort();
emit(`${label}_entry_count`, entries.length);
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
}
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
const dataDir = path.join(xdgDataHome(), appId);
const configDir = path.join(xdgConfigHome(), appId);
emitSection('app_config_paths');
emit('app_id', appId);
emit('data_dir', dataDir);
emit('config_dir', configDir);
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
const lsFile = pickLocalStorageFile(dataDir);
fileMeta('localstorage_db', lsFile);
emitSection('app_preferences');
if (lsFile) {
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
emit('language', lang ?? 'unknown');
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
if (authWrap?.state) {
emitSection('app_servers');
dumpServerProfiles(authWrap.state);
} else {
emit('app_servers_status', 'psysonic-auth not found or empty');
}
} else {
emit('app_servers_status', 'no localstorage database found');
}
emitSection('app_config_files');
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
}
emitSection('app_data_artifacts');
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
+1 -25
View File
@@ -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,26 +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}`);
}
require('child_process').execSync('node scripts/sync-wix-bundle-version.mjs', {
cwd: root,
stdio: 'inherit',
});
-84
View File
@@ -1,84 +0,0 @@
/**
* Integration tests for promotion/version sync: npm version → sync-tauri → sync-wix.
* Simulates tauri.conf.json state after the full pipeline (no file I/O).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
/** Mirrors sync-tauri + sync-wix effects on tauri.conf.json. */
function confAfterVersionSync(packageVersion, priorConf) {
const conf = structuredClone(priorConf);
conf.version = packageVersion;
conf.bundle ??= {};
conf.bundle.windows ??= {};
const override = wixVersionOverrideForPackageVersion(packageVersion);
conf.bundle.windows.wix = { ...(conf.bundle.windows.wix ?? {}), version: override };
return conf;
}
const baseConf = {
version: '1.49.0-dev',
bundle: {
windows: {
nsis: { installMode: 'currentUser' },
wix: { version: '1.49.0.1' },
},
},
};
describe('version promotion pipeline (sync-tauri + sync-wix)', () => {
it('main → next: dev to first RC increases WiX build', () => {
const conf = confAfterVersionSync('1.50.0-rc.1', baseConf);
assert.equal(conf.version, '1.50.0-rc.1');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10001');
assert.ok(
wixMappedBuildNumber('1.50.0-rc.1') > wixMappedBuildNumber('1.50.0-dev'),
);
});
it('next RC bump: rc.1 to rc.2', () => {
const from = confAfterVersionSync('1.50.0-rc.1', baseConf);
const conf = confAfterVersionSync('1.50.0-rc.2', from);
assert.equal(conf.version, '1.50.0-rc.2');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10002');
});
it('next → release: RC to stable increases WiX build', () => {
const from = confAfterVersionSync('1.50.0-rc.3', baseConf);
const conf = confAfterVersionSync('1.50.0', from);
assert.equal(conf.version, '1.50.0');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.65534');
assert.ok(wixMappedBuildNumber('1.50.0') > wixMappedBuildNumber('1.50.0-rc.3'));
});
it('post-release: next minor dev resets build band on new line', () => {
const from = confAfterVersionSync('1.50.0', baseConf);
const conf = confAfterVersionSync('1.51.0-dev', from);
assert.equal(conf.version, '1.51.0-dev');
assert.equal(conf.bundle.windows.wix.version, '1.51.0.1');
});
it('stable release sets highest build in line', () => {
const conf = confAfterVersionSync('2.0.0', {
version: '2.0.0-rc.1',
bundle: { windows: { wix: { version: '2.0.0.10001' } } },
});
assert.equal(conf.version, '2.0.0');
assert.equal(conf.bundle.windows.wix.version, '2.0.0.65534');
});
});
describe('sync-tauri-version-from-package.js invokes sync-wix', () => {
it('calls sync-wix-bundle-version.mjs after updating conf.version', () => {
const source = readFileSync(new URL('./sync-tauri-version-from-package.js', import.meta.url), 'utf8');
assert.match(source, /sync-wix-bundle-version\.mjs/);
const wixCall = source.indexOf('sync-wix-bundle-version');
const versionWrite = source.indexOf('conf.version = version');
assert.ok(wixCall > versionWrite, 'sync-wix must run after conf.version is set');
});
});
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env node
/**
* Write bundle.windows.wix.version in tauri.conf.json from package.json.
* Keeps the top-level app version unchanged (About, updater metadata, filenames).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { wixVersionOverrideForPackageVersion } from './wix-bundle-version.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
if (!version || typeof version !== 'string') {
console.error('package.json version missing');
process.exit(1);
}
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.bundle ??= {};
conf.bundle.windows ??= {};
conf.bundle.windows.wix ??= {};
const wixVersion = wixVersionOverrideForPackageVersion(version);
conf.bundle.windows.wix.version = wixVersion;
console.log(`tauri.conf wix.version -> ${wixVersion} (package ${version})`);
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
-96
View File
@@ -1,96 +0,0 @@
/**
* Map package.json semver to a monotonic WiX/MSI ProductVersion for Tauri.
*
* `bundle.windows.wix.version` must be `major.minor.patch.build` (four integers
* 65535). Alphabetic pre-releases cannot be used directly. NSIS accepts full
* semver without this mapping.
*
* Build bands (monotonic within X.Y.Z so in-place MSI upgrades work across
* dev rc stable):
* dev .1
* rc.N .10000 + N
* stable .65534
*
* Display / About still use the real package.json version.
*/
/** @type {const} */
export const WIX_BUILD = {
DEV: 1,
RC_BASE: 10_000,
STABLE: 65_534,
};
const MAX_WIX_FIELD = 65_535;
/** @param {number} build */
function assertBuildField(build, label) {
if (!Number.isInteger(build) || build < 0 || build > MAX_WIX_FIELD) {
throw new Error(`WiX build field out of range for ${label}: ${build}`);
}
}
/**
* WiX dot version for bundle.windows.wix.version (always four parts for channels
* we ship).
* @param {string} version
*/
export function wixVersionOverrideForPackageVersion(version) {
const trimmed = version.trim();
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(\d+))?$/);
if (!match) {
throw new Error(`Invalid semver for WiX mapping: ${trimmed}`);
}
const major = match[1];
const minor = match[2];
const patch = match[3];
const pre = match[4];
const buildPart = match[5];
const base = `${major}.${minor}.${patch}`;
if (buildPart !== undefined) {
throw new Error(
`Version "${trimmed}" has +build metadata — map manually or drop build for WiX`,
);
}
if (pre === undefined) {
return `${base}.${WIX_BUILD.STABLE}`;
}
if (pre === 'dev') {
return `${base}.${WIX_BUILD.DEV}`;
}
const rc = pre.match(/^rc\.(\d+)$/);
if (rc) {
const n = Number(rc[1]);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`WiX rc index must be ≥ 1 (got rc.${rc[1]})`);
}
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `rc.${n}`);
return `${base}.${build}`;
}
if (/^\d+$/.test(pre)) {
const n = Number(pre);
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `pre ${pre}`);
return `${base}.${build}`;
}
throw new Error(
`Version "${trimmed}" has non-numeric pre-release "${pre}" — MSI/WiX cannot bundle it. ` +
'Use NSIS (`--bundles nsis`) or extend wix-bundle-version.mjs.',
);
}
/** Numeric build field from mapped WiX version (for monotonicity tests). */
export function wixMappedBuildNumber(packageVersion) {
const wix = wixVersionOverrideForPackageVersion(packageVersion);
const build = Number(wix.split('.')[3]);
assertBuildField(build, packageVersion);
return build;
}
-38
View File
@@ -1,38 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
WIX_BUILD,
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
describe('wixVersionOverrideForPackageVersion', () => {
it('maps -dev to lowest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-dev'), '1.50.0.1');
});
it('maps -rc.N to RC base + N', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-rc.3'), '1.50.0.10003');
});
it('maps stable to highest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0'), '1.50.0.65534');
});
it('maps numeric pre-release to RC band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-42'), '1.50.0.10042');
});
});
describe('monotonic promotion builds within X.Y.Z', () => {
it('dev < rc.1 < rc.2 < stable', () => {
const chain = ['1.50.0-dev', '1.50.0-rc.1', '1.50.0-rc.2', '1.50.0'];
let prev = -1;
for (const v of chain) {
const build = wixMappedBuildNumber(v);
assert.ok(build > prev, `${v} build ${build} must exceed ${prev}`);
prev = build;
}
assert.equal(prev, WIX_BUILD.STABLE);
});
});
+152 -1156
View File
File diff suppressed because it is too large Load Diff
+20 -51
View File
@@ -1,29 +1,13 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.50.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.46.0-rc.1"
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,16 +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"] }
specta = "=2.0.0-rc.25"
specta-typescript = "=0.0.12"
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
@@ -55,9 +30,9 @@ 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.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking"] }
futures-util = "0.3"
md5 = "0.8"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -71,27 +46,20 @@ discord-rich-presence = "1.1"
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"] }
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.39", 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.15", default-features = false, features = ["blocking-api"] }
# Match wry/tauris 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 = [
@@ -106,10 +74,11 @@ windows = { version = "0.62", features = [
"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" }
+1 -2
View File
@@ -21,8 +21,6 @@ accepted = [
"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
@@ -40,4 +38,5 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
-17
View File
@@ -1,20 +1,3 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The library test harness (`--lib` unittests) links the wry/tao windowing
// runtime and statically imports `TaskDialogIndirect` from comctl32, which
// exists only in Common-Controls v6 (WinSxS). The real app binary gets that
// manifest from `tauri_build`, but the separate test executable does not, so
// it aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND (0xC0000139). Add the
// dependency to test targets only — never the app binary (which already has a
// manifest via tauri_build) nor non-Windows/CI builds.
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::build()
}
-2
View File
@@ -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,27 +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"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
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,19 +0,0 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
// crate's *test* executables, which statically import `TaskDialogIndirect`
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
// bare System32 comctl32.dll is v5.82 and does not export it, so an
// unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
// (0xC0000139) before any test runs. The app binary avoids this through its
// embedded manifest — mirror that here, scoped to test targets only (never
// the app, the rlib, or non-Windows/CI builds).
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
}
@@ -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,617 +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, specta::Type)]
#[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, specta::Type)]
#[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, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
pub waveforms: u64,
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[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, specta::Type)]
#[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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
pub fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_all_waveforms()
}
#[tauri::command]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
pub track_id: String,
}
#[tauri::command]
#[specta::specta]
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, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
pub in_progress_count: usize,
pub in_progress_track_id: Option<String>,
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
#[specta::specta]
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, specta::Type)]
#[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]
#[specta::specta]
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,51 +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" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
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 }
-19
View File
@@ -1,19 +0,0 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
// crate's *test* executables, which statically import `TaskDialogIndirect`
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
// bare System32 comctl32.dll is v5.82 and does not export it, so an
// unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
// (0xC0000139) before any test runs. The app binary avoids this through its
// embedded manifest — mirror that here, scoped to test targets only (never
// the app, the rlib, or non-Windows/CI builds).
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
}
@@ -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,138 +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]
#[specta::specta]
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]
#[specta::specta]
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,857 +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::hi_res_blend::{self, OutgoingBlendSnapshot};
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]
// NOTE: excluded from tauri-specta collect_commands! — specta's SpectaFn is only
// implemented up to 10 args and this has 24. Typing it needs the args bundled into
// a struct (a behaviour/contract change), tracked for the D4 flip; stays on
// generate_handler! for now.
#[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)
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
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 = if let Some(override_secs) = crossfade_secs_override {
override_secs.clamp(0.5, 30.0)
} 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 ~500800ms 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
};
let blend_rate = hi_res_blend::blend_rate_hz(
hi_res_enabled,
crossfade_enabled || (gapless && !manual),
hi_res_crossfade_resample_hz,
);
let resample_target_hz = blend_rate.unwrap_or(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,
resample_target_hz,
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(());
}
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let outgoing_blend: Option<OutgoingBlendSnapshot> =
if let Some(blend) = blend_rate {
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
hi_res_blend::capture_outgoing_blend_snapshot(
&state,
outgoing_fade_secs,
actual_fade_secs,
)
} else {
None
}
} else {
None
};
if outgoing_blend.is_some() {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
}
// ── 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 target_rate = if let Some(blend) = blend_rate {
blend
} else 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(());
}
}
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
&app,
&state,
snap,
blend,
gen,
)
.await
{
crate::app_eprintln!("{e}");
}
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 && blend_rate.unwrap_or(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]
// NOTE: excluded from tauri-specta collect_commands! — 13 args exceed specta's
// 10-arg SpectaFn limit; needs arg-bundling for the D4 flip. Stays on
// generate_handler! for now.
#[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,
hi_res_crossfade_resample_hz: Option<u32>,
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 unless hi-res gapless blend resampling is active.
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
let target_rate: u32 = blend_rate.unwrap_or(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(());
}
// Hi-res gapless: resample the chained track to the blend rate and realign
// the output stream when its Hz differs from the current track.
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if let Some(br) = blend_rate {
if stream_rate > 0 && stream_rate != br {
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
let dev = state.selected_device.lock().unwrap().clone();
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
if hi_res_enabled && br > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
&app,
&state,
&snap,
br,
snapshot_gen,
)
.await
{
crate::app_eprintln!("{e}");
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url: url.clone(),
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend skipped: current track not cached for realign"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
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,916 +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> {
enumerate_output_device_entries()
.into_iter()
.map(|e| e.key)
.collect()
}
/// Stable key + human label for the settings dropdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutputDeviceEntry {
pub key: String,
pub label: String,
}
pub(crate) fn enumerate_output_device_entries() -> Vec<OutputDeviceEntry> {
use rodio::cpal::traits::HostTrait;
let mut out = with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| {
iter.filter_map(|d| {
let key = output_device_stable_key(&d);
if key.is_empty() {
return None;
}
Some(OutputDeviceEntry {
label: output_device_display_label(&d),
key,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
});
dedupe_output_device_entries(&mut out);
out
}
fn dedupe_output_device_entries(entries: &mut Vec<OutputDeviceEntry>) {
let mut seen = std::collections::HashSet::new();
entries.retain(|e| seen.insert(e.key.clone()));
}
/// Stable per-device key for Settings / EQ maps. Linux keeps ALSA-style description
/// names; Windows/macOS use cpal [`DeviceId`] so same-named endpoints stay distinct
/// and default-device changes are observable by the watcher.
pub(crate) fn output_device_stable_key(device: &impl rodio::cpal::traits::DeviceTrait) -> String {
#[cfg(not(target_os = "linux"))]
{
if let Ok(id) = device.id() {
return id.to_string();
}
}
device
.description()
.ok()
.map(|d| d.name().to_string())
.unwrap_or_else(|| device.id().map(|i| i.to_string()).unwrap_or_default())
}
/// Human-readable label for the settings dropdown (not the stored key).
pub(crate) fn output_device_display_label(
device: &impl rodio::cpal::traits::DeviceTrait,
) -> String {
match device.description() {
Ok(desc) => format_output_device_label(&desc),
Err(_) => output_device_stable_key(device),
}
}
pub(crate) fn format_output_device_label(desc: &rodio::cpal::DeviceDescription) -> String {
use rodio::cpal::{DeviceType, InterfaceType};
let name = desc.name();
let mut parts: Vec<String> = vec![name.to_string()];
if let Some(mfr) = desc.manufacturer() {
if mfr != name && !name.contains(mfr) {
parts.push(mfr.to_string());
}
}
if let Some(driver) = desc.driver() {
if driver != name && !parts.iter().any(|p| p.contains(driver)) {
parts.push(driver.to_string());
}
}
if parts.len() == 1 {
let iface = desc.interface_type();
if iface != InterfaceType::Unknown && iface != InterfaceType::BuiltIn {
parts.push(iface.to_string());
} else {
let dtype = desc.device_type();
if dtype != DeviceType::Unknown && dtype != DeviceType::Speaker {
parts.push(dtype.to_string());
}
}
}
parts.join(" · ")
}
/// Best-effort label when a legacy plain-name pin is kept off the current list.
pub(crate) fn legacy_output_device_display_label(key: &str) -> String {
#[cfg(not(target_os = "linux"))]
{
use rodio::cpal::traits::HostTrait;
if let Ok(id) = key.parse::<rodio::cpal::DeviceId>() {
if let Some(device) = rodio::cpal::default_host().device_by_id(&id) {
return output_device_display_label(&device);
}
}
}
key.to_string()
}
/// Upgrade a preDeviceId persisted pin to the current stable key when unambiguous.
pub(crate) fn resolve_legacy_pinned_key(
pinned: &str,
entries: &[OutputDeviceEntry],
) -> Option<String> {
if entries.iter().any(|e| e.key == pinned) {
return Some(pinned.to_string());
}
let logic_matches: Vec<_> = entries
.iter()
.filter(|e| output_devices_logically_same(&e.key, pinned))
.collect();
if logic_matches.len() == 1 {
return Some(logic_matches[0].key.clone());
}
#[cfg(not(target_os = "linux"))]
{
let label_matches: Vec<_> = entries
.iter()
.filter(|e| e.label == pinned || e.label.starts_with(&format!("{pinned} · ")))
.collect();
if label_matches.len() == 1 {
return Some(label_matches[0].key.clone());
}
}
None
}
/// Resolve a stored device key to a cpal device (DeviceId on Windows/macOS, name on Linux).
pub(crate) fn resolve_output_device(
device_key: &str,
) -> Option<rodio::cpal::Device> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
let host = rodio::cpal::default_host();
if let Ok(id) = rodio::cpal::DeviceId::from_str(device_key) {
if let Some(device) = host.device_by_id(&id) {
return Some(device);
}
}
host.output_devices().ok()?.find(|d| {
output_device_stable_key(d) == device_key
|| d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(device_key)
})
}
/// cpal/rodio aliases for "follow the OS default" — not a stable per-device key.
#[cfg(target_os = "linux")]
pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
matches!(
name,
"default"
| "Default Audio Device"
| "PipeWire Sound Server"
| "Default ALSA Output (currently PipeWire Media Server)"
)
}
fn raw_cpal_default_output_device_key() -> Option<String> {
use rodio::cpal::traits::HostTrait;
with_suppressed_alsa_stderr(|| {
rodio::cpal::default_host()
.default_output_device()
.map(|d| output_device_stable_key(&d))
})
}
#[cfg(target_os = "linux")]
fn pick_listed_device_name(candidate: &str, list: &[String]) -> Option<String> {
list.iter()
.find(|d| d.as_str() == candidate || output_devices_logically_same(d, candidate))
.cloned()
}
#[cfg(target_os = "linux")]
fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
let mut out: Vec<String> = list
.iter()
.filter(|d| d.as_str() == name || output_devices_logically_same(d, name))
.cloned()
.collect();
if let Some(picked) = pick_listed_device_name(name, list) {
if !out.iter().any(|d| d == &picked) {
out.push(picked);
}
}
if out.is_empty() && !name.is_empty() {
out.push(name.to_string());
}
out
}
/// True when two device keys refer to the same sink (exact, ALSA logical, or via list canon).
#[cfg(target_os = "linux")]
pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -> bool {
if a == b || output_devices_logically_same(a, b) {
return true;
}
if comma_and_alsa_device_equivalent(a, b) {
return true;
}
let ea = equivalent_list_entries(a, list);
let eb = equivalent_list_entries(b, list);
ea.iter()
.any(|x| eb.iter().any(|y| x == y || output_devices_logically_same(x, y)))
}
/// Match wpctl/cpal `"CARD, PCM"` labels to ALSA `iface:CARD=…` picker ids.
#[cfg(target_os = "linux")]
fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
let (comma, alsa) = if linux_alsa_sink_fingerprint(a).is_some() {
(b, a)
} else if linux_alsa_sink_fingerprint(b).is_some() {
(a, b)
} else {
return false;
};
if comma.contains(':') {
return false;
}
let mut parts = comma.splitn(2, ',');
let Some(comma_card) = parts.next() else {
return false;
};
let comma_card = comma_card.trim();
let comma_pcm = parts.next().map(|s| s.trim()).unwrap_or("");
if comma_pcm.is_empty() {
return false;
}
let Some((_, alsa_card, _)) = linux_alsa_sink_fingerprint(alsa) else {
return false;
};
let pcm = comma_pcm.to_ascii_lowercase();
let alsa_lower = alsa.to_ascii_lowercase();
let cc = comma_card.to_ascii_lowercase();
let ac = alsa_card.to_ascii_lowercase();
let card_ok = cc.contains(&ac) || ac.contains(&cc);
if !card_ok {
return false;
}
if alsa_lower.starts_with("hdmi:") {
return !pcm.contains("analog");
}
if pcm.contains("analog") {
return alsa_lower.starts_with("hw:") || alsa_lower.starts_with("plughw:");
}
alsa_lower.contains(&pcm) || pcm.contains(&alsa_lower)
}
/// Build the cpal-style `"CARD, PCM name"` label PipeWire exposes for ALSA sinks.
#[cfg(target_os = "linux")]
pub(crate) fn cpal_name_from_pipewire_alsa(card: &str, alsa_name: &str) -> String {
format!("{card}, {alsa_name}")
}
/// Read `node.driver-id` from `wpctl inspect` output (PipeWire stream → sink link).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
if let Some(v) = line.strip_prefix("node.driver-id = ") {
return v.trim_matches('"').parse().ok();
}
}
None
}
/// Collect PipeWire ALSA `[psysonic]` stream node ids that have at least one
/// active playback link in `wpctl status` (ignores stale / idle nodes).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_status_psysonic_stream_ids(status: &str) -> Vec<u32> {
let mut in_audio_streams = false;
let mut ids = Vec::new();
let mut current_id: Option<u32> = None;
for line in status.lines() {
if line.contains("Streams:") && line.contains('─') {
in_audio_streams = true;
continue;
}
if !in_audio_streams {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("Video") || trimmed.starts_with("Settings") {
break;
}
if trimmed.contains("PipeWire ALSA [psysonic]") && !trimmed.contains("(deleted)") {
current_id = trimmed
.split('.')
.next()
.and_then(|s| s.trim().parse().ok());
continue;
}
if trimmed.contains('>')
&& (trimmed.contains("[active]") || trimmed.contains("[init]"))
{
if let Some(id) = current_id {
if !ids.contains(&id) {
ids.push(id);
}
}
} else if trimmed.contains('.') {
let prefix = trimmed.split('.').next().unwrap_or("").trim();
if prefix.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('>') {
current_id = None;
}
}
}
ids
}
#[cfg(target_os = "linux")]
fn linux_wpctl_inspect_driver_id(node_id: u32) -> Option<u32> {
use std::process::Command;
let inspect = Command::new("wpctl")
.args(["inspect", &node_id.to_string()])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
parse_wpctl_inspect_driver_id(&inspect)
}
/// True when a live psysonic PipeWire stream is already routed to the default sink.
/// Hyprpanel / WirePlumber often migrate streams on `set-default` before our poll
/// sees the change — reopening CPAL in that case only causes an audible glitch.
#[cfg(target_os = "linux")]
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
use std::process::Command;
let Some(default_id) = linux_wpctl_default_sink_id() else {
return false;
};
let Some(status) = Command::new("wpctl")
.args(["status"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
else {
return false;
};
let stream_ids = parse_wpctl_status_psysonic_stream_ids(&status);
stream_ids.iter().any(|&id| linux_wpctl_inspect_driver_id(id) == Some(default_id))
}
/// Parse `wpctl list audio sinks` and return the id of the default sink (trailing `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
for line in listing.lines() {
let line = line.trim_end();
if !line.ends_with('*') {
continue;
}
let id_str = line.split('\t').next()?.trim();
return id_str.parse().ok();
}
None
}
/// Parse `wpctl status` and return the id of the default sink (line marked with `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
let mut in_sinks = false;
for line in status.lines() {
if line.contains("Sinks:") {
in_sinks = true;
continue;
}
if !in_sinks {
continue;
}
if line.contains("Sources:") {
break;
}
if !line.contains('*') {
continue;
}
let after_star = line.split('*').nth(1)?.trim();
let id_str = after_star.split('.').next()?.trim();
return id_str.parse().ok();
}
None
}
/// Read `api.alsa.card.name` + `alsa.name` from `wpctl inspect` output.
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_alsa_names(inspect: &str) -> Option<(String, String)> {
let mut card: Option<String> = None;
let mut pcm: Option<String> = None;
for line in inspect.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("api.alsa.card.name = ") {
card = Some(v.trim_matches('"').to_string());
} else if card.is_none() {
if let Some(v) = line.strip_prefix("alsa.card_name = ") {
card = Some(v.trim_matches('"').to_string());
}
}
if let Some(v) = line.strip_prefix("alsa.name = ") {
pcm = Some(v.trim_matches('"').to_string());
}
}
match (card, pcm) {
(Some(c), Some(n)) if !c.is_empty() && !n.is_empty() => Some((c, n)),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn linux_wpctl_default_sink_id() -> Option<u32> {
use std::process::Command;
let listing = Command::new("wpctl")
.args(["list", "audio", "sinks"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
if let Some(ref text) = listing {
if let Some(id) = parse_wpctl_list_default_sink_id(text) {
return Some(id);
}
}
let status = Command::new("wpctl")
.args(["status"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
parse_wpctl_default_sink_id(&status)
}
/// Read `node.description` from `wpctl inspect` (Bluetooth and other non-ALSA sinks).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_node_description(inspect: &str) -> Option<String> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
if let Some(v) = line.strip_prefix("node.description = ") {
let desc = v.trim_matches('"').to_string();
if !desc.is_empty() {
return Some(desc);
}
}
}
None
}
#[cfg(target_os = "linux")]
fn linux_resolve_default_via_pipewire(list: &[String]) -> Option<String> {
use std::process::Command;
let sink_id = linux_wpctl_default_sink_id()?;
let inspect = Command::new("wpctl")
.args(["inspect", &sink_id.to_string()])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
let candidate = if let Some((card, pcm)) = parse_wpctl_inspect_alsa_names(&inspect) {
cpal_name_from_pipewire_alsa(&card, &pcm)
} else {
parse_wpctl_inspect_node_description(&inspect)?
};
pick_listed_device_name(&candidate, list).or(Some(candidate))
}
/// Resolve the active default output to a device key that matches `audio_list_devices`
/// when possible. On Linux/PipeWire, cpal's default is often a generic alias or a
/// stale card name that does not track WirePlumber default changes (Hyprpanel,
/// pavucontrol, `wpctl set-default`, etc.) — prefer `wpctl` when available.
pub fn effective_default_output_device_name() -> Option<String> {
resolve_effective_default_output_device_name(true)
}
/// Same as [`effective_default_output_device_name`] but skips the full
/// `output_devices()` scan — for the device-watcher poll path (#996).
pub(crate) fn effective_default_output_device_name_for_poll() -> Option<String> {
resolve_effective_default_output_device_name(false)
}
fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Option<String> {
// Windows/macOS: single cpal default query (pre-#1274). Full `output_devices()`
// enumeration contends with WASAPI/CoreAudio and is only needed for Linux/PipeWire
// default resolution + ALSA logical key matching.
#[cfg(not(target_os = "linux"))]
{
let _ = enumerate_devices;
return raw_cpal_default_output_device_key();
}
#[cfg(target_os = "linux")]
{
let list = if enumerate_devices {
enumerate_output_device_names()
} else {
Vec::new()
};
if let Some(resolved) = linux_resolve_default_via_pipewire(&list) {
return Some(resolved);
}
if !enumerate_devices {
// wpctl unavailable — last-resort cpal (skip generic/stale placeholder names).
if linux_wpctl_default_sink_id().is_none() {
if let Some(raw) = raw_cpal_default_output_device_key() {
if !is_generic_default_output_alias(&raw) {
return Some(raw);
}
}
}
return None;
}
let raw = raw_cpal_default_output_device_key();
if let Some(ref name) = raw {
if !is_generic_default_output_alias(name) {
return pick_listed_device_name(name, &list).or_else(|| Some(name.clone()));
}
}
raw
}
}
/// 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;
}
#[cfg(not(target_os = "linux"))]
{
if let (Ok(ida), Ok(idb)) = (
a.parse::<rodio::cpal::DeviceId>(),
b.parse::<rodio::cpal::DeviceId>(),
) {
return ida.1 == idb.1;
}
if legacy_description_key_matches_device_id(a, b)
|| legacy_description_key_matches_device_id(b, a)
{
return true;
}
}
match (
linux_alsa_sink_fingerprint(a),
linux_alsa_sink_fingerprint(b),
) {
(Some(fa), Some(fb)) => fa == fb,
_ => false,
}
}
/// PreDeviceId persisted pins (description names) vs cpal `DeviceId` enumeration keys.
#[cfg(not(target_os = "linux"))]
fn legacy_description_key_matches_device_id(legacy: &str, device_id_key: &str) -> bool {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
if legacy.parse::<rodio::cpal::DeviceId>().is_ok() {
return false;
}
let Ok(id) = rodio::cpal::DeviceId::from_str(device_id_key) else {
return legacy == device_id_key;
};
let Some(device) = rodio::cpal::default_host().device_by_id(&id) else {
return false;
};
let Ok(desc) = device.description() else {
return false;
};
if desc.name() == legacy {
return true;
}
let label = output_device_display_label(&device);
label == legacy || label.starts_with(&format!("{legacy} · "))
}
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
#[cfg(not(target_os = "linux"))]
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]
#[cfg(not(target_os = "linux"))]
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]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_returns_false_when_absent() {
let avail = vec!["A".to_string(), "B".to_string()];
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
}
#[test]
#[cfg(not(target_os = "linux"))]
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());
}
// ── generic default alias / PipeWire wpctl parsing ────────────────────────
#[test]
#[cfg(target_os = "linux")]
fn generic_default_alias_detects_cpal_pipewire_placeholders() {
assert!(is_generic_default_output_alias("Default Audio Device"));
assert!(is_generic_default_output_alias("PipeWire Sound Server"));
assert!(!is_generic_default_output_alias("HDA NVidia, Gigabyte M32U"));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_accepts_init_links_when_paused() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
90. output_FL > ALC897 Analog:playback_FL [init]
"#;
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![84]);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_ignores_streams_without_links() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
87. PipeWire ALSA [psysonic]
106. output_FL > HDMI:playback_FL [active]
"#;
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![87]);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_finds_active_streams() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
90. output_FL > ALC897 Analog:playback_FL [active]
119. PipeWire ALSA [psysonic (deleted)]
Video
"#;
assert_eq!(
parse_wpctl_status_psysonic_stream_ids(status),
vec![84]
);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_driver_id_reads_node_driver() {
let inspect = r#"
* node.driver-id = "58"
node.name = "alsa_playback.psysonic"
"#;
assert_eq!(parse_wpctl_inspect_driver_id(inspect), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_list_default_sink_id_finds_starred_sink() {
let listing = "56\talsa_output.pci-hdmi\taudio/sink\t\n58\talsa_output.pci-analog\taudio/sink\t*";
assert_eq!(parse_wpctl_list_default_sink_id(listing), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_default_sink_id_finds_starred_sink() {
let status = r#"
Audio
Devices:
Sinks:
56. HDMI out
* 58. Analog out
Sources:
"#;
assert_eq!(parse_wpctl_default_sink_id(status), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_alsa_names_reads_card_and_pcm() {
let inspect = r#"
api.alsa.card.name = "HD-Audio Generic"
alsa.name = "ALC897 Analog"
"#;
assert_eq!(
parse_wpctl_inspect_alsa_names(inspect),
Some(("HD-Audio Generic".into(), "ALC897 Analog".into()))
);
assert_eq!(
cpal_name_from_pipewire_alsa("HD-Audio Generic", "ALC897 Analog"),
"HD-Audio Generic, ALC897 Analog"
);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_node_description_reads_bluetooth_sink() {
let inspect = r#"
* node.description = "BlueZ Audio Device"
node.name = "bluez_output.xxx"
"#;
assert_eq!(
parse_wpctl_inspect_node_description(inspect),
Some("BlueZ Audio Device".into())
);
}
#[test]
#[cfg(target_os = "linux")]
fn output_device_keys_equivalent_links_hdmi_comma_and_alsa_id() {
assert!(output_device_keys_equivalent(
"HDA NVidia, Gigabyte M32U",
"hdmi:CARD=NVidia,DEV=3",
&[],
));
}
#[test]
#[cfg(target_os = "linux")]
fn output_device_keys_equivalent_distinguishes_analog_and_hdmi() {
assert!(!output_device_keys_equivalent(
"HD-Audio Generic, ALC897 Analog",
"hdmi:CARD=HD-Audio Generic,DEV=3",
&[],
));
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_prefers_enumerated_entry() {
let list = vec![
"Default Audio Device".to_string(),
"HDA NVidia, Gigabyte M32U".to_string(),
];
assert_eq!(
pick_listed_device_name("HDA NVidia, Gigabyte M32U", &list),
Some("HDA NVidia, Gigabyte M32U".to_string())
);
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_matches_linux_alsa_logical_alias() {
let list = vec!["hdmi:CARD=NVidia,DEV=3".to_string()];
assert_eq!(
pick_listed_device_name("hw:CARD=NVidia,DEV=3", &list),
None,
"different ALSA ifaces are not logically the same"
);
assert_eq!(
pick_listed_device_name("hdmi:CARD=NVidia,DEV=3", &list),
Some("hdmi:CARD=NVidia,DEV=3".to_string())
);
}
}
@@ -1,170 +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_entries, legacy_output_device_display_label,
output_devices_logically_same, resolve_legacy_pinned_key, OutputDeviceEntry,
};
#[cfg(target_os = "linux")]
use super::dev_io::output_device_keys_equivalent;
use super::engine::AudioEngine;
/// One row in the audio output device picker (`key` is persisted; `label` is display-only).
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
pub struct AudioOutputDeviceEntry {
pub key: String,
pub label: String,
}
impl From<OutputDeviceEntry> for AudioOutputDeviceEntry {
fn from(e: OutputDeviceEntry) -> Self {
Self {
key: e.key,
label: e.label,
}
}
}
fn list_output_device_entries(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
let mut list: Vec<AudioOutputDeviceEntry> = enumerate_output_device_entries()
.into_iter()
.map(Into::into)
.collect();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty()
&& !list
.iter()
.any(|e| output_devices_logically_same(&e.key, name))
{
list.push(AudioOutputDeviceEntry {
key: name.clone(),
label: legacy_output_device_display_label(name),
});
}
}
list
}
/// 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]
#[specta::specta]
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 entries = list_output_device_entries(&state);
if entries.iter().any(|e| e.key == pinned) {
return None;
}
if let Some(upgraded) = resolve_legacy_pinned_key(&pinned, &enumerate_output_device_entries()) {
if upgraded != pinned {
*state.selected_device.lock().unwrap() = Some(upgraded.clone());
return Some(upgraded);
}
}
let canon = entries
.iter()
.find(|e| output_devices_logically_same(&e.key, &pinned))?
.key
.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<AudioOutputDeviceEntry> {
list_output_device_entries(engine)
}
/// Returns the keys 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 is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
#[specta::specta]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<AudioOutputDeviceEntry> {
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]
#[specta::specta]
pub fn audio_default_output_device_name() -> Option<String> {
super::dev_io::effective_default_output_device_name()
}
/// Lightweight default query for EQ poll — skips full `output_devices()` scan (#996).
#[tauri::command]
#[specta::specta]
pub fn audio_default_output_device_name_for_poll() -> Option<String> {
super::dev_io::effective_default_output_device_name_for_poll()
}
/// Find a stored per-device EQ key that denotes the same sink as `candidate`
/// (exact or Linux ALSA logical match).
#[tauri::command]
#[specta::specta]
pub fn audio_match_stored_output_device_key(
candidate: String,
stored_keys: Vec<String>,
) -> Option<String> {
#[cfg(not(target_os = "linux"))]
{
return stored_keys
.into_iter()
.find(|k| output_devices_logically_same(k, &candidate));
}
#[cfg(target_os = "linux")]
{
let list = super::dev_io::enumerate_output_device_names();
stored_keys
.into_iter()
.find(|k| output_device_keys_equivalent(k, &candidate, &list))
}
}
/// 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]
#[specta::specta]
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,285 +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,
resample_target_hz: 0,
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
}
File diff suppressed because it is too large Load Diff
@@ -1,349 +0,0 @@
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
//! AutoDJ, or gapless must cross a sample-rate boundary.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, State};
use super::engine::AudioEngine;
use super::playback_rate::raw_counter_samples_for_content_position;
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
use super::stream::LocalFileSource;
const BLEND_44100: u32 = 44_100;
const BLEND_88200: u32 = 88_200;
const BLEND_96000: u32 = 96_000;
/// User-selected blend rate for hi-res transitions; `None` when inactive.
pub(crate) fn blend_rate_hz(
hi_res_enabled: bool,
transition_blend_active: bool,
hz: Option<u32>,
) -> Option<u32> {
if !hi_res_enabled || !transition_blend_active {
return None;
}
let raw = hz.unwrap_or(BLEND_44100);
match raw {
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
_ => Some(BLEND_44100),
}
}
pub(crate) struct OutgoingBlendSnapshot {
pub(crate) url: String,
pub(crate) position_secs: f64,
pub(crate) duration_secs: f64,
pub(crate) base_volume: f32,
pub(crate) gain_linear: f32,
pub(crate) outgoing_fade_secs: f32,
pub(crate) actual_fade_secs: f32,
pub(crate) analysis_track_id: Option<String>,
}
/// Capture the currently playing track before a hi-res blend stream reopen.
pub(crate) fn capture_outgoing_blend_snapshot(
state: &AudioEngine,
outgoing_fade_secs: f32,
actual_fade_secs: f32,
) -> Option<OutgoingBlendSnapshot> {
let url = state.current_playback_url.lock().unwrap().clone()?;
if url.is_empty() {
return None;
}
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
let cur = state.current.lock().unwrap();
let playing = cur.sink.is_some() && cur.paused_at.is_none();
(
cur.position(),
cur.duration_secs,
cur.base_volume,
cur.replay_gain_linear,
playing,
)
};
if !playing {
return None;
}
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
Some(OutgoingBlendSnapshot {
url,
position_secs,
duration_secs,
base_volume,
gain_linear,
outgoing_fade_secs,
actual_fade_secs,
analysis_track_id,
})
}
/// Drop the live main sink so a stream reopen does not leave dangling players.
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
let mut cur = state.current.lock().unwrap();
if let Some(old) = cur.sink.take() {
old.stop();
}
cur.fadeout_trigger = None;
cur.fadeout_samples = None;
}
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
if url.starts_with("psysonic-local://") {
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
let file = std::fs::File::open(path).ok()?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
return Some(PlayInput::SeekableMedia {
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[hi-res-blend]",
random_access: true,
mp4_probe_gate: None,
});
}
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())
};
let path = spill_path?;
std::fs::read(&path).ok()?
};
Some(PlayInput::Bytes(bytes))
}
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
pub(crate) async fn spawn_outgoing_blend_resample(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.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: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
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()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
}
let fade_secs = snap.outgoing_fade_secs;
if fade_secs > 0.0 {
let rate = blend_rate;
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
ps.built
.fadeout_samples
.store(fade_total.max(1), Ordering::SeqCst);
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
}
sink.play();
*state.fading_out_sink.lock().unwrap() = Some(sink);
let fo_arc = state.fading_out_sink.clone();
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
crate::app_deprintln!(
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
snap.position_secs,
fade_secs
);
Ok(())
}
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
/// chain realign) so the next source can append to the same sink.
pub(crate) async fn rebuild_current_track_at_blend_rate(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] current track not cached for gapless realign: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.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: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
state
.current_sample_rate
.store(ps.built.output_rate, Ordering::Relaxed);
state
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let stream = super::engine::ensure_output_stream_open(state)?;
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);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
}
sink.play();
{
let mut cur = state.current.lock().unwrap();
cur.sink = Some(sink);
cur.duration_secs = ps.built.duration_secs;
cur.seek_offset = snap.position_secs;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = snap.gain_linear;
cur.base_volume = snap.base_volume;
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
cur.fadeout_samples = Some(ps.built.fadeout_samples);
}
state.samples_played.store(
raw_counter_samples_for_content_position(
snap.position_secs,
ps.built.output_rate,
ps.built.output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
crate::app_deprintln!(
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
snap.position_secs
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_rate_inactive_without_hi_res_or_transition() {
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
}
#[test]
fn blend_rate_sanitizes_hz() {
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
}
}
-185
View File
@@ -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,70 +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 hi_res_blend;
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,313 +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]
#[specta::specta]
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]
#[specta::specta]
#[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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
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]
#[specta::specta]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
.store(enabled, Ordering::Relaxed);
}
#[tauri::command]
#[specta::specta]
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, raw_counter_samples_for_content_position,
uses_preserve_dsp, 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_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
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;
let restamp_content = if was_active
&& enabled == old_enabled
&& uses_preserve_dsp(old_strat)
&& new_strat == old_strat
&& speed_changed
{
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
if sample_rate > 0 && channels > 0 {
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
}
} 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 {
if is_effect_active(&state.playback_rate) {
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
}
}
#[tauri::command]
#[specta::specta]
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,662 +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,
}
}
/// 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);
}
}
@@ -1,232 +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]
#[specta::specta]
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,202 +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]
#[specta::specta]
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,419 +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,
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
pub resample_target_hz: u32,
pub duration_hint: f64,
}
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
struct PlaybackSourceShape {
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
resample_target_hz: u32,
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,
resample_target_hz,
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}");
}
let shape = PlaybackSourceShape {
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
};
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
.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(),
&shape,
)
.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(),
&PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
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>,
shape: &PlaybackSourceShape,
) -> Result<PlaybackSource, String> {
let PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
} = shape;
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
let target_rate: u32 = *resample_target_hz;
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.clone(),
*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.clone(),
*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.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
)
}
}?;
Ok(PlaybackSource { built, is_seekable })
}

Some files were not shown because too many files have changed in this diff Show More