Compare commits

..

355 Commits

Author SHA1 Message Date
Psychotoxical 216e6c957f ci(release): re-sign updater tarball after tauri-action's repack
tauri-action always re-packs Psysonic.app into .app.tar.gz after tauri
CLI finishes (regardless of includeUpdaterJson), so the .sig that tauri
CLI produces never matches the final tarball — and the sig tauri-action
was supposed to produce silently goes missing ("Signature not found for
the updater JSON. Skipping upload...").

Rather than fight the repack, sign the repacked tarball ourselves in a
follow-up step using `tauri signer sign` against the same private key
(from TAURI_SIGNING_PRIVATE_KEY), then upload the fresh .sig. tauri-
action still handles the DMG + .app.tar.gz upload; we only add the .sig.

Also removes an unused `std::rc::Rc` import in the vendored cpal patch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:31:17 +02:00
Psychotoxical 425addffa0 ci(release): disable tauri-action updater repack, upload .sig ourselves
Root cause: tauri-action runs a second "Packaging" pass after tauri CLI
finishes, which rewrites src-tauri/target/<target>/release/bundle/macos/
Psysonic.app.tar.gz with different bytes. The .sig produced by tauri CLI
no longer matches the new hash, so tauri-action silently deletes it —
leaving the directory with only .app and .app.tar.gz (no .sig for our
manifest generator to consume).

Fix: pass `includeUpdaterJson: false` to tauri-action so it skips the
repack + updater JSON upload entirely, then copy both the .app.tar.gz
and .app.tar.gz.sig produced by tauri CLI into the workspace root with
the expected asset names and `gh release upload` them.

Also disables build-linux and the Windows matrix entry during testing
to cut iteration time roughly in half.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:19:33 +02:00
Psychotoxical 43df830960 ci(release): debug .sig discovery, disable verify-nix during testing
- Replace the glob find for the .sig file with an explicit target-based
  path ($matrix → src-tauri/target/<target>-apple-darwin/release/bundle/
  macos/Psysonic.app.tar.gz.sig) and dump directory listings so we can
  see what tauri-action actually leaves behind if the path is wrong
- Skip verify-nix while we iterate fast on signing + updater (its auto-
  commits of flake.lock / npmDepsHash cause rebase friction on every
  release)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:08:56 +02:00
Psychotoxical 6ed41070a5 ci(release): upload updater .sig as release asset
tauri-action on macOS cross-target builds produces the .app.tar.gz.sig
locally but silently skips its upload ("Signature not found for the
updater JSON. Skipping upload..."), which caused generate-manifest to
abort on v1.34.15 because the .sig asset was absent.

New step after tauri-action locates the .sig under src-tauri/target/*/
release/bundle/macos/ and uploads it as Psysonic_aarch64.app.tar.gz.sig
or Psysonic_x64.app.tar.gz.sig — the exact filenames generate-update-
manifest.js expects.

Also bumps version to 1.34.16.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:46:58 +02:00
Psychotoxical 061c96a89f chore(release): v1.34.15
- feat(updater): macOS auto-update via Tauri Updater

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:29:11 +02:00
Psychotoxical 31d6e5bd77 feat(updater): macOS auto-update via Tauri Updater
- tauri-plugin-updater wired into lib.rs with pubkey + GitHub Releases
  endpoint in tauri.conf.json; updater:default capability granted
- AppUpdater.tsx: on macOS, the download button now invokes the updater
  plugin (check + downloadAndInstall) which downloads the signed
  .app.tar.gz, verifies the minisign signature against the bundled
  pubkey, replaces /Applications/Psysonic.app, and relaunches. Windows
  and Linux keep the existing "download DMG/EXE/AppImage via reqwest
  then point to the folder" flow
- CI: pass TAURI_SIGNING_PRIVATE_KEY + _PASSWORD to tauri-action so the
  .sig files are produced alongside the update bundles
- New generate-manifest job (after build-macos-windows) runs
  scripts/generate-update-manifest.js which downloads the .sig files
  from the release, assembles latest.json for darwin-aarch64 and
  darwin-x86_64, and uploads it back as a release asset

Windows will be added to latest.json once the Certum cert is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:29:11 +02:00
github-actions[bot] df91396ba1 chore(nix): refresh lock + npmDepsHash for v1.34.14 2026-04-18 11:45:16 +00:00
Psychotoxical fb927c5a2e chore(release): v1.34.14
- feat(linux): WebKitGTK smooth/linear wheel scroll toggle (PR #207 by cucadmuh)
- ci(release): Apple Developer ID signing + notarization for macOS
- fix(themes): WCAG contrast audits for middle-earth + nucleo
- docs(contributors): PRs #205, #206, #207 in Settings About

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:31:46 +02:00
Psychotoxical 7ee03e917b ci(release): sign and notarize macOS builds
Wire up Developer ID Application certificate + App Store Connect API Key
to tauri-action on the macOS runner. Decodes the base64-encoded .p8 into
a file before the build, exports APPLE_API_KEY_PATH via $GITHUB_ENV, and
forwards the remaining credentials through the env block. Windows runner
receives the vars but tauri ignores them off-platform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:26:17 +02:00
Frank Stellmacher 21f65915db Update README.md 2026-04-18 12:31:16 +02:00
Psychotoxical 33ba1dfa28 fix(themes): WCAG contrast audit for nucleo
Palette tokens lifted to AA on bg-card (#eedbb8):
- --warning (#a88020 → #7a5b0e): 2.68 → 4.64
- --border  (#b89a69 → #765a38): 1.97 → 4.70 (darker bronze, 3:1 UI on
  all bg variants including bg-player)
- --text-muted (#8c7356 → #6b5636): 3.29 → 5.14
- --positive  (#587838 → #3e5820): 3.72 → 5.90

Component override for column resize grip (default --ctp-surface1 is
1.08:1 on bg-card, invisible on this warm cream palette) — switch to
--border (4.70:1) with 2px width for a perceptible bronze grip.

Warm brass/aged-parchment palette preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:26:03 +02:00
Psychotoxical f60dabbd31 fix(themes): WCAG contrast audit for middle-earth
- Lift --warning (#c88a10 → #805408) from 2.42:1 to 5.38:1 on bg-card;
  deeper tanner's gold in line with Rohan sun motifs
- Strengthen --border (rgba 0.38 → 0.85 alpha, umber) from 2.64:1 to 3.79:1
  for the 3:1 UI-component threshold
- Raise --text-muted (#6a5230 → #5a4428) to reach AA on card/surface1
- Component overrides on dark sidebar (#241a0e): .connection-type/-server
  (1.95 → 5.52), .nav-section-label, .lyrics-line/-status,
  .queue-item-duration, .queue-divider span, .player-time
- Darken --text-muted override inside parchment .glass panel (3.89 → 7.37)

Warm bronze/brass/aged-oak palette preserved — no cold tones introduced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:02:14 +02:00
Psychotoxical f8bc39036a docs(contributors): add PRs #205, #206, #207 to Settings About list
- cucadmuh: WebKitGTK wheel scroll mode (PR #207)
- kilyabin: Apple Music-style scrolling lyrics (PR #205)
- kilyabin: Golos Text and Unbounded fonts with Cyrillic support (PR #206)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:02:03 +02:00
Frank Stellmacher f9f39ccfc2 feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)
feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)
2026-04-18 11:25:41 +02:00
Psychotoxical a8317f5877 fix(linux): guard webkit smooth scroll migration behind IS_LINUX
Migration key was written to localStorage on all platforms; the one-time
default-to-smooth logic is only relevant on Linux (WebKitGTK).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 11:25:09 +02:00
Frank Stellmacher 46bbae873e Aktualisieren von README.md 2026-04-18 10:11:36 +02:00
Maxim Isaev ba43ed867a feat(linux): add WebKitGTK smooth wheel scroll toggle in settings
Persist preference in auth store, sync from App on Linux, and expose a
Tauri command using webkit2gtk to toggle enable-smooth-scrolling at
runtime. Default on to match upstream; users may disable for discrete
GTK-style line steps.

Apply a one-time rehydrate migration so smooth scrolling stays on after
updates even if an older build persisted the wrong default.
2026-04-18 05:09:33 +03:00
Psychotoxical 832bacb569 feat(ui-scale): re-enable scoped interface scaling
Document-level zoom broke portal positioning and Tauri window measurement,
so the slider had been forced to 100 %. Scope the zoom to a wrapper inside
.main-content instead — sidebar, queue, player bar and the Linux custom
title bar live in sibling grid cells of .app-shell and stay 1:1.

- App.tsx: drop the document-level zoom + auto-reset; wrap main-content
  children in <div class="main-content-zoom" style={zoom: uiScale}>.
- layout.css: add .main-content-zoom (flex column, fills parent).
- Settings.tsx: re-enable the slider and snap it to the 6 preset stops
  (80/90/100/110/125/150) so the thumb lands directly under each preset
  button. Legacy off-preset values snap to the nearest stop on load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:34:49 +02:00
Psychotoxical b9093883a4 fix(themes): WCAG contrast audit for w98 + stark-hud
w98:
- Lift --positive (#008000#005000), --warning (#808000 → #5a4500),
  --danger (#ff0000 → #b00000) from ~2.6:1 to AA on bg-card. Brand
  --ctp-* palette preserved as canonical W98 system colours.
- --border-subtle was identical to --bg-card (1.0:1, invisible) →
  #a0a0a0 (1.73:1).
- Override .col-resize-handle::after to W98 dialog grey (was 1.23:1).
- text-secondary/muted intentionally kept at #000000: AlbumDetail
  renders text on bg-app teal where any non-black tone falls below AA.

stark-hud:
- Lift --text-muted (#5a718a → #7d92a8, 3.34 → 5.73:1) staying in the
  slate-blue family that matches the HUD aesthetic.
- --border-subtle was identical to --bg-card (invisible) → #243246.
- Override .col-resize-handle::after to overlay1 (was 1.09:1).
- Cyan accent, all glow shadows and chromatic --ctp-* untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:21:05 +02:00
Psychotoxical 77edfaa867 perf(fs-player): cut CPU under WebKitGTK software compositing
WebKitGTK on Linux runs with WEBKIT_DISABLE_COMPOSITING_MODE=1, so every
GPU-only effect is software-rasterised per frame. The fullscreen player
piled up large blur shadows, per-line scale transforms, oversized
radial-gradients with color-mix() and a 60 fps rAF spring on top — CPU
saturated and FPS dropped to ~10.

Linux-only via existing html.no-compositing class:
- Apple-style lyrics: drop transform: scale and 48 px / 32 px text-shadow
- Rail-style lyrics: drop 28 px text-shadow + scaleX
- Track title: 40 px glow → 6 px drop-shadow
- Album art wrap + play button: drop accent-coloured outer glows
- Mesh blobs: flatten 130 % radial-gradient + color-mix to solid #06060e
  and remove the 400 ms background transition
- Portrait wrap: drop translateZ(0) GPU-layer hint
- FS art: instant cover swap (no opacity crossfade)
- SpringScroller: replace rAF spring with native scrollTo({behavior:'smooth'})
- FsSeekbar: cache last applied values, skip identical DOM writes

Dynamic accent on title / seekbar / play button is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:06:04 +02:00
Psychotoxical bac0417e00 fix(themes): WCAG contrast audit for wnamp/ubuntu/w11/powerslave/dracula/vision-*
Raise text-muted, borders, grips and waveform tokens to AA/AAA where
they fell below threshold. Add component-level overrides (col-resize
grip, focus ring, scrollbar thumb) to themes whose --ctp-surface1
collided with --bg-card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:46:09 +02:00
Frank Stellmacher f30920bfeb Merge pull request #206 from kilyabin/add-fonts
feat: fonts with cyrillic support
2026-04-18 00:40:56 +02:00
Frank Stellmacher 8e72e7084b Merge pull request #205 from kilyabin/apple-music-style-lyric
feat(lyrics): Apple Music-style scrolling lyrics with per-style controls
2026-04-18 00:40:45 +02:00
Psychotoxical dcd356aee7 fix(lyrics): keep classic styles as default
Existing users keep the rail/classic experience they're used to;
the new Apple Music-like style is opt-in via the FS popover and
the Sidebar Lyrics Style setting in Appearance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:39:03 +02:00
kilyabin 4f188be792 feat: fonts with cyrillic support 2026-04-18 02:16:57 +04:00
kilyabin cd1417b604 feat: Apple-Music-like lyrics view with saving old-style 2026-04-18 01:58:17 +04:00
Psychotoxical ee437fca5a ci(nix): push store paths to Cachix during verify-nix
Wires cachix/cachix-action@v15 into the verify-nix job. The action
watches the nix store for the duration of the workflow and uploads
new paths to the `psysonic` cache on cachix.org automatically.

Using Cachix-managed signing (no signing key on our side) — Cachix
signs server-side with the cache's built-in keypair. End users will
pick up the public key automatically via `cachix use psysonic`, so
no manual nix.conf configuration is required.

Consequence: after the next `v*` release, `nix profile install
github:Psychotoxical/psysonic` pulls a prebuilt binary from Cachix
instead of recompiling Rust + npm deps locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:51:36 +02:00
github-actions[bot] e102320e32 chore(nix): refresh lock + npmDepsHash for v1.34.13 2026-04-17 20:48:19 +00:00
Psychotoxical d79de7904d chore(release): bump version to 1.34.13
Adds YouLyPlus karaoke lyrics + static-only lyrics toggle, collapses
the advanced Discord options under one header, and ships the real
macOS microphone-prompt fix (cpal patch forcing DefaultOutput on all
output streams).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:37:25 +02:00
cucadmuh bf93ee17da feat(nix): inline flake + psysonic derivation, wire verify into release
Supersedes PR #203's out-of-tree upstream-src layout now that the Nix
build lives in the canonical repo. The flake treats `self` as the
source of truth — nothing in flake.nix or psysonic.nix needs a manual
version bump per release.

flake.nix
  - `inputs.upstream-src` dropped; `src = self` throughout
  - Dev shell retains the full WebKitGTK / GStreamer / AppIndicator
    closure needed for tauri:dev on NixOS (Linux x86_64 + aarch64)

nix/psysonic.nix
  - Version read from package.json (upstreamMeta.version removed)
  - `srcClean` filter excludes node_modules / dist / target / .git /
    result / .flatpak-builder
  - `cargoDeps = rustPlatform.importCargoLock` with empty
    `outputHashes` — sufficient for our local `[patch.crates-io]`
    path overrides (cpal-0.15.3, symphonia-format-isomp4)
  - Wraps the installed binary with LD_LIBRARY_PATH + GST_PLUGIN_PATH
    + GIO_EXTRA_MODULES + GDK_BACKEND=x11 + WebKit disable flags

nix/upstream-sources.json
  - Reduced to `{ npmDepsHash }`. Placeholder for the initial commit;
    the verify-nix job rewrites it on every release from the actual
    `prefetch-npm-deps` output.

.github/workflows/release.yml
  - New `verify-nix` job gated on `create-release` (same as the
    Tauri builds). Checks out `main`, installs Nix, recomputes
    `npmDepsHash`, refreshes `flake.lock`, runs
    `nix build .#psysonic --no-link` as the sanity check, then
    commits lock + hash updates back to `main` when they changed.
  - The old standalone `upstream-release-nix.yml` is intentionally
    not ported; its scheduled re-poll of the upstream release is
    redundant now that the source lives here.

Tarball publishing and Cachix upload remain explicitly out of scope —
those ship in a follow-up workflow tied to the cache setup.
2026-04-17 22:25:19 +02:00
Psychotoxical 02c7a3fe22 feat(settings): collapse Discord sub-options under one header
"Fetch covers from Apple Music" and "Custom text templates" are now both
tucked into a single collapsible "Advanced Discord options" block (default
collapsed) that only appears when Discord Rich Presence is enabled.
Reduces vertical noise in Settings → General for the common case where
users just flip Rich Presence on and leave the defaults.

Uses the same chevron-rotate pattern as the font picker and contributors
list. Adds `settings.discordOptions` key in all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:52:16 +02:00
Psychotoxical 34b4445c13 feat(lyrics): YouLyPlus karaoke + fix(macos): suppress mic prompt
Two independent user-facing fixes rolled together.

── Lyrics: YouLyPlus provider (issue #172) ──
Adds word-by-word synced (karaoke) lyrics as an alternative lyrics mode.
Backed by the public lyricsplus aggregator (Apple Music / Spotify /
Musixmatch / QQ Music) — no API keys on our side, subscription costs are
borne by the backend operator. Five mirrors are tried on network failure.

Settings → Lyrics now exposes a mode radio (Standard vs YouLyPlus) plus
a static-only toggle. YouLyPlus misses silently fall back to the existing
server + LRCLIB + Netease pipeline so obscure tracks still resolve. The
drag-to-reorder source list is hidden in YouLyPlus mode since the
external aggregator manages its own source priority.

Rendering: per-word spans on each line, active word highlighted with
accent-tinted glow. Subscribed imperatively via usePlayerStore.subscribe
so 500 ms progress ticks do not re-render the whole lyrics block. The
Fullscreen Player reuses the same pattern; the 5-line rail layout is
unchanged. white-space: pre on .fs-lyric-word preserves the trailing
spaces the API embeds in each syllabus token (flex context would
otherwise collapse them).

i18n: new keys in all 8 locales (de, en, fr, nl, nb, ru, es, zh).

── macOS mic-prompt suppression ──
Ships a vendored cpal 0.15.3 at patches/cpal-0.15.3/ wired via
[patch.crates-io]. The only change is in src/host/coreaudio/macos/mod.rs:
audio_unit_from_device now unconditionally uses IOType::DefaultOutput for
output streams instead of conditionally choosing HalOutput.

AUHAL (HalOutput) is classified as input-capable by macOS TCC and
triggers the microphone-permission dialog at first launch / after each
update even for playback-only apps. Previous attempts (removing
NSMicrophoneUsageDescription, setting com.apple.security.device.audio-input
false in Entitlements.plist) did not suppress the prompt because TCC
fires at AudioUnit instantiation, not at plist level. The upstream fix
in cpal PR #1070 / cpal 0.17 only helps when the pinned device equals
the system default; always-DefaultOutput covers all cases.

Tradeoff: per-device output selection is a no-op on macOS — the stream
always follows the system default. Settings.tsx surfaces this via
audioOutputDeviceMacNotice (hides the CustomSelect on macOS) and skips
audio_list_devices + device-watcher effects there. Matches the behaviour
of Apple Music / Spotify on macOS.

Also removes the now-obsolete com.apple.security.device.audio-input
entitlement (sandbox is disabled anyway, so it was ignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:30:31 +02:00
Psychotoxical e1b807d0ef chore(aur): bump pkgver to 1.34.12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:33:17 +02:00
Psychotoxical d18646de23 chore(release): bump version to 1.34.12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:20:40 +02:00
Psychotoxical ccdb899833 fix(theme): improve Latte and GTA theme readability
Latte: fix server dropdown dark background (--bg-card instead of hardcoded #1e1e2e), add back-button white text over album cover overlay, fix NowPlaying white-on-white text (artist/album, badges, bio, tracklist, star/heart icons use semantic vars).

GTA: bump --text-muted from #484848 to #707070 and --text-primary from #e8e8e8 to #d8d8d8 — sidebar section labels, Settings descriptions and album detail info were nearly invisible on the near-black background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:44:18 +02:00
Psychotoxical b197694804 chore(about): add PRs #196, #198, #199, #200, #201 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:00:02 +02:00
Frank Stellmacher b45bb95522 Merge pull request #201 from cucadmuh/feat/playback-source-indicator
feat(queue): playback source badge + preload-aware source tracking
2026-04-17 15:56:01 +02:00
Psychotoxical cd1c785e43 fix(queue): use data-tooltip instead of title for source badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:55:48 +02:00
Maxim Isaev 1e54946939 fix(audio): label streaming decoder logs by source type
Differentiate streaming decoder init/decode messages between queue track streaming and radio so errors are not misattributed to radio playback.
2026-04-17 16:43:43 +03:00
Psychotoxical 34e374cf03 fix(macos): remove NSMicrophoneUsageDescription to suppress spurious mic prompt
cpal/CoreAudio triggers macOS TCC mic check during output device enumeration.
Without NSMicrophoneUsageDescription, macOS silently denies input access instead
of showing a dialog — output playback is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:38:40 +02:00
Frank Stellmacher 9387ceca83 Merge pull request #198 from kveld9/feat/DRP-enhancement
feat: discord rich presence enhancement
2026-04-17 15:34:18 +02:00
Maxim Isaev c165669db5 feat(queue): show playback source in tech strip with preload tracking
Add source badges for offline, cache, and stream playback in the queue tech line, including localized tooltips across shipped locales.

Track Rust preload-ready events by stream track id and latch the selected source per track, with dev logs to debug preload/source mismatches during next-track handoff.
2026-04-17 16:34:11 +03:00
Psychotoxical c50addaacf fix(discord): remove dead fields, timeChanged invoke loop, and unsupported {paused} placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:33:58 +02:00
Frank Stellmacher 7e64ef8f2a Merge pull request #199 from kveld9/fix/csv-import-clean
fix: csv import improvements
2026-04-17 15:28:19 +02:00
Frank Stellmacher 8c87ba95ff Merge pull request #196 from cucadmuh/feat/issue-195-queue-replaygain
feat(queue): ReplayGain in current-track tech strip
2026-04-17 15:28:16 +02:00
Frank Stellmacher 54962a15c1 Merge pull request #200 from cucadmuh/feat/streaming-playback-stability
fix(audio): streaming start stability, seek recovery, and transition safeguards
2026-04-17 15:24:09 +02:00
Maxim Isaev 689d2dc019 fix(audio): harden stream playback transitions and cache promotion
Add resilient stream-first track playback with reconnect/range recovery, seek fallback handling for non-seekable starts, and promotion of completed streamed bytes into hot cache.

Also add crossfade/gapless backup preload windows to keep automatic transitions smooth on imperfect networks.
2026-04-17 13:49:31 +03:00
kveld9 f3fb730fbc csv import improvements 2026-04-17 02:55:31 -03:00
kveld9 5576fb0d13 fix DRP timer 2026-04-16 21:41:22 -03:00
kveld9 99c6720b86 fix Settings.tsx and authStore.ts 2026-04-16 21:05:56 -03:00
kveld9 7b20965c1b fix locales 2026-04-16 20:56:20 -03:00
kveld9 28844e8456 release 2026-04-16 20:34:55 -03:00
Maxim Isaev 572eb81927 feat(queue): show ReplayGain metadata in current-track tech strip
Display track gain, album gain, and peak from file tags next to format,
bitrate, and sample rate so missing ReplayGain tags are obvious.

Refs: https://github.com/Psychotoxical/psysonic/issues/195
2026-04-16 23:15:13 +03:00
Psychotoxical b6812de26b remove(seekbar): drop realtime_waveform style
Downloaded and fully decoded the entire audio file on every track
change — too CPU/bandwidth intensive to keep. Removes SeekbarStyle
variant, Rust command, waveform_decode function, all frontend canvas
code, seekbarRealtime_waveform i18n keys across all 8 locales, and
unused urlencoding dependency from Cargo.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:15:48 +02:00
Psychotoxical 2f1d52e100 docs(readme): shorten features list and remove completed roadmap
Condensed 27 feature bullets to 14 key highlights, replaced exact
theme count with "wide selection", added CLI Control entry, and
removed the completed roadmap section (redundant with features list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:38:12 +02:00
Psychotoxical 06902acd4b feat(help): add missing sections and questions (Device Sync, Internet Radio, CLI, Playlists, Infinite Queue, Lyrics sources, Audio device, Backup & Restore, Now Playing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:10:40 +02:00
Psychotoxical 2deaed50b8 chore(about): add missing features from cucadmuh PR #187 to contributors list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:58:05 +02:00
Psychotoxical 3ac82595f9 docs(privacy): add NetEase Cloud Music lyrics source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:50:28 +02:00
Psychotoxical 7db42d74ef feat: tracklist column reset, device sync cross-platform template fix, filename template builder, privacy policy
- Add "Reset to defaults" button to column picker in AlbumTrackList, PlaylistDetail, Favorites (all 8 locales)
- Fix device sync cross-OS status detection: store filenameTemplate in manifest, use it for path computation on import
- Redesign filename template UI: preset buttons (Standard/Multi-Disc/Alt. Folder), clickable token chips ({artist}, {album}, etc.), clear button
- Fix suggestions section in PlaylistDetail missing album column rendering
- Add PRIVACY.md documenting all third-party integrations (Last.fm, LRCLIB, Apple Music, Discord); link from README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:47:41 +02:00
Psychotoxical 58851da817 Update README.md 2026-04-16 08:45:51 +02:00
Psychotoxical 3262458cd3 Update README.md 2026-04-16 08:45:18 +02:00
Psychotoxical bf3d896016 fix(device-sync): auto-import manifest on mount, clear view on disconnect
- Read psysonic-sync.json automatically when DeviceSync page opens and
  drive is already connected (no manual folder re-select needed)
- Always replace sources from manifest when choosing a folder, so
  switching between sticks loads the correct album list
- Hide source list and status badges when drive is disconnected
- Reset import flag on disconnect so re-plugging triggers a fresh import
- Fix unused `mut` warning in cancel_device_sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 23:16:06 +02:00
Psychotoxical 442ed9d191 feat(device-sync): manifest, cancel, font picker + sync status fix
- Write psysonic-sync.json to device after sync/deletions; auto-import
  on folder select when localStorage is empty (cross-platform handoff)
- Add cancel button during active sync: AtomicBool flag per job,
  tasks bail after acquiring semaphore, cancelled state in UI
- Fix sync status staying "pending": normalize template path separators
  to OS separator (Windows: '/' → '\') so compute_sync_paths and
  list_device_dir_files produce matching strings for Set comparison
- Add Geist and JetBrains Mono as variable fonts; font picker collapsible
- Fix device mount detection on Windows: removable drive letters (E:\)
  were incorrectly skipped alongside system roots; strip \?\ prefix
  from canonicalized paths before mount-point comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:59:27 +02:00
Psychotoxical e23280c013 feat(sidebar): add split Mix navigation mode with separate sidebar entries
Extract ALL_NAV_ITEMS to src/config/navItems.ts and add randomMix/randomAlbums
nav items. A new toggle in Settings switches between a single "Build a Mix" hub
and separate "Random Mix" / "Random Albums" sidebar entries (randomNavMode in
authStore). Fixes reorder crash caused by hidden items being overwritten with
undefined during the merge step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:27:53 +02:00
Psychotoxical 189bb519eb chore(about): add PR #184–#192 to contributors list
cucadmuh: CLI player controls + D-Bus forwarding + shell completions (PR #187)
kveld9: Favorites redesign, albums/playlists headers, column picker fixes,
        CSV import, search context menu (PR #184, #186, #188, #190, #191, #192)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:04:14 +02:00
Psychotoxical c573784fbb Merge pull request #192: fix tracklist column picker alignment and toggle functionality 2026-04-15 20:59:49 +02:00
kveld9 03b56afc92 bug fixes 2026-04-15 15:26:56 -03:00
Psychotoxical f437017359 fix(cli): gate OnceLock import behind linux cfg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:03:56 +02:00
Psychotoxical 34acc46c12 fix(cli): suppress dead-code warnings on Windows for Linux-only helpers
Wrap tauri_identifier, single_instance_bus_name and single_instance_object_path
with #[cfg(target_os = "linux")] since they are only reachable via D-Bus paths.
Move the argv collection into the linux-only block in lib.rs for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:01:17 +02:00
Psychotoxical d248a78acc fix(tracklist): revert overflow-x:visible regression from PR #188
.content-body has overflow-x:hidden — setting overflow-x:visible on
.tracklist would clip wide tables instead of scrolling them. Keep auto.
Also remove Spanish code comments (contributors are international).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:35:28 +02:00
Psychotoxical a4d8dd41fd Merge pull request #188 from kveld9/fix/tracklist-picker-overflow 2026-04-15 18:34:36 +02:00
Psychotoxical b7a2e91a62 fix(search): remove duplicate const total in AdvancedSearch
PR #191 moved the declaration to the top of the component but left
the original at line 139, causing a 'has already been declared' error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:32:47 +02:00
Psychotoxical d519042917 Merge pull request #191 from kveld9/feat/search-context-menu
added context menu to search panel songs
2026-04-15 18:27:06 +02:00
Psychotoxical ccbf3605a0 chore: update package-lock after moving @types/papaparse to devDeps 2026-04-15 18:22:30 +02:00
Psychotoxical fce8dc3208 fix(csv-import): guard s.isrc type before toUpperCase, restore public toggle, move @types to devDeps
- s.isrc from Subsonic API may be non-string at runtime; add typeof guard
  to prevent TypeError crashing all 80 search calls into "network errors"
- Restore isPublic toggle in PlaylistEditModal footer (accidentally removed
  in PR #190); keep new Cancel button alongside it
- Move @types/papaparse from dependencies to devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:21:23 +02:00
Psychotoxical 2384b73908 fix(post-merge #190): restore public-toggle in PlaylistEditModal, move @types/papaparse to devDeps
- PlaylistEditModal footer lost the isPublic toggle in the CSV import PR;
  restore it alongside the new Cancel button
- @types/papaparse is a type-only package — belongs in devDependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:10:19 +02:00
Psychotoxical 7cd0eaf9e1 Merge pull request #190 from kveld9/feat/playlist-csv-import
feature: spotify CSV playlist import
2026-04-15 18:09:27 +02:00
Psychotoxical 9383a6f6ed feat(radio): forward ICY StreamTitle to MPRIS metadata
When a radio stream is active, push the station name to MPRIS on start
and forward ICY StreamTitle updates (radio:metadata events) so the OS
now-playing overlay stays in sync. Skips ad metadata (is_ad flag) and
suppresses position ticks for radio streams (no meaningful duration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 624bd6bff7 fix(i18n): add common.play key to all 8 locales (fix for PR #186)
The AlbumHeader and PlaylistDetail play buttons use t('common.play') which
was missing — 'Reproducir' would have appeared as fallback in all languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:14 +02:00
Psychotoxical 67f02def43 Merge pull request #186 from kveld9/redesign/albums-playlists-headers
redesign/albums playlists headers
2026-04-15 18:02:16 +02:00
Psychotoxical 1a2bc3f65b Merge pull request #187 from cucadmuh/feat/cli-completions-player-controls
Reviewed and merged. Clean architecture, correct D-Bus forwarding design, solid completions scripts. Minor nits noted in review (parse_cli_command called 4x, search fetches all types, 500ms sleep comment) — none blocking.
2026-04-15 17:56:45 +02:00
kveld9 56d025f150 added context menu to search panel songs 2026-04-15 12:36:32 -03:00
kveld9 602811908b translations 2026-04-15 12:21:13 -03:00
kveld9 fc90617e9c some fixes 2026-04-15 11:50:44 -03:00
kveld9 a44581e5d6 new feature: import spotify playlist with .csv 2026-04-15 11:19:31 -03:00
kveld9 eb061bd9de fix album tracklist column picker cutoff and scroll containment issues 2026-04-14 23:27:57 -03:00
Maxim Isaev 6533991e7d feat(cli): expand remote player, opaque play id, tray without libindicator
Add CLI commands for stop, shuffle, repeat, mute, star, rating, reload,
server list/switch, and Subsonic search; publish extra snapshot fields and
server/search JSON for tooling. Single `play <id>` resolves track, album,
or artist; artists play a shuffled full discography.

Guard Linux tray creation with catch_unwind when Ayatana/AppIndicator .so
is missing. Harden bash completion against zsh sourcing (compopt). Narrow
AudioEngine field visibility to silence private_interfaces warnings.
2026-04-15 01:47:46 +03:00
Maxim Isaev c75297fcf6 feat(cli): completions, library/audio commands, server switcher
Embed bash and zsh completion scripts and add a `completions` subcommand.
Extend the player CLI (library, audio device, instant mix) and surface
`music_library` in snapshots. Add a shared active-server switcher in the
header and locales; instant mix can reseed the queue from CLI and UI.
2026-04-15 01:47:46 +03:00
Psychotoxical 19b7c8ec06 fix(favorites): correct post-merge issues from PR #184
- btn-sm: add global .btn-sm modifier to theme.css (was only scoped inside device-sync)
- year filter: exclude songs without year metadata when filter is active
- showing count: only render "X of Y" label when a filter is actually active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:38:41 +02:00
kveld9 c3ebc9b951 some fixes 2026-04-14 17:38:08 -03:00
Psychotoxical 117be0d85e Merge pull request #184 from kveld9/feat/ux-redesign-favorites
feat: ux redesign favorites -- sortable columns, gender filter, age range filter, new columns.
2026-04-14 22:34:18 +02:00
Psychotoxical 12818e02f8 chore(aur): bump pkgver to 1.34.11, add cmake makedepend
cmake is required by symphonia-adapter-libopus (bundles libopus via CMake).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:30:15 +02:00
kveld9 7ceaf4d5f2 merge branch 'main' into redesign 2026-04-14 17:29:57 -03:00
Psychotoxical 76da684c8d ci: add cmake to Linux build dependencies
Required by symphonia-adapter-libopus which bundles and compiles
libopus from source via CMake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:59 +02:00
Psychotoxical 795701a447 chore(release): bump version to 1.34.11
- Changelog for v1.34.11 (Opus, Device Sync, community themes, SSL fix)
- Contributors updated in Settings About (PR #181, #182, #183)
- Version bumped to 1.34.11 in package.json, tauri.conf.json, Cargo.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:28:15 +02:00
kveld9 5340d696cf new design 2026-04-14 17:26:02 -03:00
Psychotoxical 0a14123a0b docs(readme): add cmake as required build prerequisite
symphonia-adapter-libopus bundles libopus and compiles it via cmake.
Without cmake installed, cargo build fails with a C compiler error.
Added install instructions for Linux, macOS, and Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:04:25 +02:00
Psychotoxical 7cd03248c8 Merge pull request #183 from cucadmuh/feat/opus-support
feat(audio): Opus playback via symphonia-adapter-libopus
2026-04-14 21:58:22 +02:00
Maxim Isaev 4c6fc4d6d5 merge: integrate origin/main into feat/opus-support 2026-04-14 22:52:35 +03:00
kveld9 6996dacdf7 sortable columns, gender filter, age range filter, new columns. 2026-04-14 16:49:06 -03:00
Psychotoxical 798812d5ef customization: 8 new themes have been implemented
customization: 8 new themes have been implemented
2026-04-14 21:47:51 +02:00
Psychotoxical 9f4c7382aa feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate)
2026-04-14 21:47:48 +02:00
Psychotoxical d34de09673 feat(device-sync): overhaul Device Sync page
- New two-panel layout with live album search (search3, 300ms debounce)
  and 10 random albums when search is empty — replaces full paginated load
- Pre-sync summary modal: shows files to add/delete, net change, available
  space; Proceed button disabled when space is insufficient
- calculate_sync_payload now filters already-synced tracks (path exists on
  device) so add_bytes/add_count reflect the true delta
- Space check accounts for pending deletions: addBytes > availableBytes + delBytes
- Separate deviceSyncJobStore for ephemeral job progress state
- Removable drive detection + auto-poll every 5 s via sysinfo
- Desired-state diff: de-selecting a synced source stages it for deletion
  instead of silently removing it
- Status badges (Synced / Pending / Deletion) with matching row highlights
- Live-Search badge () and Zufallsalben section label (⇌) in album browser
- Status summary row height aligned with search field (52 px)
- i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:37:35 +02:00
Maxim Isaev 1ff4f1ef0f feat(audio): add Opus playback via symphonia-adapter-libopus
Wire a lazy CodecRegistry built with symphonia::default::register_enabled_codecs
plus symphonia_adapter_libopus::OpusDecoder for SizedDecoder and radio decoders.

Raise package rust-version to 1.89 to match the MSRV required by
symphonia-adapter-libopus.
2026-04-14 22:17:29 +03:00
kveld9 9a9be253e3 8 new themes have been implemented 2026-04-14 14:40:23 -03:00
kveld9 38e59d7a5e feat: add 3 visual toggles (cover art background, playlist cover photo, show bitrate) 2026-04-14 14:21:48 -03:00
Psychotoxical 915f0143f7 fix(ssl): strip trailing slash in getBaseUrl + trust OS certificate store for HTTPS streaming
Fixes two bugs reported in #178:

- getBaseUrl() now strips trailing slashes from server URLs, consistent
  with restBaseFromUrl(). A trailing slash caused double-slash stream URLs
  (//rest/stream.view) which Caddy rejects. Trailing slash also caused
  browsing to return 0 results. Fix manually ported from PR #179 by kveld9.

- reqwest now loads the OS native certificate store in addition to
  Mozilla's webpki roots (rustls-tls-native-roots feature). Fixes HTTPS
  streaming failures where the server certificate is signed by a local CA
  (e.g. Caddy internal CA) that is trusted in the system keychain but not
  in Mozilla's root store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:16:08 +02:00
Psychotoxical e0bb02b65e Update CHANGELOG.md 2026-04-14 11:48:51 +02:00
Psychotoxical 0d5011c5be Update CHANGELOG.md 2026-04-14 11:48:01 +02:00
Psychotoxical 34d899806a Update CHANGELOG.md 2026-04-14 11:47:01 +02:00
Psychotoxical 928cdba17a Update CHANGELOG.md 2026-04-14 11:42:55 +02:00
Psychotoxical 17a5c92174 feat(device-sync): USB/SD card sync page (WIP)
Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:02:13 +02:00
Psychotoxical 58f99ae846 chore: bump version to 1.34.11-dev 2026-04-13 22:25:49 +02:00
Psychotoxical ef9a620ac6 chore(aur): bump pkgver to 1.34.10 2026-04-13 22:23:55 +02:00
Psychotoxical 01f71cf50c chore(release): bump version to 1.34.10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:21:07 +02:00
Psychotoxical 39966d1c65 Create FUNDING.yml 2026-04-13 22:07:56 +02:00
Psychotoxical cbf72c7eba chore(about): add cucadmuh PR #176 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:54:02 +02:00
cucadmuh 4207455440 fix(audio): Linux output device selection and watcher (#176)
* fix(audio): stabilize Linux output device picker and watcher

Keep pinned ALSA/cpal device ids stable when enumeration omits the active
sink or returns an equivalent name. Stop Linux device-watcher from clearing
the pin based on missing list entries; macOS and Windows still treat repeated
absence as unplugged. Settings refresh flow calls canonicalize and refetches
the list; add i18n for the out-of-list device label.

* fix(settings): sort audio output devices by label

cpal enumeration order is arbitrary; order the dropdown by readable label
and place the current OS default device first among concrete outputs.
2026-04-13 21:48:21 +02:00
Psychotoxical 9983d13122 chore(about): add kilyabin PR #175 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:48:13 +02:00
kilyabin 1b55e88cd5 fix(fullscreen-player): stop mesh blob and portrait animations in no-compositing mode (#175)
In software compositing (WEBKIT_DISABLE_COMPOSITING_MODE=1), WebKit renders
entirely on the CPU. The mesh blob animations were not covered by the
no-compositing overrides: blob-a occupies 130×130% of the viewport, so each
frame the CPU had to composite a surface larger than the screen. On high-DPI
displays (e.g. 8K) this drops the fullscreen player to ~10 FPS.

- Stop mesh blob pan animations (static gradients are preserved)
- Stop portrait drift animation (filter was already removed)
- Remove box-shadow from the seekbar played bar (its width changes on every
  playback tick; in software mode each change triggers a box-shadow repaint)
2026-04-13 20:46:56 +02:00
Psychotoxical 2326f1f94b feat(linux): AppImage bundle + force X11/XWayland on all Linux packages
- CI: add squashfs-tools dep, APPIMAGE_EXTRACT_AND_RUN=1, appimage to
  --bundles, and *.AppImage to upload glob
- main.rs: set GDK_BACKEND=x11 and WEBKIT_DISABLE_COMPOSITING_MODE=1
  on Linux before Tauri init — WebKitGTK on Wayland is unstable;
  both vars are overridable by setting them before launch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:45:00 +02:00
Psychotoxical 27ea221130 chore(about): update contributors, remove feature tagline, limit changelog to 3
- Add missing contributions: cucadmuh (PR #144, #167, #173, #174),
  kveld9 (PR #168), nisarg-78 (PR #115)
- Remove aboutFeatures tag line from all 8 locales (too dynamic to maintain)
- Changelog section now shows only the 3 most recent versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:23:22 +02:00
cucadmuh 9cd4743d1c feat(settings): audio output device picker (labels, OS default, live refresh) (#173)
* feat(settings): clearer audio device labels for duplicate ALSA names

Show HDMI outputs as "Card (HDMI n)" from hdmi:DEV indices; include PCM and
optional subdevice for hw/plughw/sysdefault; label other ALSA plugins with
iface and PCM. When labels still collide, append a structured hint
(iface · card · PCM) instead of only truncating the raw device string.

* feat(settings): improve audio output device picker

Parse ALSA-style ids into clearer labels (HDMI with DEV index, PCM/subdevice
for hw/plughw/sysdefault). Disambiguate colliding labels; share stderr
suppression for Linux device enumeration.

Add audio_default_output_device_name and tag the matching list entry as the
current system output (i18n). While the Audio tab is open, refresh list and
mark on audio:device-changed and audio:device-reset without toggling the
refresh spinner. Show an error toast if listing devices fails.
2026-04-13 20:13:22 +02:00
cucadmuh e75fda168e fix(folder-browser): arrow navigation only without modifiers (#174)
Skip column/list arrow handling when any modifier is down. Detect modifiers
via nativeEvent and getModifierState (WebKit/WebView), and match arrow keys
by both key and code. On row buttons, preventDefault for plain arrows only
to avoid native focus/scroll stealing navigation. Filter field ArrowDown uses
the same modifier check.
2026-04-13 20:13:10 +02:00
Psychotoxical ec021516c7 fix(audio): device-reset false positive + stderr noise + i18n tooltip
- Device watcher requires 3 consecutive misses (~9 s) before triggering
  audio:device-reset, preventing false positives when ALSA temporarily
  hides a busy device from output_devices() enumeration
- Add stderr suppression to open_stream_for_device_and_rate (last source
  of ALSA terminal noise on Linux)
- Fix tooltip showing raw key 'common.refresh' — replaced with
  settings.audioOutputDeviceRefresh (added to all 8 locales)
- Device switch restarts track from beginning (no seek-back)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:00:36 +02:00
Psychotoxical 82e1b458f3 fix(audio): clean up ALSA device names for display on Linux
Raw ALSA names like "sysdefault:CARD=U192k" are replaced with
readable labels ("U192k") in the device dropdown. The underlying
value stays unchanged so rodio can still open the correct device.

Covers: sysdefault, hw, plughw, iec958, front, surround prefixes.
Names without ALSA structure (pipewire, pulse, default) are kept as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:46:53 +02:00
Psychotoxical fc7a5815ef feat(audio): add refresh button for device list in Settings
The module-level cache prevented newly connected USB audio devices
from appearing in the dropdown. Replace with a manual refresh button
(RotateCcw icon, spins while loading) next to the dropdown.

Device enumeration now runs on every Audio tab open and on explicit
refresh. ALSA stderr noise is already suppressed by the dup2 guard,
so re-enumeration is silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:38:58 +02:00
Psychotoxical 1cf5faff21 fix(audio): suppress ALSA stderr noise during device enumeration
Device watcher (3 s loop) and audio_list_devices both called
cpal output_devices(), triggering ALSA to probe unavailable backends
(JACK, OSS, dmix) and spam stderr with error messages.

Fix: redirect fd 2 to /dev/null for the duration of each enumeration
on Unix via libc dup/dup2 + RAII guard. Also add a module-level
cache in Settings.tsx so audio_list_devices is only invoked once
per app session instead of on every Audio tab activation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:30:24 +02:00
Psychotoxical 16cb4f5a6f feat(audio): audio output device selection (closes #169)
Adds the ability to choose which audio output device Psysonic plays
through — useful for USB DACs, dedicated soundcards, and multi-device setups.

Rust (audio.rs):
- open_stream_for_device_and_rate(device_name, rate): opens a named
  device by cpal name, falls back to system default if not found
- AudioEngine.selected_device: persists the chosen device across
  stream reopens so hi-res rate switches stay on the right device
- audio_list_devices: Tauri command — returns all output device names
- audio_set_device: Tauri command — switches device immediately,
  drops old sinks, emits audio:device-changed
- start_device_watcher: now handles two cases:
    1. No pinned device + system default changed → reopen on new default
    2. Pinned device disappeared (DAC unplugged) → clear selected_device,
       fall back to system default, emit audio:device-reset

Frontend:
- authStore: audioOutputDevice (string | null), persisted
- playerStore: applies stored device on cold start
- App.tsx: listens to audio:device-reset, clears authStore device
  and restarts playback on the fallback device
- Settings → Audio tab: device dropdown at top (above Hi-Res and EQ),
  uses CustomSelect (portal-based, styled), all 8 locales
- CustomSelect: added disabled prop

Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out
of scope for now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:20:23 +02:00
Psychotoxical 9b22327bb0 fix(login): clarify that https:// URLs are accepted in server URL field (#171)
https:// was already supported by the code (startsWith('http') check) but
the placeholder text only showed bare host:port examples, giving no hint
that a full URL with protocol is valid.

- Updated serverUrlPlaceholder in login namespace (all 8 locales) to show
  https://music.example.com as the domain example
- Added settings.serverUrlPlaceholder i18n key (all 8 locales) and wired it
  into AddServerForm — previously the placeholder was a hardcoded English string

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:59:19 +02:00
Psychotoxical dba89cc4e4 fix(offline): replace blocking overlay with slim banner; add server settings link (#170)
When offline with no cached content, the full-screen OfflineOverlay blocked
all navigation including Settings, making it impossible to fix a broken
server config. Replace it with the same slim banner used in offline-cache mode.

- OfflineBanner now handles both cases: cache (existing) and no-cache (new)
- No-cache banner shows server name and a direct link to Settings → Server tab
- OfflineOverlay component is no longer used (import removed from App.tsx)
- All 8 locales: added offlineNoCacheBanner and serverSettings keys

Fixes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:52:17 +02:00
Psychotoxical 91d4cb58b8 feat: playlist management enhancements & UX improvements (#168)
- Multi-selection for Albums, Artists, Playlists with context menu bulk-add
- Collapsible playlist section in sidebar
- Infinite scroll on Artists page (IntersectionObserver)
- Submenu flip-up on viewport overflow
- Remove from Playlist in context menu
- All 8 locales synced

Fixes applied:
- title= → data-tooltip on playlist toggle button (CLAUDE.md)
- Hardcoded Spanish aria-label → i18n (sidebar.expandPlaylists/collapsePlaylists)
- AddToPlaylistSubmenu fetches playlists on first open if store empty

Co-Authored-By: kveld9 <kveld9@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:37:50 +02:00
Psychotoxical 374ebce9b5 feat(keybindings): in-app modifier chords; fix seek shortcut units (#167)
feat(keybindings): in-app modifier chords; fix seek shortcut units
2026-04-13 17:35:45 +02:00
Psychotoxical 083c5f62cb fix(pr168): title→data-tooltip, i18n playlist toggle, cold-start playlist fetch
- Replace title= with data-tooltip on sidebar playlist expand button (CLAUDE.md)
- Replace hardcoded Spanish aria-label strings with i18n keys (sidebar.expandPlaylists / collapsePlaylists) across all 8 locales
- AddToPlaylistSubmenu now fetches playlists on first open if store is empty (regression fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:33:56 +02:00
kveld9 592a479c30 fixed some bugs 2026-04-12 20:43:31 -03:00
Maxim Isaev 6439200e95 feat(keybindings): in-app modifier chords; fix seek shortcut units
Persist chords as ctrl/alt/shift/super plus key code; legacy single-key
bindings still match without modifiers. Settings capture uses buildInAppBinding;
App uses matchInAppBinding and skips chords also registered as global shortcuts.
Share MODIFIER_KEY_CODES and formatBinding with global shortcut formatting.

Fix seek-forward/backward hotkeys: seek() expects 0-1 progress, not seconds.
2026-04-13 01:01:50 +03:00
kveld9 9cb1471170 chore: apply stashed changes 2026-04-12 17:55:13 -03:00
kveld9 21e26e2604 chore: remove tauri generated schemas from tracking 2026-04-12 17:54:16 -03:00
kveld9 94323e91fa fixed bugs 2026-04-12 17:53:52 -03:00
kveld9 bef6941a2b feat: add multi-selection and context menu for playlist management 2026-04-12 17:53:52 -03:00
Psychotoxical 805b6bf163 chore(gen): update Tauri schema files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:51 +02:00
Psychotoxical fee454da5d fix(folder-browser): correct --bg-base to --bg-app in filter bar; add cucadmuh PR #165
--bg-base is undefined — sticky filter bar would have rendered transparent.
Also added PR #165 to cucadmuh's contributions in Settings About.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:40 +02:00
Psychotoxical a765bcd5d0 feat(themes): add Vision Dark & Vision Navy colorblind-safe themes (closes #166)
Purple & Gold palette (Deuteranopia / Protanopia / Tritanopia safe).
Vision Dark: near-black #0D0B12 + Gold #FFD700 (~14.7:1 WCAG AAA).
Vision Navy: deep navy #0A1628 + Gold #FFD700 (~14.5:1 WCAG AAA).
New 'Accessibility' group in ThemePicker.
Removed stale theme count from aboutFeatures across all locales.

This is an initial step toward colorblind accessibility — color variables
alone cannot cover all CVD use cases. Structural improvements (secondary
indicators, pattern/shape cues) are still needed in future iterations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:29 +02:00
Psychotoxical 7e50c7af40 feat(folder-browser): per-column filter keyboard flow
feat(folder-browser): per-column filter keyboard flow
2026-04-12 18:41:08 +02:00
Maxim Isaev a12d6a4015 feat(folder-browser): add per-column filter and queue append hotkey flow
Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes.

When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue.
2026-04-12 14:08:36 +03:00
Psychotoxical da1cc91ff1 fix(deps): bump @tauri-apps/plugin-fs to ^2.5.0 and plugin-dialog to ^2.7.0
Rust crates resolved to newer minor versions; npm pins were behind,
causing CI version-mismatch error on tauri build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:49:07 +02:00
Psychotoxical 08235e1aa5 chore(aur): bump PKGBUILD to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:46:45 +02:00
Psychotoxical 57dbd50092 docs: add CHANGELOG for v1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:44:08 +02:00
Psychotoxical 4a2baaf87d chore: bump version to 1.34.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:38:48 +02:00
Psychotoxical 65e748c464 chore(settings): add cucadmuh PR #163 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:35:02 +02:00
Psychotoxical 346fe604d9 feat(contextMenu): add Trash2 icon to Remove + Artist öffnen unter Album öffnen
- "Diesen Song entfernen" bekommt Trash2-Icon
- Neuer Eintrag "Künstler öffnen" (goToArtist) nach "Album öffnen" in beiden Song-Kontextmenüs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
Psychotoxical 600af0e527 feat: bulk multi-select + drag in PlaylistDetail, Favorites, and artist context menu (#157)
- PlaylistDetail: Ctrl/Cmd+Click enters select mode; bulk drag emits
  { type: 'songs', tracks } when ≥2 selected; filtered-view rows now
  also draggable as single songs
- Favorites songs: full multi-select system — Ctrl+Click, Shift+Click,
  header toggle-all checkbox, bulk-selected highlight, bulk drag to
  queue, bulk-bar with Add to Playlist + Clear
- ContextMenu: ArtistToPlaylistSubmenu resolves all artist album songs
  and forwards to AddToPlaylistSubmenu
- Locale: common.clearSelection + playlists.addSelected in all 7 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:34:33 +02:00
cucadmuh 2dd993257a feat(player): build infinite queue using instant-mix strategy (#163)
Prefer artist-driven candidates (Top + Similar) when extending infinite queue, with random songs as a fallback when mix sources are empty.
2026-04-12 12:34:27 +02:00
Psychotoxical c2f7d6d495 chore(about): add missing contributor entries for v1.34.4 PRs
kilyabin: PR #148 (ru locale), #155 (Build a Mix hub), #156 (FS player perf + settings)
cucadmuh: PR #158 (Folder Browser keyboard nav + context menus)
kveld9: new contributor — PR #159 (Spanish translation), #160 (column sorting)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:11:17 +02:00
Psychotoxical 6a8422ad1f fix(artists): add hover effect to alphabet filter buttons
Buttons were built with inline styles — no :hover possible. Replaced with
.artists-alpha-btn CSS class: accent-colored hover with subtle glow ring,
active state unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:52:31 +02:00
Psychotoxical 8f45f7230e fix(statistics): accurate counts for large libraries + album-artist tooltip
Remove the 10-page cap in fetchStatisticsLibraryAggregates — the loop now runs until
the server returns a partial page, so albums/songs/playtime reflect the full library
regardless of size (previously capped at 5,000 albums). Switched sort type from
'newest' to 'alphabeticalByName' for a stable pagination order.

Add a tooltip on the Artists stat card explaining it shows album artists only (Subsonic
API limitation — track-level featured/guest artists without their own album are not
included). Tooltip added in all 8 locales. Labels with a tooltip get a dotted underline
and cursor:help as visual hint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:47:41 +02:00
cucadmuh ae2e1bcb97 feat(folder-browser): keyboard nav, context menus, now-playing path UX
Adds full keyboard navigation (arrow keys, Enter, Ctrl+Enter for context menu) across Folder Browser columns. Context menus for all row types with keyboard-operable submenus and star-rating control via arrow keys + Enter. Now-playing path is visually emphasized and stays stable; rebuilds on hotkey re-invoke or active-path follow-along. Adaptive column layout for deep trees with right-side visibility priority. New configurable 'Open Folder Browser' keybinding. StarRating animation sync for keyboard-driven changes. All 7 locales updated.
2026-04-12 11:27:26 +02:00
kilyabin 3d07a877f2 feat(fullscreen): performance fixes + appearance settings
Adds no_compositing_mode Tauri command; frontend adds html.no-compositing on Linux to replace GPU-only CSS effects (backdrop-filter, filter, mask-image) with software-friendly equivalents.

Settings → Appearance → Fullscreen Player: toggle for artist portrait visibility + 0–80% dimming slider.

Fix: long words in lyric lines now wrap correctly.
2026-04-12 11:26:44 +02:00
kilyabin bf38a286cd feat(nav): merge Random Mix & Albums into Build a Mix hub
Replaces two sidebar entries (Random Mix, Random Albums) with a single 'Build a Mix' item (Wand2 icon) at /random. Landing page shows two cards — Mix by Tracks and Mix by Albums — with glow-on-hover. Old routes updated throughout. All 7 locales updated.
2026-04-12 11:26:27 +02:00
Kveld. 8f18f73b33 feat(i18n): add Spanish (es) translation
Adds complete Spanish locale with 964 translated strings. Registered in i18n.ts, added to language dropdown in Settings. 8 languages total.
2026-04-12 11:25:54 +02:00
Kveld. 1a2352009b feat(tracklist): column-header sorting for albums & playlists
Replaces dedicated sort buttons with clickable column headers in album and playlist detail views. Three-click cycle: asc → desc → natural order. Sortable by title, artist, album, favorite, rating, duration. Active column shown bold with ▲/▼ indicator.
2026-04-12 11:25:40 +02:00
Psychotoxical eedf6f9337 Update CHANGELOG.md 2026-04-12 11:08:12 +02:00
Psychotoxical b9b8f3fc15 feat(tracklist): multi-select + psyDnD, filter/sort, settings & UI polish
- AlbumTrackList: extract TrackRow as React.memo, selection state moved to
  selectionStore (Zustand) for O(1) re-renders per toggle; Ctrl/Cmd+Click
  enters select mode; drag selected tracks as {type:'songs'} payload;
  selection clears on outside click or song-list change
- QueuePanel: handle 'songs' multi-track drop type; whitelist drag types to
  suppress drop feedback for non-queue drags (lyrics grip etc.)
- AlbumDetail + PlaylistDetail: filter/sort toolbar (title/artist, natural
  order); disc grouping bypassed when sorted; playlist reorder DnD disabled
  while filter active
- useTracklistColumns: 'known' field auto-shows newly added columns for
  existing users
- PlayerBar: mute/unmute restores previous volume via premuteVolumeRef
  instead of hardcoded 0.7
- Settings/Input: reset buttons restyled as RotateCcw icon above card,
  matching HomeCustomizer layout
- i18n: filterSongs, sortNatural, sortByTitle, sortByArtist keys across
  all 7 locales
- components.css: album-card-title/artist nowrap to keep playlist grid
  cards uniform height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 11:42:47 +02:00
Psychotoxical b46acc88e3 fix(player): hot playback cache — minor follow-up (#153)
fix(player): hot playback cache — minor follow-up
2026-04-11 10:32:57 +02:00
Maxim Isaev b4c3124168 fix(player): minor hot-cache eviction, prefetch budget, and settings live size
Small fix for hot playback cache: eviction keeps current and next only;
prefetch up to five tracks when under cap, always fetch the immediate next;
grace for the previous current until debounce; run evict immediately on
MB or folder changes; re-read cap after download; optional Track.size;
live disk usage on Audio settings.
2026-04-11 03:29:59 +03:00
Psychotoxical 20dabbfd03 feat(lyrics): configurable sources with drag-to-reorder + feat(audio): ReplayGain pre-gain & fallback
Lyrics Sources:
- Replace lyricsServerFirst + enableNeteaselyrics toggles with a new
  Settings section — three sources (Server, LRCLIB, Netease) each
  individually toggled and drag-reorderable via psy-drop DnD
- useLyrics.ts iterates sources in user-defined order, skipping disabled
  ones; embedded SYLT from local files still wins unconditionally
- onRehydrateStorage migration from legacy fields on first load

ReplayGain Pre-Gain:
- New authStore fields: replayGainPreGainDb (0…+6 dB) and
  replayGainFallbackDb (-6…0 dB, untagged / radio)
- audio.rs compute_gain applies pre_gain_db and fallback_db
- Settings sliders under ReplayGain mode selector
- Radio HTML5 volume scaled by fallbackDb factor on playRadio

i18n(ru): incorporate PR #148 translation improvements (kilyabin)
All 7 locales updated for new keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:57:36 +02:00
Psychotoxical 003e7a3203 Merge pull request #148 from kilyabin/lang-ru-fix
i18n(ru): improvement of some translation strings
2026-04-10 23:56:10 +02:00
Psychotoxical c142e9e983 feat(radio): PLS/M3U playlist resolution + ICY metadata for playlist streams
Resolves PLS and M3U/M3U8 playlist URLs to their first direct stream URL
before playback and ICY metadata fetching. Stations configured with a
.pls or .m3u URL (e.g. SomaFM, schizoid.in) now play correctly and
report track metadata via ICY headers.

- Rust: parse_pls_stream_url / parse_m3u_stream_url helpers
- Rust: resolve_playlist_url (shared) + resolve_stream_url Tauri command
- fetch_icy_metadata: auto-resolves playlist URLs before connecting
- playerStore: playRadio() awaits resolve_stream_url before setting audio src

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:18:53 +02:00
Psychotoxical 549677ffd4 feat(random-mix): context-aware remix button + genre reset chip
Remix button now targets the active context: relabels to "{{genre}} neu
mixen" when a genre is selected and re-fetches that genre instead of the
global random pool. "Alle Songs" chip added as first genre option to
reset back to the full library mix without leaving the page.

Also fixes album-grid cards stretching to full page height on macOS
WebKit (align-items: start on .album-grid-wrap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:19:22 +02:00
Psychotoxical dcc3e52ad1 fix(store): reset conflicting hot cache + preload state on rehydration
Users who had both hotCacheEnabled and preloadMode !== 'off' before
mutual exclusion was enforced will have both reset to off on next start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:52 +02:00
Psychotoxical 4b6888049f fix(audio): properly suppress unused-variable warning for DecodeError msg
Use `let _ = msg` under `#[cfg(not(debug_assertions))]` so the binding
remains available in debug builds for the eprintln! format string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:34:55 +02:00
Psychotoxical f45892e975 chore(deps): fix npm audit vulnerabilities (axios, vite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:29:02 +02:00
Psychotoxical 16c511c167 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:40 +02:00
kilyabin 9986149e2c i18n(ru): improvement of some translation strings 2026-04-10 19:25:52 +04:00
Psychotoxical 31f72b0459 Update CHANGELOG.md 2026-04-10 17:18:55 +02:00
Psychotoxical e060737de9 chore(aur): bump PKGBUILD to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:15:54 +02:00
Psychotoxical 28b23a9de1 docs(changelog): add v1.34.8 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:13:40 +02:00
Psychotoxical 649f5223b4 chore: bump version to 1.34.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:06:19 +02:00
Psychotoxical 1cc674e4ee fix(ui): icon updates, remove mic button, theme fixes
- LiveSearch: replace SlidersVertical with TextSearch for advanced search
- AlbumHeader: add Highlighter icon for artist bio button
- PlayerBar: remove unused lyrics/mic button
- components.css: album-detail-badge always opaque (accent bg, white text)
- theme: Middle Earth — remove sidebar stripes, fix queue/artist/bio contrast
- theme: Toy Tale — fix muted text, queue tabs, sidebar labels, divider
- theme: Tetrastack — brighten purple/blue palette, raise text-muted contrast
- theme: Horde & Alliance — remove repeating sidebar line pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:05:20 +02:00
Psychotoxical 41e98d5783 fix(lyrics): strip Netease metadata lines from LRC output
Filter lines matching 作词/作曲/编曲/制作人/etc. that Netease
embeds as timestamped LRC entries at the start of the song.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:13:35 +02:00
Psychotoxical 6ffcd6f6fa feat(lyrics): add Netease Cloud Music as opt-in fallback source
Netease is queried only when server and LRCLIB both return nothing,
preserving the existing lyrics chain completely. Off by default.

- Rust command `fetch_netease_lyrics` proxies Netease API (CORS bypass)
- `src/api/netease.ts` TypeScript wrapper via invoke
- `authStore.enableNeteaselyrics` toggle (default: false)
- `useLyrics`: Netease fires last in fallback chain when enabled
- Settings toggle in the Lyrics section
- `lyricsSourceNetease` label in LyricsPane
- i18n: all 7 languages (en, de, fr, nl, nb, ru, zh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:11:06 +02:00
Psychotoxical 33a15fd17a chore(settings): add AudioMuse-AI PR #147 to cucadmuh's contributions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:51:14 +02:00
Psychotoxical 084543e59b fix(tracklist): split multi-artist tracks and fix reset button style
- Use OpenSubsonic `artists[]` array to render each artist separately
  with · separator; artists with an ID are clickable, others plain text
- Fall back to single artist (artistId/artist) on non-OpenSubsonic servers
- Settings reset-to-defaults buttons changed from btn-ghost to btn-danger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:37:29 +02:00
Psychotoxical b0081e3d7a Merge pull request #147 from cucadmuh/feat/audiomuse-navidrome
feat(discovery): Navidrome AudioMuse-AI (Instant Mix, similar artists, probe)
2026-04-10 14:09:21 +02:00
Psychotoxical ccc9f2cae5 Merge pull request #147: feat(discovery): Navidrome AudioMuse-AI integration
Resolves conflict in ContextMenu.tsx: keep useShallow import from main
alongside getSimilarSongs added by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:09:13 +02:00
Psychotoxical f34bd7c0f1 fix(audio): separate gapless chain from preload gate and reduce black flash
- Gapless now always chains at 30s regardless of preloadMode ('off' no
  longer breaks gapless playback)
- Byte pre-download (audio_preload) skips when Hot Cache is active to
  avoid duplicate downloads
- When both Gapless and Preload are active, bytes are pre-fetched early
  and the chain reuses the cached data (separate bytePreloadingId guard)
- player-album-art-wrap gets background: var(--bg-card) so the brief
  opacity-0 loading state shows a themed colour instead of black

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:04:30 +02:00
Psychotoxical 8cb5eb9384 chore(settings): remove alpha badges from Hot Cache and Hi-Res sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:45:56 +02:00
Psychotoxical 02a4c43f61 docs: add psysonic-bin AUR badge and early-development warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:41:51 +02:00
Psychotoxical c49f6af38b perf: reduce CPU usage and unify next-track buffering settings
- Throttle audio:progress from 100ms to 500ms; WaveformSeek and
  FsSeekbar use imperative DOM updates instead of React re-renders
- Fix all usePlayerStore() calls without selectors across pages and
  components (useShallow / individual selectors throughout)
- Remove filter:blur and transform:scale from Hero, AlbumDetail and
  FullscreenPlayer backgrounds — eliminated expensive software
  compositing layers on WebKitGTK
- Replace translate3d with 2D translate in FS mesh and portrait
  animations; remove will-change:transform from mesh blobs
- Move Hot Cache section from Storage tab to Audio tab; group Preload
  and Hot Cache under a shared 'Next Track Buffering' section with a
  mutual-exclusivity note and auto-disable logic
- Add 'off' toggle to Preload (replaces Off button with toggle switch);
  enabling either method now automatically disables the other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:38:33 +02:00
Maxim Isaev 69c0c5a907 merge: integrate origin/main into feat/audiomuse-navidrome 2026-04-10 13:04:34 +03:00
Maxim Isaev 4de577eede feat(navidrome): Instant Mix probe, ping identity, and AudioMuse UX
Parse type and serverVersion from Subsonic ping; persist per-server identity and
run a background Instant Mix probe (random songs + getSimilarSongs) for
Navidrome 0.60+. Hide the AudioMuse server toggle when the probe returns no
similar tracks; clear prefs when the server is no longer eligible.

Artist pages fall back to Last.fm similar artists when AudioMuse is enabled but
the server returns none. Instant Mix failures set a per-server issue flag with a
settings warning and toast. Settings description links the official plugin repo
via i18n Trans.
2026-04-10 13:02:46 +03:00
Psychotoxical c9dfbcc19f chore(settings): reset uiScale to 1.0 on startup while scaling is disabled
Users who had previously set a custom scale are silently reset to 100%
on next launch so they are not stuck at a broken zoom level. The stored
value is cleared in fontStore so the slider starts at 100% once the
feature is re-enabled.

Also credits nisrael for the ICY / AzuraCast PR #146.

TODO: remove the reset effect and re-enable the slider in Settings.tsx
when UI scaling is properly reworked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:17:43 +02:00
Maxim Isaev 36f3d42dbe feat(discovery): Navidrome AudioMuse-AI client integration and library scoping
Add per-server toggle for AudioMuse-style discovery: Instant Mix via
getSimilarSongs, server similar artists instead of Last.fm on artist pages,
and higher similar-artist count in Now Playing when enabled.

When browsing a single music folder, filter similar/top song results by
album ids from that scope (Navidrome does not apply musicFolderId to those
endpoints). Request larger similar batches under a narrow scope.

Keep radio-from-track as two blocks (shuffled top songs, then shuffled
similar-by-artist) so it differs from Instant Mix. Show Alpha badge beside
the setting. Add research/ to .gitignore.
2026-04-10 12:07:32 +03:00
Psychotoxical 49f7fe5f6e feat: add ICY metadata and AzuraCast radio streaming support (#146)
feat: add ICY metadata and AzuraCast radio streaming support
2026-04-10 11:05:59 +02:00
Psychotoxical 28943f1ecb chore(settings): disable UI scale slider pending rework
Interface scaling is temporarily disabled in the Settings UI
(opacity + pointer-events: none) with a note that it will return
in a future update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:04:21 +02:00
Nils Israel 46cefb5712 feat: add ICY metadata and AzuraCast radio streaming support
Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/88faada5-28bb-446f-b53b-46a0efef387e

Co-authored-by: GitHub Copilot <198982749+copilot@users.noreply.github.com>
Signed-off-by: Nils Israel <nils@sxda.io>
2026-04-10 10:38:59 +02:00
Psychotoxical 74985fe331 fix(audio): fall back to track gain when album gain is missing
In album gain mode, tracks without albumGain tags received no
ReplayGain adjustment at all. Now falls back to trackGain before
defaulting to 0 dB — fixes inconsistent loudness across albums
where only some tracks have albumGain metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:12:06 +02:00
Psychotoxical 4861d99cb1 fix(statistics): library-scoped genre stats, cached fetches, duration i18n
fix(statistics): library-scoped genre stats, cached fetches, duration i18n
2026-04-10 01:11:29 +02:00
Maxim Isaev d2592839b0 i18n(ru): use «Популярное» for most-played nav, home, and page title
«Чаще всего» read awkwardly; «Популярное» matches the play-count sense
without sounding like a grammar exercise.
2026-04-10 02:06:24 +03:00
Maxim Isaev bbeb2f661e merge: integrate origin/main into fix/statistics-i18n-small-fixes 2026-04-10 01:42:14 +03:00
Psychotoxical 5f0fb5dcbd feat(audio): auto-switch to new audio output device at runtime
Adds a background device-watcher that polls the OS default output device
every 3 s via CPAL. When the device changes (Bluetooth headphones connecting,
USB DAC plugging in, etc.) the stream is reopened on the new device, the old
Sink is dropped (it was bound to the now-closed OutputStream), and
audio:device-changed is emitted to the frontend.

Frontend handler (TauriEventBridge): if playing, restarts the current track
from the saved position; if paused, clears the warm-pause flag so the next
resume uses the cold path (audio_play + seek) which creates a new Sink on
the new device.

Fixes #143 (audio through speakers after connecting Bluetooth headphones).
Also covers the intermittently reported single-channel output after long idle,
which can be caused by the OS reconfiguring the audio device in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:40:12 +02:00
Maxim Isaev 6f6cb0fd6b fix(statistics): cache Subsonic stat fetches and localize duration units
Add 7-minute TTL caches (same idea as rating prefetch) for overview
album strips, random-song format sample, and paginated library
aggregates; key by server and music folder.

Introduce formatHumanHoursMinutes with common.duration* strings in all
locales for Statistics and playlist duration labels.

Refresh RU/ZH statistics and nav wording; fix zh playtime label.
2026-04-10 01:39:08 +03:00
Psychotoxical 15dc970f53 fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon
- Revert _msg → msg in DecodeError match arm; the variable is used in the
  debug_assertions eprintln! block so the underscore prefix caused a compile error
- Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar,
  LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:28:12 +02:00
Maxim Isaev fc40d235d0 fix(statistics): scope genre insights to selected music library
getGenres() ignores musicFolderId. Derive genre counts from the same
paginated getAlbumList('newest') scan used for playtime and totals,
using each album's genre and songCount.
2026-04-10 01:18:44 +03:00
Psychotoxical f304589ea1 fix(audio): suppress unused variable warning in DecodeError match arm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:15:55 +02:00
Psychotoxical 22f7de45e8 fix(audio): set User-Agent header, add debug fetch logging
Set psysonic/<version> as User-Agent on the audio reqwest::Client to
prevent reverse proxies (nginx, Caddy, Traefik) from blocking requests
with a 403. Add #[cfg(debug_assertions)] logging in fetch_data to show
the exact URL, status, content-type and server header — useful for
diagnosing proxy-related 403s reported by users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:52:02 +02:00
Psychotoxical 05da369aad chore(aur): bump pkgver to 1.34.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:29:11 +02:00
Psychotoxical 9671f89a48 chore: bump version to 1.34.7, add changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:28:54 +02:00
Psychotoxical 9567a94dea feat(windows): replace GDI taskbar icons with embedded .ico assets
Strips the legacy monochrome GDI drawing code and loads high-quality
icons from embedded .ico files via CreateIconFromResourceEx. Fixes
windows 0.58 import paths (Controls→Shell subclassing, TaskbarList CLSID)
and adds proper cleanup for all four HICONs on WM_NCDESTROY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:14:21 +02:00
Psychotoxical 1ba8619a37 chore: remove preview-update debug button, add GDI features, add contributor
- Remove debug "Preview Update Modal" button from Settings/About
- Add Win32_Graphics/Win32_Graphics_Gdi features for taskbar GDI icons
- Add sorensiimSalling contributor credit for Russian translation (PR #140)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:30:23 +02:00
kilyabin 973b472c1f fix: not translated lyricsServer* string to RU locale (#140) 2026-04-09 22:24:03 +02:00
Psychotoxical 20bf93c344 feat(windows): taskbar thumbnail toolbar with GDI media icons
Implements ITaskbarList3::ThumbBarAddButtons for Prev/Play-Pause/Next
buttons in the Windows taskbar thumbnail preview. Icons are drawn at
runtime via GDI (no binary assets). WndProc subclass intercepts
THBN_CLICKED and emits the same media:* events as souvlaki/tray.
update_taskbar_icon command swaps Play↔Pause icon on state change.
Frontend syncs via playerStore subscribe alongside mpris_set_playback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:12:35 +02:00
Psychotoxical fd834314ba fix(audio): VLC-style frame dropping for corrupt MP3s, silence logs in release
Replace the fixed DECODE_MAX_RETRIES (3) loop with a consecutive-error counter
that tolerates up to 100 bad frames before giving up — matching how VLC handles
files with a handful of invalid main_data offset frames. Frame-drop logs are
wrapped in #[cfg(debug_assertions)] so production builds stay silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:42:38 +02:00
Psychotoxical a78c0fe9ac fix: font switching, layout scaling, embedded lyrics, folder opening
- fix(fonts): update CSS var declarations to @fontsource-variable naming
  ('Inter Variable', 'Outfit Variable', etc.) so dynamic font switching works
- fix(layout): 100vh → 100% on .app-shell to fix Windows WebView2 playerbar drift;
  1fr → minmax(0,1fr) in all grid-template-rows + remove min-height: 720px to fix
  Linux playerbar disappearing at high zoom or small window sizes
- fix(updater): replace shell open() with Rust open_folder command to bypass
  shell:allow-open capability scope blocking local paths on Windows
- fix(lyrics): add get_embedded_lyrics Tauri command (id3 crate for MP3 SYLT/USLT,
  lofty for FLAC SYNCEDLYRICS/LYRICS); fix parseLrc regex for LRC without fractional
  seconds; fix SubsonicStructuredLyrics to accept both synced and issynced fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:52:27 +02:00
Psychotoxical 9c57d4f887 feat(updater): professional update modal with skip, changelog, and OS-aware downloader
Replaces the small corner toast with a centered modal that appears on
startup when a newer GitHub release is detected (4 s delay).

Features:
- Skip this Version: stores skipped tag in localStorage, reappears only
  for newer releases
- Collapsible changelog: renders GitHub release body as markdown accordion
- OS-aware download: Windows → .exe installer, macOS → .dmg (aarch64
  preferred), Linux Arch → AUR hint (yay/pacman), Linux other → .AppImage/.deb
- In-app downloader: Rust download_update command streams to ~/Downloads/,
  emits update:download:progress every 250 ms for a real-time progress bar
- Post-download: Show in Folder button opens Downloads dir via shell.open
- Buttons: Download Now / Skip this Version / Remind me Later

Rust: check_arch_linux() reads /etc/arch-release + /etc/os-release
platform.ts: adds IS_MACOS, IS_WINDOWS alongside existing IS_LINUX
Settings About: Preview Update Modal button for testing
Fixes: renderInline regex had nested capture group causing undefined entries
in split() result → TypeError → React crash → WebKit white-screen freeze

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 5d067b1f8b fix(app): hide resize grips in native fullscreen on all platforms
Previously onResized only tracked fullscreen on Linux. Now all platforms
set isWindowFullscreen, and an initial check on mount catches windows
that start maximized/fullscreened. CSS: .app-shell[data-fullscreen] .resizer
{ display: none } added in layout.css (shipped with updater commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 48d0145dc8 fix(albums): align year filter inputs to button height and font size
Input padding and font-size now match adjacent .btn elements so the
year filter row no longer has mixed heights.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 0da92c2fa1 fix(radio): prevent macOS WKWebView crash + shuffle Artist Radio queue
- Remove currentTime from Zustand persist partialize: it caused 100ms
  localStorage writes that grew with the queue, crashing WKWebView SQLite
  after ~10 min of Artist Radio on macOS
- Trim old played radio tracks from queue (keep last 5) to cap localStorage
  payload size during proactive radio top-up in next()
- Add reconnect logic for internet radio stall events (max 5 retries)
- Shuffle initial Artist Radio queue via Fisher-Yates so positions 2+
  are drawn from similar-artist tracks, not predictable top-5 tracks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 4e3efa3814 feat: self-host fonts via @fontsource-variable, remove Google Fonts CDN
Replaces all Google Fonts CDN references (index.html preconnect/stylesheet
and theme.css @import) with local @fontsource-variable npm packages.
All 10 UI fonts now bundle as WOFF2 into dist/assets/ — app renders
correctly without any internet connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 77085a544e chore(credits): add missing contributor PRs to Settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:23:48 +02:00
Psychotoxical 8ffecb4b7c Merge pull request #138 from cucadmuh/fix/statistics-music-folder-totals
fix(statistics): scope album and song totals to selected music library
2026-04-09 12:48:02 +02:00
Maxim Isaev c569ff5f34 fix(statistics): scope album and song totals to selected music library
getGenres() is not musicFolder-scoped, so summing genre counts showed
global totals while other statistics respected the library filter.
Derive album and track counts from the same paginated getAlbumList
pass used for playtime, with the same 5000-album cap and ≥ prefix when
capped.
2026-04-09 00:59:16 +03:00
Psychotoxical d16a99a6f9 docs(help): add 11 new FAQ entries for undocumented features
Ratings (q37-q39): how to rate songs/albums/artists, click active star to
remove rating, Skip-to-1★ and rating filter for mixes. Folder Browser (q40).
Settings: Theme Scheduler (q41), UI Scale (q42), Seekbar styles (q43),
AutoEQ (q44), Replay Gain (q45), Hot Cache (q46). Offline: playlist
caching and toggle (q47). All 7 locales updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:52:36 +02:00
Psychotoxical 9b239892ef chore: bump version to 1.34.6, add changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:36:59 +02:00
Psychotoxical ba670bd1e8 feat: fix UI freezes in ZIP and offline cache downloads
ZIP downloads (PlaylistDetail, Albums, NewReleases, RandomAlbums) now use
invoke('download_zip') via Rust streaming instead of fetch+blob+arrayBuffer,
eliminating JS heap saturation on large files. Progress shown in ZipDownloadOverlay.

Offline cache downloads (600+ songs) no longer freeze the UI: transient job
state moved to a separate non-persisted offlineJobStore, reducing localStorage
writes from ~1200 down to 2 for an entire download. Also adds playlist offline
toggle — clicking the cache button when already cached removes it from cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:33:23 +02:00
Psychotoxical c1e57b4c06 fix(home): recently added albums title links to /new-releases instead of /albums 2026-04-08 18:56:39 +02:00
Psychotoxical fbe68116cc chore(aur): bump pkgver to 1.34.5 2026-04-08 18:05:26 +02:00
Psychotoxical dba0c26480 docs(changelog): add v1.34.5 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 3643a78cd6 perf: drastically reduce background API requests to Navidrome (v1.34.5)
- NowPlaying polling: only active while dropdown is open + respects
  Page Visibility API — was firing every 10s unconditionally (~8.6k req/day)
- Connection check interval: 30s → 120s (4× reduction)
- Queue sync debounce: 1.5s → 5s (prevents burst on rapid skip)
- Rating prefetch: add 7-minute in-memory TTL cache for artist/album
  ratings — repeated random album loads no longer re-fetch known ratings

Fixes server log flooding reported by users behind reverse proxies (Traefik).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:04:03 +02:00
Psychotoxical 1e4b851e9e feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
feat: disable native decorations on Linux, hide custom TitleBar for t…
2026-04-08 17:49:38 +02:00
kilyabin 37799fb861 feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs
On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, Openbox, etc.)
     the window has no title bar at all — neither native nor custom. On stacking DEs
     (GNOME, KDE, XFCE) the custom TitleBar can be toggled in settings as before.

     Native decorations are disabled on all Linux at startup. The frontend checks
     is_tiling_wm() to decide whether to render the custom TitleBar:
     - DE (GNOME/KDE): custom TitleBar shown (user-toggleable in settings)
     - Tiling WM: no TitleBar at all, setting hidden from Settings page

     Detection is based on environment variables:
     - HYPRLAND_INSTANCE_SIGNATURE, SWAYSOCK, I3SOCK (direct compositor signatures)
     - XDG_CURRENT_DESKTOP check for known tiling WMs

     Changes:
     - Added is_tiling_wm_cmd() Tauri command for frontend detection
     - Native decorations disabled on all Linux at startup
     - TitleBar not rendered on tiling WMs
     - Custom TitleBar setting hidden in Settings for tiling WMs
2026-04-08 19:36:39 +04:00
Psychotoxical 099516121e perf(fullscreen): fix dynamic accent color delay via direct fetch + cache
- Bypass ImageCache 5-slot queue: fetch cover art directly, create blob URL
  for canvas extraction, revoke immediately after use
- Module-level cache (artKey → accent) avoids re-fetching within the same
  album — same-cover tracks get the color instantly
- Keep previous color visible on cache miss instead of flashing to theme color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:13:48 +02:00
Psychotoxical 9047a44480 perf(fullscreen): extract dynamic accent from already-loaded cover blob
Previously a separate getCachedUrl call fetched the 300px cover art just
for color extraction, racing with the 500px fetch for display. Now reuses
resolvedCoverUrl directly — color appears as soon as the display image is
ready, with no redundant network request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:56:18 +02:00
Psychotoxical 47fcade3b3 feat(settings): show hint in theme picker when scheduler is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:51:43 +02:00
Psychotoxical d832dfb253 fix(statistics): remove Top Rated Songs/Artists sections
The implementation was unreliable (depended on starred songs + session-only
overrides) so the sections are removed entirely. All PR#130 rating logic
(StarRating, setRating API calls, userRatingOverrides) is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:37:12 +02:00
Psychotoxical 041d946b58 feat(settings): 12-hour AM/PM time format for English locale in theme scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:23:46 +02:00
Psychotoxical 64d8b1cbd3 fix(titlebar): window drag, resize grips and mobile-mode grid for custom titlebar
- Add core:window:allow-start-dragging capability so data-tauri-drag-region
  actually works (was silently blocked without this permission)
- Add CSS resize grips (bottom-left + bottom-right) that replace the native
  GTK grip lost when decorations: false
- Add [data-mobile][data-titlebar] grid override so the titlebar renders
  correctly in narrow-window mode instead of being hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:13:58 +02:00
Psychotoxical 5848c621fd feat(folder-browser): Miller columns directory navigation
New page /folders with macOS Finder-style column layout:
- getMusicDirectory / getMusicIndexes / getMusicFolders Subsonic API methods
- Root level uses getIndexes.view (music folder IDs are not getMusicDirectory IDs)
- Columns fill available width with flex: 1, min-width: 200px
- Auto-scroll to rightmost column on expand
- Cancelled-flag prevents stale state on fast navigation
- Clicking a track plays it in the context of the current column's tracks
- FolderOpen/Folder/Music icons, accent-colored selection, ellipsis truncation
- Hidden in sidebar by default (user can enable in Settings)
- i18n: EN DE FR NL NB ZH RU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:40:29 +02:00
Psychotoxical 0a0dde057d feat(seekbar): fade edges on waveform style
Apply a destination-in gradient mask after drawing the waveform bars so
both the left and right edges fade smoothly to transparent instead of
cutting off abruptly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:01 +02:00
Psychotoxical 0119e27f6d feat(covers): logo fallback for broken cover art images
CachedImage now shows /logo-psysonic.png when an image fails to load
instead of a broken icon. The fallback switches to object-fit: contain
with a card background so the logo stays proportional inside the container.

If the caller provides a custom onError (e.g. to hide the element), that
handler is used instead. A separate fallbackSrc state prevents React from
re-writing the broken URL on re-renders. The DOM-level onerror = null
guard prevents any infinite error loop.

extractCoverColors skips color extraction when the logo fallback is
active, falling back to the theme accent color instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:25 +02:00
Psychotoxical c7adb599ee fix(artist): decouple artist info fetch from main render path (fix #132)
getArtistInfo2.view can trigger Navidrome to do slow external lookups
(Last.fm / Apple Music) when no local artist image exists, blocking the
entire page for 10+ seconds.

Render the page immediately after getArtist() resolves (local data only),
then fire getArtistInfo and getTopSongs in the background. A cancelled
flag prevents stale state updates when the user navigates away before
either background fetch completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:10:01 +02:00
Psychotoxical d6546e12ca docs(readme): bring features and roadmap up to date with v1.34.4
- Update language count to 7 (add Norwegian and Russian)
- Update theme count to 67, add Theme Scheduler mention
- Expand seekbar description to all 10 styles
- Add missing features: Ratings, Fullscreen Player, Playlist Management,
  AutoEQ, Internet Radio, Tray Icon, Backup & Restore, UI Scale,
  Album Multiselect, Custom Linux Titlebar
- Add ~15 missing completed items to roadmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:57:25 +02:00
Psychotoxical f0438cae5e fix(seekbar): pulsewave clean connection, retrotape rolling wheel at playhead
Pulsewave: draw flat played line only up to where the Gaussian envelope
starts (startX), eliminating overlap between the flat line and the wave.

Retrotape: replace dual fixed-position reels with a single spinning reel
that tracks the playhead position across the full seek bar width.

fix(i18n): remove literal 'N' from skip-star descriptions in all languages

Replace 'After N skips' with natural phrasing ('after several skips') in
EN, FR, NL, NB, ZH. Simplify ratingsSkipStarThresholdLabel to a single
word (Skips / Sauts / Overslagen / Hopp / 跳过次数). DE was already fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:46:58 +02:00
Psychotoxical e64d151079 feat(settings): theme scheduler, UI scale, CustomSelect scroll fix
- Time-based theme scheduler: auto-switches between day/night theme
  based on configurable times; setInterval re-checks every minute
- UI scale slider (80–150%) via CSS zoom on document root; preset
  buttons aligned to actual slider positions
- CustomSelect: scroll listener keeps dropdown anchored on scroll
- All features i18n in 7 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:23:24 +02:00
Psychotoxical f0b423ddc4 Update CHANGELOG.md 2026-04-08 12:04:37 +02:00
Psychotoxical 8f3b9ebe00 ci: use setup-node built-in npm cache instead of manual cache step 2026-04-08 11:30:05 +02:00
Psychotoxical d4b18fec5a fix(statistics): explicit Map<string, SubsonicSong> type to satisfy tsc 2026-04-08 11:17:05 +02:00
Psychotoxical dbb53bfa70 release: bump to v1.34.4
Song ratings in context menu + player bar, entity ratings (PR #130),
5 new seekbar styles, custom Linux title bar, album multi-select,
mix rating filter, top-rated stats, compilation filter, scroll reset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:13:23 +02:00
Psychotoxical 737b057d4a feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)
Seekbar styles (5 new, by Psychotoxical):
- Neon Glow: transparent track, played section as multi-layer glowing neon tube
- Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF)
- Particle Trail: particles spawn at playhead and drift with glow (rAF)
- Liquid Fill: glass tube fills with liquid and animated wave surface (rAF)
- Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF)

Ratings system (PR #130 by cucadmuh):
- Shared StarRating component with pulse/clear animations, disabled/locked states
- Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport)
- Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server)
- Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home
- Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters
- Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads
- Settings: new Ratings section for skip threshold and per-axis mix filter stars
- i18n: all 7 languages (en, de, fr, nl, zh, nb, ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:29:21 +02:00
Maxim Isaev 0c2ac13fed merge: integrate origin/main into feat/improve-rating-ux
Resolve conflicts in RandomAlbums (keep mix rating filter with batch ZIP/offline
selection from upstream) and Settings (SeekbarPreview + mix filter constants).
2026-04-08 05:04:12 +03:00
Maxim Isaev 1624880c2a feat(ratings): mix cutoff filter for random flows and home, i18n
Apply the same per-axis star cutoff to random mix (multi-batch fetch until
full), random albums, Hero, and Home hero/discover rows. Prefetch artist and
album ratings via Subsonic when list payloads omit them. Exclude items when
0 < rating ≤ threshold; 0 or missing counts as unrated.

Settings: rating filter description interpolates sidebar menu labels; Russian
strings for custom title bar; short axis labels aligned across locales.
2026-04-08 04:57:26 +03:00
Psychotoxical 6587c82e0c fix(backup): include psysonic_home in backup keys
Home section visibility/order is a user preference and was missing
from the backup. Server-specific stores (offline, playlists_recent)
intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:40:54 +02:00
Maxim Isaev 8a1d942128 feat(ratings): skip-to-1★ threshold and mix min-rating filter
Add Ratings section under General: manual skip count before setting 1★,
and per-axis minimum stars for random mixes/albums (song, album, artist).
StarRating shows five stars with selection capped at three; shared max in authStore.
Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types.
2026-04-08 02:39:25 +03:00
Psychotoxical 829936a78d feat(seekbar): configurable seekbar styles with animated preview
Add five seekbar styles selectable in Settings → Appearance:
Waveform, Line & Dot, Bar, Thick Bar, and Segmented. Each option
shows an animated canvas preview using requestAnimationFrame.

Style is persisted in authStore (seekbarStyle, default: waveform).
Translations added for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:39:08 +02:00
Psychotoxical ada7dd010f Revert "feat(waveform): real amplitude waveform via Symphonia + smooth crossfade"
This reverts commit 504c53e71d.
2026-04-08 01:23:36 +02:00
Psychotoxical 67de94d955 chore: update screenshots and fix titlebar window capabilities
- Replace single screenshot with 4 new screenshots in README
- Remove old icon.png, logo.png, screenshot.png
- Add missing allow-minimize + allow-toggle-maximize capabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:06:52 +02:00
Maxim Isaev 705c80ef07 feat(ratings): Subsonic entity ratings and StarRating UX
Probe OpenSubsonic after connect; persist album/artist ratings via setRating when supported. Add shared StarRating (repeat same star to clear, pulse and shrink animations, hover freeze after clear). Widen tracklist duration column and bump narrow saved widths in localStorage. Use StarRating in AlbumHeader, track lists, and playlist rows.
2026-04-08 01:09:17 +03:00
Psychotoxical 504c53e71d feat(waveform): real amplitude waveform via Symphonia + smooth crossfade
Add Rust command `compute_waveform` that downloads the audio file,
seek-samples 500 evenly-spaced frames with Symphonia (fast path) and
computes peak amplitudes. Percentile-based normalisation (p5→p95)
preserves visible dynamics even for heavily compressed music.

Frontend caches results per track (module-level Map), shows the
pseudo-random fallback immediately and crossfades to the real waveform
over 400 ms (ease-in-out) once the computation finishes.

Also: Settings input-tab reset buttons now use btn-danger styling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:15 +02:00
Psychotoxical 5be7f7cabd feat(albums): add multi-select to Albums, New Releases, and Random Albums
Select multiple albums to add them to the offline cache or download
each as a separate ZIP. Action buttons appear inline in the topbar
when at least one album is selected; title shows selection count.
AlbumCard gains selectionMode/selected/onToggleSelect props with a
checkmark overlay and selected outline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:04:44 +02:00
Psychotoxical c1624342d3 fix: scroll content-body to top on route change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:22:51 +02:00
Psychotoxical 4182b67732 feat(most-played): add toggle to filter compilation artists
Toggle button in the Top Artists section header hides known compilation
artist names (Various Artists, VA, Soundtracks, OST, etc.) from the
ranking. Default off. Active state shown in accent color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:20:35 +02:00
Psychotoxical 7b4eeea0dc feat(titlebar): custom Linux title bar with now-playing display (#titlebar)
Replaces the native GTK/GNOME title bar with a built-in one that matches
the app theme on all Linux desktops. Tested on CachyOS + KDE Plasma and
Fedora + GNOME.

- set_decorations(false) on Linux at startup
- TitleBar component: drag region, minimize/maximize/close, centered now-playing
- Hides automatically in native fullscreen (F11) via onResized+isFullscreen
- Optional: toggle in Settings → Verhalten (default: on)
- macOS and Windows completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:08:44 +02:00
Psychotoxical 47cea7e3d6 fix(titlebar): detect fullscreen via onResized+isFullscreen instead of manual state
Manually tracking setIsWindowFullscreen was unreliable (e.g. WM-triggered
fullscreen bypasses the keybinding handler). Now listens to window resize
events and queries actual fullscreen state after each resize.

Tested on CachyOS + KDE Plasma and Fedora + GNOME.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:07:52 +02:00
Psychotoxical 5ed1b58d67 feat(titlebar): hide custom title bar in native fullscreen (F11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:05:31 +02:00
Psychotoxical 900853fedc fix(titlebar): re-focus window after re-enabling native decorations on GTK
GTK re-stacks the window when set_decorations(true) is called, causing it
to lose focus and sink behind other windows. Call set_focus() immediately
after to bring it back to the front.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:01:42 +02:00
Psychotoxical 2c3c89f078 fix(titlebar): add minimize and toggleMaximize window capabilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:50:49 +02:00
Psychotoxical 45fa606ae1 feat(titlebar): make custom title bar optional via Settings toggle
- New authStore field useCustomTitlebar (default: true)
- set_window_decorations Tauri command toggles native decorations at runtime
- Settings toggle visible only on Linux (no restart required)
- i18n keys added to EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:47:29 +02:00
Psychotoxical d49137475f feat(titlebar): custom Linux title bar with now-playing display
- set_decorations(false) on Linux at startup via #[cfg(target_os = "linux")]
- New TitleBar component: drag region + minimize/maximize/close buttons
- Currently playing track (▶/⏸ + artist – title) shown in the center
- app-shell grid gains a 32px titlebar row when data-titlebar is set
- IS_LINUX utility constant via navigator.platform
- macOS and Windows are completely unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:12:17 +02:00
Psychotoxical dd4e2f1162 docs: add v1.34.3 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:36:05 +02:00
Psychotoxical 78beb699a7 chore: bump version to 1.34.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:36:01 +02:00
Psychotoxical 6f63d7020c fix(radio): add radio-card CSS class to RadioCard component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:35:54 +02:00
Psychotoxical a606d1edd6 feat(themes): add Dracula theme to Open Source Classics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:35:48 +02:00
Psychotoxical 9ef8566d64 fix(discord): add Apple Music cover opt-in, show Paused state
- iTunes Search API calls are now gated behind enableAppleMusicCoversDiscord
  (default: false). No external requests to Apple are made unless the user
  explicitly enables the new opt-in toggle in Settings.
- When the toggle is flipped, Discord presence is immediately re-sent for
  the current track (coversSettingChanged bypasses the early-return guard).
- When paused, activity type switches to Playing (no auto-timer) and state
  text shows "Paused" instead of the artist name.
- New authStore field + setter; new Settings toggle shown only when Discord
  RPC is enabled; i18n keys added to all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:33:16 +02:00
Psychotoxical 4b8b6ae797 feat(most-played): add dedicated Most Played page (#86)
New sidebar item (TrendingUp icon, /most-played route) with:

- Top Artists section: derived by grouping frequent albums by artistId
  and summing playCount — no extra API calls needed. Shows up to 10
  artists as a responsive grid with avatar, rank and total play count.
- Top Albums section: paginated list using getAlbumList('frequent'),
  50 albums per batch with a "Load more" button. Each row shows rank,
  cover thumbnail (44px), album name, artist (navigates to artist
  page), year and play count.
- Sort toggle (most plays first / fewest plays first).

Also adds playCount?: number to SubsonicAlbum — Navidrome returns this
field in getAlbumList2?type=frequent but it was missing from the type.

Closes #86

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:00:07 +02:00
Psychotoxical 9319c40fde feat(fs-player): adaptive dynamic accent color from album cover
Extracts the most vibrant pixel from an 8×8 downscaled album cover via
the Canvas API and applies it as --dynamic-fs-accent on the .fs-player
root element.  All accent-colored FS player elements use
var(--dynamic-fs-accent, var(--accent)) as fallback so the theme accent
is restored automatically when the FS player closes.

Elements updated:
- .fs-track-title (color + text-shadow)
- .fs-btn.active, .fs-btn-heart.active/:hover
- .fs-btn-play and :hover (background + box-shadow)
- .fs-seekbar-played (background + box-shadow)
- .fs-mesh-blob-a/-b (radial-gradient via color-mix at 14%/8% opacity)
- .fs-art-wrap box-shadow glow (color-mix at 70% opacity)
- MicVocal lyrics-toggle inline style

Color safety: WCAG 4.5:1 contrast against near-black FS background
enforced by progressively lightening in HSL space (ensureContrast).

Pure math functions unit-tested with vitest (28 tests, 0 deps beyond
vitest itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:14:20 +02:00
Psychotoxical 63a3bcd0f4 feat(playlists): add ZIP download button to playlist detail page
Mirrors the album download UX: Download (ZIP) button in the playlist
hero header, progress bar while downloading, folder picker via
downloadModalStore (remembers last used folder). Uses the same
Subsonic /rest/download.view endpoint as album downloads — it accepts
playlist IDs just as well as album IDs.

Closes #127

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:00:49 +02:00
Psychotoxical a4c400cc69 fix(audio): correctly parse iTunSMPB gapless info from older iTunes M4A files
M4A files store the iTunSMPB value behind a 16-byte binary 'data' atom
header. The previous parser took bytes directly after the ASCII key,
landing in the binary header and producing a corrupted parts array
(total_valid=348 instead of ~13M samples), which caused take_duration
to exhaust the source in ~8ms — silent no-playback.

Fix: search for the " 00000000 " sentinel within 128 bytes of the key
to locate the actual value string, falling back to the old offset for
ID3v2/MP3 files where no binary header exists.

Also patches symphonia-format-isomp4 (local crate under patches/) to:
- Tolerate SL descriptor predefined=0x01 (older iTunes M4A)
- Convert descriptor.unwrap() panic to a proper decode error
- Gracefully skip malformed trak atoms (e.g. MJPEG cover-art streams)
  instead of failing the entire probe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:54:10 +02:00
Psychotoxical e4aad22c03 chore(aur): bump pkgver to 1.34.2, fix ring comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:59:33 +02:00
Psychotoxical 489d50016f docs: add v1.34.2 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:57:20 +02:00
Psychotoxical 47490072ef chore: bump version to 1.34.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:52:34 +02:00
Psychotoxical ff0a588014 feat(audio): add ALAC and AAC-LC native decoding via Symphonia
Add 'alac' to symphonia features. Combined with the existing 'aac' and
'isomp4' features this enables native lossless decoding of Apple Lossless
Audio Codec (.m4a/ALAC) and AAC-LC files without server-side transcoding.

Symphonia probes the format from the stream bytes, so no URL or hint
changes are needed for server streams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:34:00 +02:00
Psychotoxical e4041287e2 chore(contributors): add cucadmuh PR #125 to contributor list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:00:12 +02:00
Psychotoxical 70f0641439 feat(subsonic): per-server music folder filter and sidebar picker (#125)
Adds library scoping per server: users can select one Navidrome music
folder or 'All' from a new sidebar dropdown. Choice is persisted per
server ID in localStorage. A musicLibraryFilterVersion counter triggers
refetches across browsing pages when the scope changes.

- getMusicFolders() + libraryFilterParams() in subsonic.ts
- New fields in authStore: musicFolders, musicLibraryFilterByServer,
  musicLibraryFilterVersion, setMusicFolders, setMusicLibraryFilter
- Sidebar dropdown via createPortal; collapses to icon in narrow mode
- API scoping applied to: getAlbumList2, getRandomSongs, getArtists,
  getStarred2, search3
- Full i18n coverage (en, de, fr, nl, zh, nb, ru)

Review fixes applied (co-authored):
- Sidebar: title={} → data-tooltip + data-tooltip-pos='right'
- authStore: setMusicFolders merged into single set() call
- Locales: removed dead libraryScopeHint key from all 7 files
- Genres.tsx: removed musicLibraryFilterVersion dep (getGenres unscoped)

Co-authored-by: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:54:57 +02:00
Psychotoxical c779e8f587 fix(pr-review): address review findings for music folder filter
- Sidebar.tsx: title={} → data-tooltip + data-tooltip-pos="right" on
  scope subtitle (CLAUDE.md rule: never use native title= for tooltips)

- authStore.ts: merge two set() calls in setMusicFolders into one atomic
  update — avoids double render on folder list arrival

- locales (all 7): remove dead libraryScopeHint key (defined but never
  rendered in any component)

- Genres.tsx: remove musicLibraryFilterVersion from effect deps —
  getGenres is intentionally not folder-scoped, re-fetching on filter
  change was a no-op waste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:53:44 +02:00
Psychotoxical 95468fb137 refactor: remove Tauri auto-updater, replace with simple GitHub-check modal
Remove tauri-plugin-updater, relaunch_after_update command, generate-update-
manifest.js, the generate-manifest CI job, and all signing/upload steps for
.sig files and latest.json. The updater plugin, its pubkey and endpoint config
in tauri.conf.json, and the updater:default capability are all gone.

Replace AppUpdater.tsx with a lightweight component that:
- Fetches https://api.github.com/repos/Psychotoxical/psysonic/releases/latest
  after a 4 s delay (no install, no download, no progress bar)
- Shows a dismissible toast with two buttons:
  GitHub → releases/latest
  Website → https://psysonic.psychotoxic.eu/#downloads

i18n: remove updaterInstall/Downloading/Installing/Download/ExperimentalHint
keys from all 7 locales; add updaterWebsite; update updaterVersion wording.

CI: remove generate-manifest job and all .sig signing/upload steps from
build-macos-windows. Also remove TAURI_SIGNING_PRIVATE_KEY env vars (no
longer needed). Release now just builds and uploads the installable binaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:42:52 +02:00
Maxim Isaev c36b1ea538 fix(ui): dismiss tooltip on mousedown; hide library dropdown hint while open
TooltipPortal clears the overlay when mousedown hits any [data-tooltip] anchor so click-open controls (e.g. library scope) do not leave a stale hover tooltip. The library scope trigger omits data-tooltip while the dropdown is open.
2026-04-07 14:27:47 +03:00
Psychotoxical 3f1b6fd92d fix(audio): restore device-default rate when Hi-Res is toggled off
The previous optimization skipped all rate-switch logic in standard mode,
but left the stream at e.g. 88.2 kHz if Hi-Res was toggled off mid-session.

Fix: store device_default_rate (from cold-start open) in AudioEngine.
In audio_play, compute target_rate = hi_res ? output_rate : device_default_rate
and reopen only when target differs from current — no-op cost when already
at the right rate (the common case in pure standard mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:18:33 +02:00
Psychotoxical 1c2aa79e29 perf(audio): standard mode resource optimization — lower CPU at 44.1kHz
Stream switch: skip entirely when hi_res_enabled=false. Previously the
engine compared 44100 vs. device-default 48000 Hz and re-opened the device
on every track start — unnecessary IPC + PipeWire reconfigure in safe mode.

MSS buffer: 512 KB in standard mode (was 4 MB). For in-memory MP3/AAC
the large buffer caused eager allocation competing with playback startup.
4 MB is kept only for hi-res (high-bitrate FLAC benefits from fewer reads).

ThreadPriority: no longer set Max at audio-stream-thread startup. Max is
now applied only when a hi-res reopen is requested, keeping the scheduler
budget for the actual decode threads during standard playback.

audio_preload: 8 s throttle delay + gen guard so background pre-fetch does
not compete with the decode/sink-feed cycle of the just-started track.

needs_prefill: now gated on hi_res_enabled to make the condition explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:12:03 +02:00
Maxim Isaev f08619fb3d feat(subsonic): per-server music folder filter and sidebar picker
Apply musicFolderId across Subsonic requests, bump a filter version so library views reload, and add a fixed-position sidebar dropdown (with capped height when there are many folders).
2026-04-07 13:59:59 +03:00
Psychotoxical 5ec8fa8ba3 fix(fullscreen): remove letter-spacing from track title to fix optical misalignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:46:27 +02:00
Psychotoxical 51c118806e feat(fullscreen): track name on top, large & bold; artist name below, small
Swap DOM order and styles in the info cluster:
- fs-track-title: clamp(28px,4.5vw,68px) / weight 900 / var(--accent) — primary
- fs-artist-name: clamp(13px,1.5vw,22px) / weight 400 / 55% opacity — secondary
- fs-cluster gap: 12px → 8px (denser hierarchy)
- fs-meta: margin-top: 4px for breathing room after artist name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:44:32 +02:00
Psychotoxical fc653941c2 fix(lyrics): allow line wrapping in fullscreen lyrics overlay
Increase slot height from 3.6vh → 6vh (CSS + JS factor 0.036 → 0.06)
so that long lyric lines can wrap onto a second line without being clipped.

Fixed height on .fs-lyric-line is kept intentional — rail math requires
uniform slots. 2 wrapped lines at 2vh font / 1.4 line-height = 5.6vh,
comfortably inside the 6vh slot. font-size adjusted from 2.2vh → 2vh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:36:06 +02:00
Psychotoxical 8add62a502 perf(lyrics): fix CPU spikes during lyric transitions; fix release workflow
CSS: remove `font-weight` from transition list on `.fs-lyric-line` — it
triggers layout reflow on every animation frame when the active line changes.
Replaced with `transform: scaleX(1.015)` on `.fsl-active` (compositor-only).
Added `contain: layout style` to `.fs-lyrics-overlay` to isolate reflows.

Cargo.lock: update for thread-priority crate (added in previous audio commit).

CI: replace delete-asset workaround with `updaterJsonKeepUniversal: false`
to prevent tauri-action from uploading a latest.json with wrong signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:30:32 +02:00
Psychotoxical 44287a7ceb feat(audio): bit-perfect hi-res playback + underrun hardening (opt-in alpha)
Safe mode (default): all audio outputs at 44.1 kHz, rodio resamples
internally. Maximum stability out of the box.

Hi-Res mode (alpha toggle): stream opens at the file's native sample rate
(e.g. 88.2/96/192 kHz) for bit-perfect output.

Rust (audio.rs):
- open_stream_for_rate(): CPAL queries device supported configs, finds
  exact rate match or falls back to highest available / device default.
- create_engine() thread: ThreadPriority::Max (silently ignored without
  CAP_SYS_NICE), loop handles rate-switch requests, PIPEWIRE_LATENCY
  scales with rate (8192 frames for >48 kHz ≈ 93 ms quantum), keeps
  PULSE_LATENCY_MSEC in sync.
- audio_play(): hi_res_enabled param gates effective_rate (44100 vs
  native); stream re-opens only when needed; 150 ms PipeWire settle +
  500 ms sink pre-fill (pause→append→sleep→play) for hi-res tracks.
- audio_chain_preload(): hi_res_enabled param; rate-mismatch bail skipped
  in safe mode so gapless chains always work at 44.1 kHz.
- SizedDecoder MSS buffer: 4 MB (was 512 KB) to reduce Symphonia read
  overhead on high-bitrate hi-res files.
- thread-priority crate added to Cargo.toml.

Frontend:
- authStore: enableHiRes (bool, default false) + setEnableHiRes.
- playerStore: hiResEnabled passed to all audio_play /
  audio_chain_preload invoke calls.
- Settings → Audio tab: "Native Hi-Res Playback" toggle with Alpha
  badge and stability warning, i18n for all 7 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:20:07 +02:00
cucadmuh 0f3033d84e feat: add hot playback cache (queue prefetch, alpha)
Ephemeral on-disk cache for upcoming queue tracks. Adds Rust commands (download/delete/purge), hotCacheStore with LRU eviction, serial prefetch worker, playback gate, and Settings UI. Disabled by default.

- fix: boundary check before file delete in delete_hot_cache_track
- fix: remove stale languageRu2 key from ru.ts
- fix: restore accidentally removed lyricsServerFirst/fsLyricsToggle/lyricsSource* strings in ru.ts
2026-04-07 10:52:26 +02:00
Psychotoxical d49af977ed docs: add v1.34.1 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:12:48 +02:00
Psychotoxical d73f348339 feat: v1.34.1 — fullscreen lyrics overlay, FsArt crossfade, lyrics toggle, contributor updates
- FullscreenPlayer: synced lyrics overlay (5-line rail, mask fade, click-to-seek)
- FullscreenPlayer: FsArt crossfade component with onLoad pattern, 300px art, next-track pre-fetch
- FullscreenPlayer: MicVocal lyrics toggle button next to heart (persisted in authStore)
- FullscreenPlayer: idle system — only close button auto-hides, cluster+seekbar always visible
- LyricsPane: refactored to use shared useLyrics hook (no double-fetch with FS overlay)
- authStore: showFullscreenLyrics field (default true)
- i18n: fsLyricsToggle key added to all 7 locales; ru2.ts removed (merged into ru.ts)
- Settings: Contributors updated (nisrael, cucadmuh, kilyabin); logout moved to Server tab as btn-danger
- theme.css: btn-danger style added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:00:33 +02:00
Psychotoxical 390e6e788d chore: bump version to 1.34.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:59:32 +02:00
Psychotoxical ff950efb0c feat(settings): add nisrael to contributors, update cucadmuh/kilyabin, move logout button
- Add nisrael (Nightfox themes PR #114, rustls-tls fix PR #112)
- Update cucadmuh with gapless manual skip fix (PR #119)
- Update kilyabin with RU locale improvements (PR #120) and auto-install script (PR #121)
- Move logout button from System tab to Server tab — styled as btn-danger (outlined red)
- Add .btn-danger CSS class to theme.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:54:27 +02:00
Psychotoxical 4ef21d6d78 i18n(ru): apply translation improvements from PR #120
Manually merged kilyabin's Russian locale improvements (more natural phrasing
throughout) while preserving keys added after #120 was opened: fsLyricsToggle,
lyricsServerFirst/Desc, lyricsSourceServer/Lrclib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:46:41 +02:00
kilyabin 7fc0550b65 feat: auto-install script for Debian/RHEL 2026-04-06 22:39:19 +02:00
cucadmuh 5d06738ce1 fix(audio): gapless manual skip — honor user-initiated play over pre-chained track
* fix(audio): honor manual skip when next track is gapless-pre-chained

* fix(audio): match gapless chain and preload by stream id, not full URL
2026-04-06 22:39:16 +02:00
Psychotoxical 1197c1f916 feat: album cover art in Discord Rich Presence via iTunes Search API
Fetches album artwork from the iTunes Search API and passes the URL
directly to Discord's large_image field. Subsonic cover URLs require
auth and can't be used by Discord directly.

- 3-strategy search: exact quoted → relaxed → track title fallback
- 1-hour in-memory cache per artist|album key
- iTunes search runs in tokio::task::spawn_blocking (reqwest::blocking
  must not run on the Tokio async executor)
- artwork_cache wrapped in Arc<Mutex<...>> for cross-thread sharing
- Fallback to the pre-uploaded "psysonic" asset when no match found
- Cargo.toml: blocking feature alongside rustls-tls

Co-Authored-By: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:30:22 +02:00
Psychotoxical b448c2bc82 fix(queue): revert auto-scroll target back to queueIndex+1
The queue-current-track section already shows the active track above the
list. Scrolling to queueIndex+1 (next track) is the correct behavior —
it keeps the upcoming tracks in view, not the already-visible current one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:23:55 +02:00
cucadmuh 6226383762 i18n(ru): refine Russian locale using phrasing from ru2
Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-06 19:13:47 +02:00
nisarg 13a8f696d0 fix: queue auto-scroll to current track and fix re-renders
* stop queue panel from constant rerendering

* add queueHeader type

* fix(queue): move hook before early return, fix auto-scroll target

- usePlayerStore must be called before conditional return (Rules of Hooks)
- Auto-scroll to songs[queueIndex] not [queueIndex+1] — current track,
  not the next one

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:11:03 +02:00
Nils Israel 64e0948904 feat: integrate nightfox.nvim themes into Open Source Classics
* feat: integrate nightfox.nvim themes into Open Source Classics

Agent-Logs-Url: https://github.com/nisrael/psysonic/sessions/410b9047-62de-4c0b-a6bc-1dc6f8247164

Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>

* i18n: add Nightfox to theme FAQ in de + zh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nisrael <66925+nisrael@users.noreply.github.com>
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:09:39 +02:00
Nils Israel 10b2bde5ef fix: switch reqwest to rustls-tls for cross-platform TLS compatibility
The native-tls backend (macOS Security framework) was failing with
'bad protocol version' when connecting to HTTPS music servers.
Switching to rustls (statically linked, no system dependency) fixes
playback on macOS and ensures consistent TLS 1.2/1.3 support across
all platforms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 19:06:55 +02:00
Psychotoxical 1c92f9ed74 docs: add Discord community link to README 2026-04-06 13:23:57 +02:00
Psychotoxical 443e0c0675 fix(ci): remove redundant tauri-action release params to prevent duplicate drafts 2026-04-06 13:18:03 +02:00
Psychotoxical 9cc8cfe087 fix(ci): force bash shell on Windows for gh release step 2026-04-06 13:00:43 +02:00
Psychotoxical 7ac417fbfc chore: bump AUR PKGBUILD to 1.34.0 2026-04-06 12:51:08 +02:00
Psychotoxical 50ac1b8284 feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:46:13 +02:00
Psychotoxical 29203803de feat(i18n): Russian locale and translations in src/locales
feat(i18n): Russian locale and translations in src/locales
2026-04-06 10:42:45 +02:00
Maxim Isaev 83c36de091 feat(i18n): Russian locale and translations in src/locales
- Add Russian UI strings with idiomatic phrasing and plural forms
- Split en/de/fr/nl/zh/nb strings into src/locales/*.ts for maintainability
- Register ru in i18n and Settings language picker
- Add languageRu to all locales; extend English help (supported languages)

Made-with: Cursor
2026-04-06 11:08:40 +03:00
Psychotoxical 1d0965708b Update README.md 2026-04-06 02:07:23 +02:00
Psychotoxical 2b5416f423 Update README.md 2026-04-06 02:07:04 +02:00
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
Psychotoxical 9be0d8dfa9 feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:34:26 +02:00
Psychotoxical 67f31b0700 fix: include InternetRadio.tsx (referenced in App.tsx router) 2026-04-04 02:09:19 +02:00
Psychotoxical c873880a26 feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:04:14 +02:00
Psychotoxical 463b7483fd fix: wrap next/previous in arrow functions to satisfy MouseEventHandler type 2026-04-03 15:00:36 +02:00
Psychotoxical 3d11ef91a1 chore: bump AUR pkgver to 1.30.0 2026-04-03 14:54:59 +02:00
Psychotoxical c365140870 feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix
- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:41 +02:00
Psychotoxical 651b3cb050 Update README.md 2026-04-03 12:02:34 +02:00
Psychotoxical e2ee9247ad chore: attribute contributions from JulianNymark and nisarg-78
JulianNymark contributed OGG/Vorbis support, audio error toasts, and
human-readable error messages (PRs #42, #43, #44). nisarg-78 contributed
QoL and UI improvements (PR #38). Changes were incorporated manually
due to conflicts at the time of merge.

Co-Authored-By: JulianNymark <819074+JulianNymark@users.noreply.github.com>
Co-Authored-By: nisarg-78 <84626554+nisarg-78@users.noreply.github.com>
2026-04-02 22:45:58 +02:00
Psychotoxical 74df7b6b88 Update CHANGELOG.md 2026-04-02 22:36:36 +02:00
Psychotoxical 27a6693c8c Update CHANGELOG.md 2026-04-02 22:28:35 +02:00
Psychotoxical a932e7c2db chore: bump to v1.29.0 — release prep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:24:41 +02:00
Psychotoxical 7263d93d42 feat: v1.29.0 — Radio fast-start, seek fix, OGG, error toasts, contributors
Incorporates PRs #38 (nisarg-78), #42 #43 #44 (JulianNymark) with fixes:

Audio / Playback
- Artist Radio starts immediately from fast getTopSongs (local library);
  getSimilarSongs2 (Last.fm) enriches queue in background — no more wait
- Fix seek audio glitch: EqualPowerFadeIn only resets to zero-gain on
  seeks to track start (<100 ms); all other seeks resume at unity gain
- OGG/Vorbis container support via symphonia-format-ogg (PR #42)
- Human-readable audio error messages in SizedDecoder (PR #44)
- Audio playback errors shown as themed toast notifications (PR #43)

Queue / Radio
- Infinite Queue: proactive load of 5 tracks (was 25) when ≤2 remain
- Radio: proactive reload at ≤2 remaining tracks, independent of
  Infinite Queue setting — radio no longer stops at last track
- Fix: clicking Start Radio multiple times no longer stacks duplicates
- Fix: Start Radio on artist keeps current song playing
- Manual tracks always appear before Radio, Radio before Auto-added
- Queue separators: "— Radio —" and "— Auto —" dividers
- Fix: radio proactive load now works even when songs lack artistId
  (uses currentRadioArtistId module var as fallback)

UI / UX
- Click synced lyrics lines to seek (PR #38)
- Volume scroll wheel on volume slider (PR #38)
- Lyrics: active / completed / upcoming visual states (PR #38)
- Shared toast utility (src/utils/toast.ts) replaces inline toast fn
- Auto-updater: relaunch_after_update Rust command exits first to
  release single-instance lock before spawning new process

About / Credits
- nisarg-78 and JulianNymark added to contributors (since v1.29.0)
- netherguy4 added as Special Thanks for feature ideas and feedback
- i18n: aboutSpecialThanksLabel in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:17:55 +02:00
Psychotoxical 95283d792b feat: v1.28.0 — Infinite Queue, Start Radio, Single-click Play, Performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:40:39 +02:00
Psychotoxical 53d5888ebf chore: bump AUR pkgrel to 1.27.4-2 (CFLAGS fix rollout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:25:31 +02:00
Psychotoxical cad4338324 fix(aur): unset CFLAGS/CXXFLAGS to fix ring LTO link failure on CachyOS
CachyOS makepkg.conf sets -flto=auto in CFLAGS. ring's build.rs uses the
cc crate to compile its C/asm objects, which picks up CFLAGS, producing
fat-LTO objects. bfd cannot resolve ring_core_* symbols from fat-LTO
objects when linking against non-LTO Rust rlibs.

Also adds nasm to makedepends (required by ring 0.17.x for x86_64 asm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:41:34 +02:00
Psychotoxical f9bc67cb77 fix(aur): append -fuse-ld=bfd to RUSTFLAGS — substitution was no-op on CachyOS 2026-04-02 11:54:50 +02:00
Psychotoxical 74c75d83ca fix(aur): override RUSTFLAGS to swap lld→bfd for ring linker error
makepkg.conf on Arch injects -fuse-ld=lld into RUSTFLAGS which overrides
.cargo/config.toml target rustflags. PKGBUILD now explicitly patches
RUSTFLAGS at env level so bfd is used regardless of system config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:44:00 +02:00
Psychotoxical 005abae97d fix: explicit Windows NSIS updater bundle signing step
tauri-action uploads the .exe but doesn't create .nsis.zip/.sig because
the bundler doesn't pick up TAURI_SIGNING_PRIVATE_KEY automatically.
Added a PowerShell step that zips the exe, signs it via tauri signer,
and uploads .nsis.zip + .nsis.zip.sig — mirroring the macOS approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:23:05 +02:00
Psychotoxical a9c20dfbdf chore: bump to v1.27.3 — CI fixes, ring linker fix, draft releases
- fix: CI Windows NSIS upload — let tauri-action handle artifact upload directly
- fix: Linux/AUR ring linker error — cc + -fuse-ld=bfd via .cargo/config.toml
- fix: releases now created as draft for review before publishing
- chore: consolidate 1.27.0–1.27.2 changelog into single entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:06:18 +02:00
Psychotoxical 0b5db172bd chore: bump to v1.27.2 — bugfix release
- fix: Radio from context menu passed artist name as ID to getSimilarSongs2 (closes #29)
- fix: CI Windows NSIS upload — tauri-action no longer searches missing bundle/msi/
- fix: Linux/AUR ring linker error — use cc instead of rust-lld via .cargo/config.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:38:26 +02:00
Psychotoxical bf99a64baf fix: guard against empty NSIS bundle in Windows signing step
tauri-action already signs and uploads the .nsis.zip.sig when
TAURI_SIGNING_PRIVATE_KEY is set, so the manual find returns empty
and crashed with "a value is required for <FILE>". Exit 0 cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 03:23:17 +02:00
Psychotoxical 523596e414 chore: bump AUR pkgver to 1.27.1 2026-04-02 03:08:52 +02:00
Psychotoxical 2fe35e3f9b fix: v1.27.1 — CI signing pipeline, sig naming, npm+cargo caching 2026-04-02 03:07:19 +02:00
Psychotoxical e9fa541933 chore: add npm + cargo caching to all CI jobs (netherguy4 #28) 2026-04-02 02:54:58 +02:00
Psychotoxical 746aa69405 fix: explicit bundle signing step after tauri-action 2026-04-02 02:53:08 +02:00
Psychotoxical 205b2c1914 fix: signing env at job level, fix macOS bundle filenames in manifest 2026-04-02 02:38:53 +02:00
Psychotoxical 0086b3e310 chore: bump AUR pkgver to 1.27.0 2026-04-02 02:15:26 +02:00
Psychotoxical 55e7cb835b feat: v1.27.0 — In-App Auto-Update, Configurable Home, Icon Consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 02:14:57 +02:00
Psychotoxical d8da511a8f fix: v1.26.1 — hero/album/playlist background flickering
Background of Hero carousel, Album Detail and Playlist Detail was
flickering for up to 20 seconds on first visit. useCachedUrl with
fallbackToFetch=true returned the raw server URL immediately, causing
a double render — once with the HTTP URL and again when the blob was
ready. Fixed by passing fallbackToFetch=false in all three locations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:20:15 +02:00
Psychotoxical 434ee0ecf1 feat: v1.26.0 — Bulk Select, Song Info, Favorite Button, Recently Played
### Added
- Favorite/Star button in player bar (requested by @halfkey)
- Bulk multi-select for album tracklist and playlist detail (add to playlist, remove from playlist)
- Song Info modal via right-click context menu (metadata: format, bitrate, sample rate, bit depth, channels, file size, path, replay gain)
- Recently Played section on Home page
- "Show activity in Now Playing" opt-in toggle in Settings → Behavior

### Fixed
- Queue cover art not updating on track change (useCachedUrl/CachedImage reset on cacheKey change)
- FullscreenPlayer background flickering (FsBg preloads image before layer transition)
- Playlist card delete confirmation visual (size expansion + pulse animation)
- Gruvbox Light Soft: back button and badge invisible against light background

### Changed
- buildStreamUrl: removed unused suffix parameter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:57:57 +02:00
Psychotoxical 7d1c66071e Update CHANGELOG.md 2026-04-01 17:28:50 +02:00
Psychotoxical 1adfda1daa fix: remove invalid single-instance capability + dead static tray icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:51:50 +02:00
Psychotoxical e65c476a76 fix: v1.25.1 — Opus playback, single-instance enforcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:45:37 +02:00
Psychotoxical 560349819f feat: v1.25.0 — Tray Icon, Minimize to Tray, Sidebar Customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:46:41 +02:00
266 changed files with 75629 additions and 23330 deletions
+12
View File
@@ -0,0 +1,12 @@
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
# bfd is always available via binutils (part of base-devel on Arch).
#
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
# overrides target-specific rustflags here. The PKGBUILD's build() applies
# the same fix by patching the RUSTFLAGS env var directly.
[target.x86_64-unknown-linux-gnu]
linker = "cc"
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: psychotoxic
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+166 -6
View File
@@ -19,6 +19,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
@@ -65,7 +66,7 @@ jobs:
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body,
draft: false,
draft: true,
prerelease: false
});
return data.id;
@@ -82,8 +83,10 @@ jobs:
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: '--bundles nsis'
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
# Re-add before the next public release.
# - platform: 'windows-latest'
# args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
@@ -91,22 +94,98 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
# Apple signing + notarization (macOS runner only — ignored on Windows)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
# Tauri Updater signing — produces .sig files alongside the update bundles
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
# done, which invalidates the .sig tauri CLI created (different hash).
# We can't stop the repack (it's tied to includeUpdaterJson), so we
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
VERSION=${{ needs.create-release.outputs.package_version }}
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "app-v${VERSION}" \
"Psysonic_${ARCH}.app.tar.gz.sig" \
--clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
build-linux:
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
# Re-enable by removing `if: false` before the next public release.
if: false
needs: create-release
permissions:
contents: write
@@ -119,16 +198,22 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
@@ -136,7 +221,8 @@ jobs:
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
@@ -144,5 +230,79 @@ jobs:
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
# Verifies that `nix build .#psysonic` still works against the current source,
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
# without having to compile locally.
#
# The refreshed lock/hash files are committed back to `main` when they change.
verify-nix:
# TEMPORARILY DISABLED during the v1.34.x testing iteration (signing +
# updater stabilisation). Remove `if: false` to re-enable — it auto-commits
# flake.lock / npmDepsHash refreshes to main on every release, which causes
# rebase friction while we're iterating fast on the release pipeline.
if: false
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
# Full history so we can push the auto-commit back to the default branch.
fetch-depth: 0
# Checkout main, not the tag — we want to push lock/hash refreshes to
# the moving branch, not the immutable tag ref.
ref: main
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
# server-side). The action watches the nix store during subsequent build
# steps and uploads new paths automatically.
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
cat nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
- name: commit + 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="${{ needs.create-release.outputs.package_version }}"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin HEAD:main
+8
View File
@@ -31,6 +31,7 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
# Documentation
CLAUDE.md
@@ -40,3 +41,10 @@ memory/
# Local scratchpad / notes (not committed)
tmp/
# Third-party clones for local research (not committed)
research/
# Nix build output symlink
result
result-*
+828
View File
@@ -5,6 +5,834 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **⚠️ Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
>
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
## [1.34.16] - 2026-04-18
### Fixed
- **CI — Updater signature upload** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: tauri-action on macOS produces the `.app.tar.gz.sig` minisign signature locally but does not upload it as a release asset for cross-target builds, which caused the `latest.json` manifest generator to fail (no signature to embed). An explicit post-build step now finds the `.sig` under `src-tauri/target/*/release/bundle/macos/` and uploads it with the expected filename (`Psysonic_aarch64.app.tar.gz.sig` / `Psysonic_x64.app.tar.gz.sig`).
## [1.34.15] - 2026-04-18
### Added
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
## [1.34.14] - 2026-04-18
### Added
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
### Fixed
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
### Changed
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
---
## [1.34.13] - 2026-04-17
### Added
- **YouLyPlus — word-by-word synced lyrics (karaoke)** *(Issue [#172](https://github.com/Psychotoxical/psysonic/issues/172), by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → Lyrics now exposes a mode toggle between the existing **Standard** pipeline (Server tags + LRCLIB + Netease, configurable order) and a new **YouLyPlus** mode that fetches karaoke-style word-sync lyrics from the public `lyricsplus` aggregator (Apple Music / Spotify / Musixmatch / QQ Music). When a track has no YouLyPlus entry the app silently falls back to the Standard pipeline, so obscure titles still resolve. Active word highlighting in both the sidebar Lyrics pane and the Fullscreen Player. Five backend mirrors are tried on network failure; no API keys on the user side — subscription costs are borne by the lyricsplus operator.
- **Static-only lyrics option** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A new toggle renders synced lyrics as plain static text — no auto-scroll, no word highlighting — for users who prefer to read rather than follow. Works in both Standard and YouLyPlus modes.
- **Discord Rich Presence — collapsible advanced options** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The *Fetch covers from Apple Music* toggle and the *Custom text templates* form are now tucked under a single collapsible **Advanced Discord options** header (default collapsed) that only appears when Discord Rich Presence is enabled. Reduces vertical noise in Settings → General for the common case.
### Fixed
- **macOS — spurious microphone permission prompt (real fix)** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The 1.34.12 attempt of removing `NSMicrophoneUsageDescription` did not actually suppress the prompt — on modern macOS, TCC fires at AudioUnit instantiation time, not at Info.plist level. Root cause: `cpal` (via `rodio`) instantiates an `AUHAL` output unit (`IOType::HalOutput`), which macOS classifies as input-capable even for playback-only apps. Psysonic now ships a vendored `cpal 0.15.3` at `src-tauri/patches/cpal-0.15.3/` wired via `[patch.crates-io]`; the patch forces `IOType::DefaultOutput` for all output streams, which never touches input and never triggers the mic dialog. **Tradeoff:** per-device output selection is a no-op on macOS — the stream always follows the system default (change via System Settings → Sound or the menu-bar speaker icon). Matches the behaviour of Apple Music and Spotify on macOS. Settings surfaces this with an explanatory notice on macOS and hides the device picker there.
---
## [1.34.12] - 2026-04-17
### Added
- **Playback source indicator in Queue** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: The current-track tech strip in the Queue panel now shows a **source badge** indicating how the track was loaded: `stream` (live from server), `preloaded` (buffered before playback), or `cache` (served from local hot cache). Preload tracking is wired through the Rust audio engine so the badge reflects actual playback origin, not just current state.
- **ReplayGain metadata in Queue tech strip** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#196](https://github.com/Psychotoxical/psysonic/pull/196))*: The current-track tech strip now shows track and album ReplayGain values alongside bitrate and format when the file contains gain tags.
- **Discord Rich Presence enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#198](https://github.com/Psychotoxical/psysonic/pull/198))*: Discord Rich Presence received several improvements: dead/unused fields removed, the `{paused}` placeholder that Discord does not support was dropped, and a `timeChanged` invoke loop that fired redundantly on every progress tick was eliminated. The DRP timer is now accurate and stable.
- **Context menu in Search results** *(contributed by [@kveld9](https://github.com/kveld9), PR [#191](https://github.com/Psychotoxical/psysonic/pull/191))*: Song rows in the Search panel now support the full right-click context menu (Play, Queue, Playlist, etc.) — previously search results were click-only with no context actions.
- **Spotify CSV playlist import** *(contributed by [@kveld9](https://github.com/kveld9), PR [#190](https://github.com/Psychotoxical/psysonic/pull/190))*: Playlists exported from Spotify as CSV can now be imported directly into Psysonic. Tracks are matched by ISRC when available, with title/artist fallback. Unmatched tracks are listed in a report after import. Duplicate checking is done before writing.
- **CLI completions and expanded player controls** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#187](https://github.com/Psychotoxical/psysonic/pull/187))*: The `psysonic` CLI gains shell completions for bash/fish/zsh/elvish, new subcommands for library browsing and audio device listing, a server switcher command, and an opaque play-ID scheme for stable track references. The tray icon on Linux no longer requires `libayatana-appindicator` / `libindicator` — it falls back gracefully when the library is absent.
- **Albums and Playlists header redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#186](https://github.com/Psychotoxical/psysonic/pull/186))*: The header sections on the Albums and Playlists pages have been redesigned for a cleaner, more consistent layout.
- **Favorites page redesign** *(contributed by [@kveld9](https://github.com/kveld9), PR [#184](https://github.com/Psychotoxical/psysonic/pull/184))*: The Favorites page has been overhauled with sortable columns, a gender filter, an age range filter, and additional metadata columns.
- **Split Mix navigation mode** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A new toggle in Settings switches the Mix section between a single **Build a Mix** hub entry and **two separate sidebar entries** — Random Mix and Random Albums — for users who prefer direct access. Navigation items are now defined in `src/config/navItems.ts`; the toggle is stored as `randomNavMode` in authStore.
- **Device Sync improvements** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Device Sync received several updates: a JSON manifest is now written to the device root on every sync (and read back automatically when the device is mounted); a **Cancel** button interrupts a running sync cleanly; a font picker was added to the sync page; sync status display was fixed; and the filename template builder now works correctly on all platforms.
- **Radio — ICY StreamTitle forwarded to MPRIS** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: While playing internet radio, the current song title parsed from ICY `StreamTitle` metadata is now forwarded to MPRIS `xesam:title` on Linux so that the track name appears in desktop notification shells and media controls.
- **Help page — expanded coverage** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added missing help sections covering Device Sync, Internet Radio, CLI usage, Playlists, Infinite Queue, Lyrics sources, Audio device selection, Backup & Restore, and Now Playing details.
- **Tracklist column reset and privacy policy** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: A reset button in the tracklist column picker restores the default column set. The Device Sync page received a cross-platform filename template fix. A privacy policy page was added documenting data usage for Last.fm, LRCLIB, NetEase, and Discord.
### Fixed
- **Streaming playback stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#200](https://github.com/Psychotoxical/psysonic/pull/200))*: Several edge cases in the Rust audio engine around stream start, mid-track seeking, and track transitions were hardened. Cache promotion (moving a preloaded track into the hot cache) is now safer under concurrent access. Stream decoder errors during transitions no longer leave the engine in a stuck state.
- **CSV import reliability** *(contributed by [@kveld9](https://github.com/kveld9), PR [#199](https://github.com/Psychotoxical/psysonic/pull/199))*: The CSV import pipeline now guards the `ISRC` field type before calling `toUpperCase`, preventing a crash on rows with numeric or null ISRC values. The playlist public/private toggle in the edit modal (accidentally removed during a post-merge fix) is restored.
- **Tracklist column picker** *(contributed by [@kveld9](https://github.com/kveld9), PR [#188](https://github.com/Psychotoxical/psysonic/pull/188) and PR [#192](https://github.com/Psychotoxical/psysonic/pull/192))*: Fixed a column picker overflow where the dropdown was clipped by the tracklist container. Also fixed column toggle state and alignment issues in the picker UI. An `overflow-x: visible` regression introduced in PR #188 was subsequently reverted.
- **macOS — spurious microphone permission prompt** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Removed `NSMicrophoneUsageDescription` from `Info.plist`. It was inherited from an earlier Tauri template but Psysonic never uses the microphone; its presence caused macOS to show a permission dialog on first launch.
- **Device Sync — auto-import and disconnect cleanup** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The sync manifest is now automatically imported when the Device Sync page is opened if a device with a manifest is already mounted. The sync file list is cleared when the device is disconnected.
- **Audio — streaming decoder log labels** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#201](https://github.com/Psychotoxical/psysonic/pull/201))*: Rust log lines from the streaming decoder are now tagged with the source type, making it easier to distinguish stream vs. local decode paths in debug output.
- **Theme — Latte and GTA readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Improved contrast and text readability in the Catppuccin Latte and GTA themes.
- **i18n — missing `common.play` key** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Added the `common.play` translation key to all 8 locales; it was missing after PR #186 which introduced its usage.
### Removed
- **Waveform seekbar — realtime waveform style** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The `realtime_waveform` CSS class and its associated style block were removed. This style was applied during live streaming and produced a low-quality rendering mode that was no longer needed after the streaming architecture improvements.
---
*Thank you to everyone who contributed to this release:*
*[@cucadmuh](https://github.com/cucadmuh) for the playback source indicator, ReplayGain in the tech strip, streaming stability hardening, and CLI improvements — four substantial PRs.*
*[@kveld9](https://github.com/kveld9) for the CSV import, search context menu, Discord RP enhancements, Favorites redesign, and header redesign — a very productive cycle.*
---
## [1.34.11] - 2026-04-14
### Added
- **Opus audio playback** *(Issue [#180](https://github.com/Psychotoxical/psysonic/issues/180), contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#183](https://github.com/Psychotoxical/psysonic/pull/183))*: Psysonic can now decode Opus audio natively via `symphonia-adapter-libopus`, which bundles and compiles libopus from source. Previously `.opus` files were sent to the server for transcoding — a workaround that never worked reliably. Native decoding is now used directly; the server is no longer involved. Note: building from source requires `cmake` to be installed (see README).
- **Device Sync — synchronise your library to USB and SD card players** *(Issue [#161](https://github.com/Psychotoxical/psysonic/issues/161), by [@Psychotoxical](https://github.com/Psychotoxical))*: A fully overhauled Device Sync page lets you copy music from your Navidrome library to any mounted USB drive or SD card. Browse albums via live search (300 ms debounce) or a random album selection. Choose a filename template (Artist/Album/Track format), pick a target folder, and review a pre-sync summary showing files to add, files to delete, and available space — including a warning if the device would run out of space after accounting for pending deletions. Already-synced files are detected and skipped automatically so incremental syncs are fast.
- **3 visual toggles** *(contributed by [@kveld9](https://github.com/kveld9), PR [#181](https://github.com/Psychotoxical/psysonic/pull/181))*: Three new toggles in Settings → Appearance:
- **Cover art background** — enables/disables the blurred album art background in Album Detail and the Hero section.
- **Playlist cover photo** — shows/hides the cover collage at the top of Playlist Detail pages.
- **Show bitrate badge** — toggles the bitrate label displayed on tracks in the queue and track lists.
- **8 community themes** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: A new **Community** theme group appears directly below Psysonic Themes in the Theme Picker, containing eight new themes: **AMOLED Black Pure** (pure black for OLED), **Monochrome Dark** (grayscale), **Amber Night** (warm golden amber), **Phosphor Green** (classic terminal green), **Midnight Blue** (deep blue), **Rose Dark** (pink/rose accents), **Sepia Dark** (warm cream sepia), and **Ice Blue** (cool cyan). Psysonic now ships with 75 themes across 9 groups.
### Fixed
- **HTTPS streaming failures and server URL trailing slash** *(Issue [#178](https://github.com/Psychotoxical/psysonic/issues/178), by [@Psychotoxical](https://github.com/Psychotoxical) with fix ported from PR [#179](https://github.com/Psychotoxical/psysonic/pull/179) by [@kveld9](https://github.com/kveld9))*: Two bugs that broke HTTPS server connections are now fixed. A trailing slash in the configured server URL caused double-slash stream URLs (`//rest/stream.view`) that reverse proxies like Caddy would reject, and also caused album browsing to return 0 results. Additionally, `reqwest` now loads the OS native certificate store alongside Mozilla's root store — fixing HTTPS streaming failures when the server certificate is signed by a local CA (e.g. Caddy's internal CA) that is trusted in the system keychain but not in Mozilla's bundle.
- **Server display in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The server list in Settings → Servers now shows the URL and username on separate lines instead of a single truncated `username@url` string. Protocol prefixes (`http://`, `https://`) are stripped for cleaner display. HTTPS connections show a green lock icon.
### Changed
- **Waveform seekbar — live theme updates** *(contributed by [@kveld9](https://github.com/kveld9), PR [#182](https://github.com/Psychotoxical/psysonic/pull/182))*: The canvas-based seekbar now listens for `data-theme` attribute changes via `MutationObserver` and redraws immediately with the new theme colours. Switching themes no longer requires an app restart to update the waveform.
---
*Thank you to everyone who contributed to this release:*
*[@cucadmuh](https://github.com/cucadmuh) for implementing native Opus decoding — a long-requested feature that finally makes `.opus` libraries fully playable.*
*[@kveld9](https://github.com/kveld9) for three PRs in one release: the SSL/trailing-slash fix, visual customisation toggles, and eight new community themes with a live waveform update fix.*
---
## [1.34.10] - 2026-04-13
### Added
- **AppImage bundle for Linux** + X11/XWayland enforcement on all Linux packages: CI now builds `.AppImage` in addition to `.deb` and `.rpm`. `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` are set automatically at startup on all Linux packages — WebKitGTK on Wayland is unstable. Both environment variables are still overridable by setting them before launch.
- **Audio output device selection** *(Issue [#169](https://github.com/Psychotoxical/psysonic/issues/169))*: Settings → Audio now shows a dropdown of all available output devices. The current OS default is pinned at the top with a label; a Refresh button re-enumerates silently. A device watcher detects hot-plug events and emits `audio:device-reset` after ~9 s of consecutive misses, preventing false positives on busy ALSA devices. On Linux, technical ALSA prefixes are stripped for display (`sysdefault:CARD=U192k``U192k`).
- **Vision Dark & Vision Navy — colorblind-safe themes** *(Issue [#166](https://github.com/Psychotoxical/psysonic/issues/166))*: Two new themes using a Purple & Gold palette designed to be safe for Deuteranopia, Protanopia, and Tritanopia. Vision Dark pairs near-black `#0D0B12` with Gold `#FFD700` (~14.7:1 WCAG AAA); Vision Navy uses deep navy `#0A1628` + Gold (~14.5:1 WCAG AAA). Both appear under a new **Accessibility** group in the Theme Picker. These themes are a first step toward proper colorblind support and will be revised and expanded in upcoming releases — structural improvements such as secondary indicators and pattern/shape cues are still on the roadmap.
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
- **Keybindings — in-app modifier chords** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 01 progress fraction.
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
### Fixed
- **Fullscreen Player — animation overhead in no-compositing mode** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#175](https://github.com/Psychotoxical/psysonic/pull/175))*: In software-rendering mode (`WEBKIT_DISABLE_COMPOSITING_MODE=1`) the mesh blob pan animations are now stopped (static gradients are preserved), the portrait drift animation is stopped, and `box-shadow` is removed from the seekbar played bar. The seekbar played bar width changes on every playback tick; triggering a full shadow repaint in software mode caused significant CPU overhead.
- **Folder Browser — arrow keys with modifier keys** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#174](https://github.com/Psychotoxical/psysonic/pull/174))*: Column and list arrow-key handling is now skipped when any modifier key is held, preventing conflicts with browser focus navigation and OS-level shortcuts. Modifier detection uses both `nativeEvent` and `getModifierState` for WebKit/WebView2 compatibility.
- **Audio output device — Linux stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#176](https://github.com/Psychotoxical/psysonic/pull/176))*: Pinned ALSA/cpal device IDs now stay stable when enumeration temporarily omits the active sink or returns an equivalent name. The Linux device-watcher no longer clears the pin based solely on missing list entries — only macOS and Windows treat repeated absence as "device unplugged". The Settings refresh flow calls `canonicalize` and refetches the list; an i18n label is now shown when the active device is no longer in the enumerated list.
- **Login — server URL field** *(Issue [#171](https://github.com/Psychotoxical/psysonic/issues/171))*: The placeholder text in the Add Server form was previously a hardcoded English string. It is now fully localised and clarifies that `https://` URLs are accepted.
- **Offline mode — non-blocking banner** *(Issue [#170](https://github.com/Psychotoxical/psysonic/issues/170))*: The full-screen blocking overlay shown when Psysonic starts without a cached library is replaced with a slim banner at the top of the content area. The banner includes a direct link to Server Settings so the user can fix the connection without navigating manually.
---
*Special thanks to everyone who contributed to this release:*
*[@cucadmuh](https://github.com/cucadmuh) for the significant Folder Browser improvements, the modifier-chord keybindings and and the Linux audio stability fixes — four PRs in one release cycle, remarkable.*
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
*[@kveld9](https://github.com/kveld9) for the playlist management overhaul.*
---
## [1.34.9] - 2026-04-12
### Added
- **Multi-select in Playlist Detail & Favorites** *(Issue [#157](https://github.com/Psychotoxical/psysonic/issues/157))*: The same Ctrl/Cmd+Click multi-select system that was previously exclusive to album track lists is now available everywhere. Hold Ctrl (or ⌘ on macOS) to enter select mode, Shift+Click to range-select, click the header checkbox to toggle all. Selected tracks can be dragged as a group directly into the queue. A bulk action bar appears with **Add to Playlist** and **Clear selection** options. Works in Playlist Detail (main tracklist) and in the Favorites song list.
- **"Open Artist" in context menu**: Song context menus now show an **Open Artist** entry directly below **Open Album**, navigating to the artist detail page. Previously only accessible via the tracklist artist link.
- **"Add to Playlist" for Artists**: The context menu for artists now includes an **Add to Playlist** submenu. Psysonic fetches all albums from the artist and collects every track, then forwards them to the playlist picker — identical to the existing album-level submenu.
- **Infinite queue — Instant Mix strategy** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#163](https://github.com/Psychotoxical/psysonic/pull/163))*: When Infinite Queue is enabled, Psysonic now builds the upcoming track list using the same artist-driven logic as Instant Mix. It fetches **Top Songs** and **Similar Songs** for the current track's artist, shuffles and deduplicates the pool, and only falls back to fully random songs when no artist-driven candidates are available. This results in much more coherent listening sessions that stay close to your current musical context.
- **Fullscreen Player — appearance settings** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: Settings → Appearance → Fullscreen Player now offers a toggle to show/hide the artist portrait and a 080 % dimming slider for the background portrait.
- **Build a Mix hub** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#155](https://github.com/Psychotoxical/psysonic/pull/155))*: The previous *Random Mix* and *Random Albums* sidebar entries have been merged into a single **Build a Mix** page (Wand icon) at `/random`. A landing card lets you choose between *Mix by Tracks* and *Mix by Albums*. Old routes remain fully functional.
- **Spanish translation** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#159](https://github.com/Psychotoxical/psysonic/pull/159))*: Complete Spanish (es) locale with 964 translated strings. Psysonic now ships in 8 languages: English, German, French, Dutch, Chinese, Norwegian, Russian, and Spanish.
- **Column-header sorting for Albums & Playlists** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))*: Track lists in Album Detail and Playlist Detail now support click-to-sort directly on the column headers. Three-click cycle: ascending → descending → natural order. Sortable columns: Title, Artist, Album, Favourite, Rating, Duration. The active column is shown bold with a ▲/▼ indicator.
- **Folder Browser — keyboard navigation & context menus** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))*: Full keyboard navigation in the Folder Browser with arrow keys, Enter to open, and Ctrl+Enter to open the context menu. Context menus for all row types include keyboard-operable submenus and star-rating control via arrow keys. The now-playing path is visually emphasized and updates live. Adaptive column layout prioritizes right-side visibility for deep directory trees. A new configurable *Open Folder Browser* keybinding is available in Settings → Keyboard.
- **PLS/M3U playlist resolution for Internet Radio**: Stations configured with a `.pls` or `.m3u`/`.m3u8` URL (e.g. SomaFM, schizoid.in) are now resolved to their first direct stream URL before playback. ICY metadata fetching also auto-resolves playlist URLs. Previously these stations would fail to play or show no track info.
- **Lyrics sources — configurable order & per-source toggle**: The old *Server First* toggle has been replaced with a full drag-to-reorder list in Settings → General. Three sources — **Server** (embedded/OpenSubsonic), **LRCLIB**, and **Netease Cloud Music** — can each be individually enabled or disabled, and their priority order is fully customisable. Embedded SYLT tags from local files always win unconditionally.
- **ReplayGain Pre-Gain & Fallback** *(audio)*: Two new sliders in Settings → Audio → ReplayGain:
- **Pre-Gain** (0+6 dB): added on top of every ReplayGain-tagged track for users who prefer a louder default.
- **Fallback Gain** (60 dB): applied to untagged tracks and internet radio streams, preventing volume jumps when switching between tagged and untagged content.
- **Context-aware Remix button in Build a Mix**: When a genre filter is active, the Remix button now re-fetches the same genre instead of resetting to the full library pool. An *All Songs* chip is available as the first genre option to return to the global mix without leaving the page.
- **AlbumTrackList multi-select & psyDnD** *(tracklist polish)*: Album track lists now support full multi-select with Ctrl/Cmd+Click, Shift+Click range selection, and drag-to-queue for multiple tracks simultaneously. The `TrackRow` component is `React.memo` with fine-grained Zustand selectors, so only the toggled row re-renders on selection change (O(1)).
- **Mute/unmute restores previous volume**: The mute button in the player bar now restores the volume to its level before muting instead of always jumping to 70 %.
### Fixed
- **Statistics — accurate counts for large libraries**: The statistics page was previously capped at 10 pages (≈ 5,000 albums), causing incorrect totals on larger libraries. The pagination loop now runs until the server returns a partial page, regardless of library size. Sort type changed to `alphabeticalByName` for stable pagination.
- **Statistics — Artists count tooltip**: The Artists card now shows a tooltip (dotted underline, cursor: help) explaining that the count reflects album artists only — a Subsonic API limitation. Featured or guest artists who do not have their own album are not counted. The tooltip is localised in all 8 languages.
- **Artists page — alphabet navigation hover effect**: The AZ filter buttons had inline styles that prevented `:hover` CSS from applying. Buttons are now styled via `.artists-alpha-btn` CSS class with an accent-coloured hover highlight and a subtle glow ring.
- **Hot Cache — eviction & prefetch budget**: Eviction now correctly keeps only the current and next track; prefetch fetches up to five tracks when under the size cap but always fetches the immediate next; the previous current track is given a grace period until the debounce fires; eviction runs immediately on MB limit or folder changes; the cap is re-read after each download completes. Live disk usage is now shown on the Audio settings page.
- **Hot Cache + Preload — mutual exclusion on rehydration**: Users who had both Hot Cache and Preload enabled before the mutual-exclusion rule was introduced will have both automatically reset to off on first launch, preventing a conflicting state.
- **Fullscreen Player — Linux compositing performance** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: A new `no_compositing_mode` Tauri command detects Linux software-rendering mode and adds an `html.no-compositing` class, which swaps GPU-only CSS effects (`backdrop-filter`, `filter`, `mask-image`) for software-friendly equivalents throughout the fullscreen player.
- **Fullscreen Player — long lyric lines wrapping**: Long words in lyric lines now wrap correctly instead of overflowing the container.
- **Russian locale** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))*: Numerous translation improvements across the application, replacing machine-translated or awkward phrasings with natural Russian.
- **npm audit vulnerabilities**: Updated `axios` and `vite` to address reported security advisories.
### Changed
- **"Remove from Queue" context menu item** now has a **Trash** icon, matching the destructive action style of other delete operations.
- **Playlist Detail — filter-mode drag**: Rows in a filtered/sorted playlist view can now be dragged to the queue as single songs (previously dragging was disabled entirely in filter mode).
- **Infinite queue deduplication**: Tracks already present in the queue are excluded from the candidate pool, preventing the same song from appearing twice in a row during Infinite Queue sessions.
### Contributors
Thank you to everyone who contributed to **v1.34.9**:
- [@cucadmuh](https://github.com/cucadmuh) — Infinite queue via Instant Mix strategy (PR [#163](https://github.com/Psychotoxical/psysonic/pull/163)), Folder Browser keyboard navigation & context menus (PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))
- [@kilyabin](https://github.com/kilyabin) — Fullscreen Player performance & appearance settings (PR [#156](https://github.com/Psychotoxical/psysonic/pull/156)), Build a Mix hub (PR [#155](https://github.com/Psychotoxical/psysonic/pull/155)), Russian locale improvements (PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))
- [@Kveld9](https://github.com/Kveld9) — Spanish translation (PR [#159](https://github.com/Psychotoxical/psysonic/pull/159)), Column-header sorting (PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))
A huge thank you to all three of you — your contributions have made this one of the most feature-packed patch releases yet. Psysonic keeps getting better because of people like you. 🙌
---
## [1.34.8] - 2026-04-10
### Added
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now supports [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) if it is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic keeps its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
- **ICY metadata & AzuraCast radio** *(contributed by [@nisrael](https://github.com/nisrael), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
### Fixed
- **Multi-artist tracks**: Tracks with multiple artists (OpenSubsonic `artists[]` field, e.g. semicolon-separated entries) now display each artist individually. Artists with their own profile page are clickable links; artists without one appear as plain text. Separated by `·`.
- **Gapless + Preload Gate**: The gapless chain and the preload gate now run on separate paths. Previously both could fire simultaneously, causing a brief black flash on track change.
- **Replay Gain — missing album gain**: When no album gain tag is present, Psysonic now correctly falls back to track gain instead of skipping gain correction entirely.
- **Statistics — music library scope**: Genre insights now respect the currently selected music library. Fetch results are cached to avoid redundant server requests. Playback durations are displayed in localized units.
- **Russian locale**: "Most Played" in the sidebar, home page, and page title now uses «Популярное».
### Changed
- **"Reset to defaults" buttons** in Settings → Input are now styled as warning buttons (red border).
- **Lyrics button** removed from the player bar (redundant with the queue panel tab).
- **Icons**: Advanced search now uses the `TextSearch` icon; artist bio button now uses `Highlighter`.
- **Album chip** in the album detail header is now opaque across all themes.
- **Hot Cache and Hi-Res Audio**: Alpha badges removed — both features are production-ready.
- **CPU optimisations**: Next-track buffering and preload settings have been consolidated into a unified control.
### Theme Fixes
- **Middle Earth**: Removed vertical stripe pattern from sidebar; improved queue artist contrast on hover; fixed album detail artist colour, bio text, and "Read more" link readability; "Next Tracks" divider label is now lighter.
- **Toy Tale**: Fixed sidebar section labels (System/Library), queue tab buttons (Lyrics/Queue), inactive artist text, and "Next Tracks" divider label — all were too dark to read.
- **Tetrastack**: Raised all purple and blue palette values (`#a020f0``#c070ff`, `#0060f0``#4090ff`); raised `--text-muted` from `#3a3a6a` to `#7878b8` — affected settings descriptions, artist names in tracklists, and queue labels.
- **Horde & Alliance**: Removed repeating horizontal line pattern from sidebar.
### Contributors
Thank you to everyone who contributed to this release:
- [@cucadmuh](https://github.com/cucadmuh) — AudioMuse-AI Navidrome integration (PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))
- [@sorensiimSalling](https://github.com/sorensiimSalling) — ICY metadata & AzuraCast radio support (PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))
You make Psysonic better. 🙌
---
## [1.34.7] - 2026-04-09
### Added
- **Windows — Taskbar Thumbnail Toolbar**: Prev / Play-Pause / Next media buttons now appear in the Windows taskbar thumbnail preview (the popup that appears when hovering over the taskbar icon). Buttons emit the same `media:*` events as the tray menu and souvlaki. The Play/Pause icon swaps in real-time as playback state changes.
- **Windows — High-quality taskbar icons**: The taskbar thumbnail toolbar icons are now loaded from embedded `.ico` assets (`play.ico`, `pause.ico`, `prev.ico`, `next.ico`) via `CreateIconFromResourceEx`, replacing the previous monochrome GDI drawing code. All four icons are properly cleaned up on window destruction.
- **Professional update modal**: The in-app updater now shows a polished modal with the full release changelog, a **Skip this version** option, and an OS-aware direct download button (`.dmg` on macOS, `.exe` on Windows, `.deb`/`.rpm` on Linux) as a fallback if the auto-update fails. The modal is fully localised in all 7 supported languages.
- **Self-hosted fonts — no internet required**: All 10 UI fonts are now shipped as WOFF2 files bundled into the app via `@fontsource-variable` npm packages. The previous Google Fonts CDN dependency has been removed entirely — Psysonic now renders correctly with no internet connection and without any external requests on startup.
- **Help — 11 new FAQ entries**: The Help page covers previously undocumented features across Ratings (how to rate songs/albums/artists, removing a rating, Skip-to-1★, rating filter for mixes), Folder Browser, Theme Scheduler, UI Scale, Seekbar styles, AutoEQ, Replay Gain, Hot Cache, and offline playlist caching. All 7 locales updated.
### Fixed
- **Embedded lyrics (MP3 & FLAC)**: A new `get_embedded_lyrics` Tauri command reads lyrics tags directly from local files — `SYLT`/`USLT` frames for MP3 (via the `id3` crate) and `SYNCEDLYRICS`/`LYRICS` tags for FLAC (via `lofty`). Additionally: the LRC parser now correctly handles timestamps without fractional seconds (e.g. `[01:23]`), and the Subsonic structured-lyrics parser now accepts both `synced` and `issynced` field names for compatibility with different server versions.
- **Linux — player bar disappearing at high zoom / small window sizes**: All `grid-template-rows` definitions now use `minmax(0, 1fr)` instead of bare `1fr`, and the `min-height: 720px` constraint on the app shell has been removed. The player bar no longer gets pushed off-screen when the window is small or the UI scale is above 100 %.
- **Windows — "Open folder" in Settings crashing**: The Settings page uses a Rust `open_folder` command instead of the Tauri `shell:open` API, which was blocked by the capability scope on Windows for local paths.
- **macOS — Artist Radio crashing WKWebView after ~10 minutes**: Storing `currentTime` in the persisted Zustand state caused up to ~1,200 synchronous `localStorage.setItem` calls per radio session, eventually crashing the WKWebView SQLite backend. `currentTime` has been removed from the persist partializer. Old played radio tracks are also now trimmed from the queue (keeping the last 5) to cap the localStorage payload during queue top-up.
- **Artist Radio — predictable track order**: The initial Artist Radio queue is now shuffled via Fisher-Yates, so positions 2+ draw from similar-artist tracks in a random order rather than always playing the server's top-5 tracks in sequence.
- **Internet Radio — stall / buffering recovery**: Stall events on the HTML5 `<audio>` element now trigger automatic reconnection (up to 5 retries), recovering from transient network interruptions without requiring a manual restart.
- **Corrupt MP3s — VLC-style frame tolerance**: The audio decoder now tolerates up to 100 consecutive bad frames before giving up (previously 3), matching VLC's behavior for files with invalid `main_data` offset frames. Frame-drop log messages are suppressed in release builds.
- **Statistics — album/song totals respect selected music library**: Album and track counts on the Statistics page were previously derived from `getGenres()`, which is not scoped to the active music folder. Both counts are now derived from the same paginated `getAlbumList` pass used for playtime, with the same 5,000-album cap and a `≥` prefix when capped. *(PR [#138](https://github.com/Psychotoxical/psysonic/pull/138) by [@cucadmuh](https://github.com/cucadmuh))*
- **Fullscreen — resize grips visible in native fullscreen**: Resize grips are now hidden whenever the window enters native fullscreen on all platforms (previously only tracked on Linux). An initial check on mount also catches windows that start in a maximized or fullscreen state.
- **Albums page — year filter input height**: The "From year" / "To year" inputs in the Albums filter bar now match the height and font size of adjacent buttons, fixing the mixed-height row introduced in v1.34.4.
- **Russian locale — missing lyrics-source strings**: The `lyricsServerFirst` and related settings strings were not translated in the Russian locale. *(PR [#140](https://github.com/Psychotoxical/psysonic/pull/140) by [@kilyabin](https://github.com/kilyabin))*
### Contributors
Thank you to everyone who contributed to this release:
- [@cucadmuh](https://github.com/cucadmuh) — Statistics music-folder scope fix (PR [#138](https://github.com/Psychotoxical/psysonic/pull/138))
- [@kilyabin](https://github.com/kilyabin) — Russian locale lyrics strings (PR [#140](https://github.com/Psychotoxical/psysonic/pull/140))
---
## [1.34.6] - 2026-04-08
> I'm sorry this is already the third release today — every time we shipped a critical fix, another critical issue surfaced. Hopefully this one holds. 🤞
### 🚨 Critical Fix
- **ZIP downloads no longer freeze the UI**: All ZIP downloads (Album Detail, Playlist Detail, Albums, New Releases, Random Albums) previously buffered the entire file in the JS heap via `fetch + blob + arrayBuffer`, which caused the app to become completely unresponsive for large downloads (e.g. a 600-song, 7 GB playlist). Downloads now stream directly to disk via the Rust backend (`invoke('download_zip')`), matching the existing single-album download behavior. Progress is shown in the download overlay (bottom right).
- **Offline cache downloads no longer freeze the UI**: Caching a large playlist (600+ songs) triggered up to ~1,200 synchronous `localStorage.setItem` calls as Zustand's `persist` middleware wrote on every state update. Transient download job state has been moved to a new non-persisted store (`offlineJobStore`), reducing localStorage writes for an entire download to **2** regardless of playlist size.
### Added
- **Playlist offline toggle**: When a playlist is already cached offline, clicking the cache button now removes it from the offline cache (shown with a red trash icon) instead of re-downloading it.
### Fixed
- **Home page — "Recently Added" section title** now links to `/new-releases` instead of `/albums`.
---
## [1.34.5] - 2026-04-08
### 🚨 Critical Fix
- **Massive API request flood fixed** *(closes [#133](https://github.com/Psychotoxical/psysonic/issues/133))*: Psysonic was generating 15,000+ background requests per day, filling reverse-proxy access logs (Traefik, nginx) and in some cases crashing the proxy entirely. Four root causes identified and resolved:
- **Now Playing polling**: Was firing every 10 seconds unconditionally — even when minimized or the dropdown was closed. Now only polls while the dropdown is open, and respects the Page Visibility API to pause immediately when the window is hidden.
- **Connection check interval**: Reduced from every 30 seconds to every **120 seconds** (4× reduction).
- **Queue sync debounce**: Increased from 1.5 s to **5 s**, preventing request bursts when skipping rapidly through tracks.
- **Rating prefetch cache**: Artist and album ratings are now cached in memory for **7 minutes**. Repeated page loads (Random Albums, Random Mix) no longer re-fetch ratings that were just retrieved.
### Added
- **Theme Scheduler** *(Settings → Appearance)*: Automatically switches the active theme at configurable times of day. Two time slots (e.g. a light theme during the day, a dark one at night). English locale displays hours in 12-hour AM/PM format; all other languages use 24-hour format.
- **Theme Scheduler hint**: When the scheduler is active, a notice appears at the top of the Theme Picker explaining why manually selecting a theme has no immediate effect.
- **UI Scale** *(Settings → Appearance)*: Adjust the global interface scale (80 % 125 %) without changing the system font size.
- **Folder Browser**: New sidebar section with Miller-columns directory navigation. Browse the server's music folder tree and play or queue folders directly.
- **Seekbar — Waveform fade edges**: The Waveform seekbar style now fades out at both ends, giving it a cleaner, less abrupt look.
- **Cover art fallback logo**: When a cover art image fails to load (broken URL, server error), the Psysonic logo is shown as a placeholder instead of a broken image icon.
- **Tiling WM support** *(PR [#134](https://github.com/Psychotoxical/psysonic/pull/134))*: On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, etc.) the custom title bar is automatically hidden — the WM manages window decorations. The title bar toggle in Settings is also hidden on tiling WMs. Detection is based on environment variables (`HYPRLAND_INSTANCE_SIGNATURE`, `SWAYSOCK`, `I3SOCK`, `XDG_CURRENT_DESKTOP`).
### Changed
- **Custom title bar disabled by default**: New installations start with the native OS title bar. Existing users keep their saved preference.
### Fixed
- **Custom title bar (Linux) — drag & resize**: Window dragging via the title bar now works correctly (missing Tauri `core:window:allow-start-dragging` capability was silently blocking it). CSS resize grips are now shown at the bottom corners to compensate for the removed native GTK grips. The title bar no longer misplaces itself when the window is resized to a small width (mobile-grid layout now includes the title bar row).
- **Fullscreen Player — accent color delay**: The dynamic accent color extracted from album artwork now appears in ~200300 ms instead of up to 18 seconds. The previous implementation queued the cover fetch behind up to 5 concurrent image loads via the app-wide image cache. It now fetches the cover directly and independently. The extracted color is also cached per cover ID, so switching between tracks on the same album is instant.
- **Artist Detail page — slow initial render** *(closes [#132](https://github.com/Psychotoxical/psysonic/issues/132))*: Artist info and biography are now fetched independently of the main artist data, so the page renders immediately and the bio fades in once available. Previously, a slow `getArtistInfo` response blocked the entire page from rendering.
- **Seekbar — Pulse Wave & Retro Tape styles**: Pulse Wave no longer leaves a stray connecting line at the playhead position. Retro Tape's rolling wheel is now anchored at the playhead instead of the center of the bar.
- **Statistics — Top Rated Songs/Artists sections removed**: These sections were incorrectly added in v1.34.4 and have been removed. All other rating features from that release remain fully intact.
---
## [1.34.4] - 2026-04-08
### Added
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (15 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating to a song after it has been manually skipped a configurable number of consecutive times. This skip count threshold can be enabled and adjusted in Settings → Ratings.
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
### Fixed
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
### i18n
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.3] - 2026-04-07
### Added
- **Most Played page** *(closes [#86](https://github.com/Psychotoxical/psysonic/issues/86))*: New dedicated page accessible via the sidebar (TrendingUp icon, `/most-played`). Shows **Top Artists** (ranked by total play count, derived by aggregating album play counts per artist) and a paginated **Top Albums** list with cover art, play count, sort toggle (most/fewest first), and a Load More button.
- **Playlist ZIP download** *(closes [#127](https://github.com/Psychotoxical/psysonic/issues/127))*: Download (ZIP) button in the playlist hero header — same UX as album download. Uses the Subsonic `/rest/download.view` endpoint with the playlist ID, shows a progress bar during transfer, and remembers the last used folder.
- **Fullscreen Player — adaptive accent color**: Extracts the most vibrant pixel from the current album cover (8×8 Canvas downscale, max-HSL-saturation) and applies a WCAG 4.5:1-compliant accent as `--dynamic-fs-accent`. Song title, play button, seekbar, active states, background mesh blobs, and cover art glow all transition smoothly to the extracted color. Resets to the theme accent when the player closes.
- **Dracula theme**: Added to the Open Source Classics group.
- **Discord Rich Presence — Apple Music cover opt-in**: iTunes artwork lookup is now disabled by default. A new toggle in Settings → Integrations ("Fetch covers from Apple Music for Discord") must be explicitly enabled — it sends artist and album name to Apple's search API to find cover art for the Discord profile.
- **Discord Rich Presence — Paused state**: When playback is paused, the Discord presence now shows "Paused" as the status text.
### Fixed
- **M4A playback — older iTunes-purchased files**: Files with an embedded MJPEG cover-art stream and an `iTunSMPB` gapless tag now play correctly. The Symphonia isomp4 patch skips malformed trak atoms gracefully; `parse_gapless_info` now searches for the `" 00000000 "` sentinel to skip the 16-byte binary `data`-atom header, correctly extracting encoder delay and total sample count.
### i18n
- New keys for the Most Played page, playlist download, and Discord Apple Music opt-in added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.2] - 2026-04-07
### Added
- **M4A / ALAC / AAC-LC support** *(closes [#51](https://github.com/Psychotoxical/psysonic/issues/51))*: Apple Lossless (ALAC) and AAC-LC files in M4A containers are now decoded natively by the Rust audio engine (Symphonia) without requiring server-side transcoding.
- **Per-server music folder filter** *(PR [#125](https://github.com/Psychotoxical/psysonic/pull/125) by [@cucadmuh](https://github.com/cucadmuh))*: Users with multiple music libraries on their Navidrome server can now scope browsing to a single folder. A dropdown in the sidebar (visible only when more than one library exists) lets you pick a folder or switch back to "All Libraries". The selection is persisted per server and automatically resets to "All" if the selected folder is no longer available.
- **Hi-Res / Bit-Perfect Playback** *(Alpha)*: New opt-in toggle in Settings → Playback. When enabled, the audio output stream is re-opened at the file's native sample rate (e.g. 88.2 kHz, 96 kHz) — bypassing rodio's internal resampler for a bit-perfect signal path. Disabled by default (safe 44.1 kHz mode). Includes ALSA/PipeWire underrun hardening: scaled quantum size, 500 ms sink pre-fill at high rates, and scheduler priority escalation only when needed.
- **Hot Playback Cache** *(Alpha, PR [#123](https://github.com/Psychotoxical/psysonic/pull/123) by [@cucadmuh](https://github.com/cucadmuh))*: Configurable on-disk prefetch cache for the next track in the queue. Reduces playback latency on slow or metered connections. Toggle and directory can be configured in Settings → Storage.
### Changed
- **Fullscreen Player — info block reworked**: The track title is now the dominant element (large, bold, accent color) and sits above the artist name (small, muted). Matches community feedback on visual hierarchy.
- **Fullscreen lyrics — line wrapping**: Long lyric lines now wrap onto a second line instead of being truncated. Slot height increased from 3.6 vh to 6 vh to accommodate two-line entries without breaking rail positioning.
- **Update notifications**: Removed the Tauri auto-updater (in-app download and install). The app now shows a simple dismissible toast when a newer version is detected on GitHub, with direct links to the [GitHub Releases page](https://github.com/Psychotoxical/psysonic/releases/latest) and the [Psysonic website](https://psysonic.psychotoxic.eu/#downloads). No signing keys, no update manifests.
### Fixed
- **Standard mode CPU usage**: Playing a 44.1 kHz MP3 with Hi-Res disabled no longer triggers an unnecessary audio device re-open on every track start. MSS read-ahead buffer reduced from 4 MB to 512 KB for standard-rate files. Background prefetch is now throttled by 8 s to avoid competing with playback startup. Combined, these changes reduce idle CPU from ~610 % to ~23 % on a modern machine.
- **Hi-Res toggle — stream rate not restored**: Toggling Hi-Res off while a track was playing at 88.2 or 96 kHz left the output stream at the high rate for subsequent tracks. The device's default rate is now restored on the next play.
- **Fullscreen lyrics — CPU spikes on line transitions**: Animating `font-weight` in CSS triggered a full layout reflow on every animation frame. Removed `font-weight` from the transition list; active-line emphasis now uses `transform: scaleX(1.015)` (compositor-only). Added `contain: layout style` to the overlay to isolate reflows from the rest of the page.
### i18n
- New keys for Hi-Res playback settings and music folder filter added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.1] - 2026-04-06
### Added
- **Fullscreen Player — Synced Lyrics Overlay**: Synced lyrics are now displayed directly in the Fullscreen Player as an animated 5-line rail with a soft fade mask at the top and bottom edges. Click any visible line to seek to that position. Toggle the overlay on/off with the new microphone icon button next to the heart — preference is persisted.
> **Note:** The overlay currently requires synced (timestamped) lyrics. Support for unsynced lyrics in the Fullscreen Player is planned for a future release.
- **Embedded Lyrics & LRC support**: The app now fetches lyrics from two sources using the shared `useLyrics` hook (used by both the Lyrics Pane and the Fullscreen overlay):
- **Server-embedded lyrics** via the OpenSubsonic `getLyricsBySongId` endpoint — reads timestamped or plain lyrics baked directly into the audio file's tags (Navidrome 0.53+).
- **LRCLIB** — external LRC lookup as fallback (or primary, configurable in Settings → Playback).
Both sources share a module-level cache so switching between the Lyrics Pane and the Fullscreen Player never triggers a second network request.
- **Artist Image Upload**: A camera overlay now appears when hovering the artist portrait on the Artist page. Clicking it opens a file picker and uploads the image directly to your server.
> **Requires `EnableArtworkUpload = true`** in your Navidrome configuration (new option in Navidrome [#5110](https://github.com/navidrome/navidrome/issues/5110) / [#5198](https://github.com/navidrome/navidrome/issues/5198) — default: `true`). The same requirement applies to the existing Radio Station cover upload.
- **Discord Rich Presence — Album Cover Art**: Album artwork is now displayed in Discord's Rich Presence card. Because Subsonic cover URLs require authentication (and can't be accessed by Discord directly), artwork is fetched from the iTunes Search API using a 3-strategy search (exact → relaxed → track-title fallback), cached for 1 hour, and passed as a direct URL to Discord. Falls back to the static Psysonic asset when no match is found.
- **Nightfox themes** *(PR [#112](https://github.com/Psychotoxical/psysonic/pull/112) by [@nisrael](https://github.com/nisrael))*: Six themes from the [nightfox.nvim](https://github.com/EdenEast/nightfox.nvim) palette have been added to the **Open Source Classics** group — Dawnfox, Dayfox, Nightfox, Nordfox, Carbonfox, and Terafox.
- **Auto-install script** *(PR [#121](https://github.com/Psychotoxical/psysonic/pull/121) by [@kilyabin](https://github.com/kilyabin))*: `install.sh` now supports Debian/Ubuntu (`.deb`) and RHEL/Fedora (`.rpm`) — automatically detects the distro, downloads the correct package from the latest release, and installs it.
### Changed
- **Fullscreen Player — performance overhaul**:
- `FsArt` (cover art) and `FsLyrics` are now isolated `memo` components — unrelated state changes no longer trigger their re-renders.
- Cover crossfade uses an `onLoad` DOM event instead of `new Image()` preloading. This avoids a React batching edge case where both state updates were flushed together and the browser never saw the `opacity: 0` starting state, preventing the CSS transition from firing.
- `useCachedUrl(..., true)` passes the raw URL as an immediate fallback — the image starts fetching from the network instantly while IndexedDB resolves the blob in the background.
- Lyrics slot height is stored in a `useRef` and updated only on `resize` — eliminates repeated `window.innerHeight` layout reads on every render.
- Mouse-move handler is throttled to 200 ms.
- **Artist page — biography**: The bio text is now collapsed by default with a *Read more* / *Show less* toggle button, keeping the page layout clean for artists with long bios.
- **Settings — Logout button**: Moved from the System tab to the bottom of the Server tab, styled as a danger button (red outline → red fill on hover).
### Fixed
- **Gapless playback — manual skip** *(PR [#119](https://github.com/Psychotoxical/psysonic/pull/119) by [@cucadmuh](https://github.com/cucadmuh))*: When the next track had already been gapless-pre-chained into the Sink, a manual skip would not interrupt it — the pre-chained track continued playing at full volume from the old Sink after the fade-out. The chain is now matched by stream identity so user-initiated playback always takes precedence.
- **Radio / Artist cover cache**: `invalidateCoverArt` is now called after every cover upload and delete, so the old image is evicted from the local cache immediately.
- **Queue auto-scroll**: The active track now scrolls reliably into view; eliminated unnecessary component re-renders caused by unstable selector references.
- **macOS TLS** *(PR [#114](https://github.com/Psychotoxical/psysonic/pull/114) by [@nisrael](https://github.com/nisrael))*: Switched `reqwest` from `native-tls` (macOS Security framework) to `rustls-tls` (statically linked). The native backend was returning *bad protocol version* when connecting to HTTPS music servers, silently preventing playback.
### i18n
- **Russian translation improvements** *(PR [#120](https://github.com/Psychotoxical/psysonic/pull/120) by [@kilyabin](https://github.com/kilyabin))*: Extensive phrasing refinements across the entire Russian locale.
- New keys (`fsLyricsToggle`, embedded lyrics settings) added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.0] - 2026-04-06
### Added
- **Mobile UI — Early Preview** ⚠️ — After multiple requests from the community, an initial mobile layout is shipping in this release. **This is a very early work-in-progress** — expect rough edges, missing features, and layouts that still need a lot of polish. Feedback is very welcome! Join the [Discord](https://discord.gg/ckVPGPMS) to share your thoughts.
- Sidebar and queue panel are hidden on mobile; a sticky **Bottom Navigation Bar** replaces them with quick access to Mainstage, Albums, Now Playing, and Search.
- **Mobile Player View** (`/now-playing`) — Full-screen ambient player with dynamic album-art-based background color, large cover art, track metadata line, and playback controls.
- **Mobile Search Overlay** — Full-screen search with recent search history, category chips (Albums, Artists, Genres), and grouped results.
- **Mobile Album Header** — Compact two-row icon button layout (Play + Queue primary, Favorite + Bio + Download + Offline secondary).
- **Mobile Tracklist** — Simplified track rows; disc headers preserved for multi-disc albums.
- **Mobile Hero / Carousel** — Blurred-background-only layout with circular Play + Queue buttons.
- **Russian 2 translation** *(PR [#107](https://github.com/Psychotoxical/psysonic/pull/107) by [@kilyabin](https://github.com/kilyabin))*: A second Russian translation alongside the existing one from [@cucadmuh](https://github.com/cucadmuh) *(PR [#106](https://github.com/Psychotoxical/psysonic/pull/106))*. Both are selectable in Settings → Appearance as **Russian** and **Russian 2**. Since the maintainer neither speaks nor reads Russian, **community feedback is essential here** — please vote on the [Discord](https://discord.gg/ckVPGPMS) or via GitHub which translation feels more natural so we can retire the weaker one in a future release.
- **Clickable Mainstage section headers** — "Zuletzt hinzugefügt", "Entdecken", "Künstler entdecken", and "Persönliche Favoriten" now navigate to their respective pages on click, with a `ChevronRight` indicator and accent-color hover effect.
### Fixed
- **macOS network playback** *(Issue [#108](https://github.com/Psychotoxical/psysonic/issues/108))*: Added `com.apple.security.network.client` to `Entitlements.plist` and disabled the app sandbox for unsigned/ad-hoc builds. Without this, macOS silently blocked outbound TCP connections from the Rust audio engine, causing the player to skip through every track without playing anything.
- **Auto-updater** *(under observation)*: Fixed an incorrect signature in the auto-generated `latest.json` — the CI was writing the public key as the signature value. The updater now receives a correctly signed manifest. **Note:** Due to OS-level restrictions on macOS (Gatekeeper) and Windows (SmartScreen) for unsigned apps, it is not yet certain whether the in-app updater will reliably work on these platforms. Manual installation from the Releases page remains the safe fallback.
### Changed
- All new i18n keys added to all 8 languages (EN, DE, FR, NL, ZH, NB, RU, RU2).
## [1.33.0] - 2026-04-06
### Added
- **Norwegian (Bokmål) translation** *(PR [#101](https://github.com/Psychotoxical/psysonic/pull/101) by [@zz5zz](https://github.com/zz5zz))*: Psysonic is now fully translated into Norwegian Bokmål — selectable in Settings → Appearance.
- **Configurable next-track preload** *(Issue [#102](https://github.com/Psychotoxical/psysonic/issues/102))*: A new setting in Settings → Playback controls when Psysonic starts buffering the next track. Three modes available:
- **Balanced** (default) — begins buffering 30 s before the end of the current track (previous behaviour).
- **Early** — begins buffering after just 5 s of playback, maximising reliability on slow connections.
- **Custom** — set the exact threshold (5 120 s before the end) via a slider.
- **Tray icon visibility toggle**: A new toggle in Settings → App Behavior lets you show or hide the system tray icon. When disabled, the icon is fully removed from the notification area / menu bar.
### Changed
- **Fullscreen Player — complete redesign**: The Ambient Stage has been rebuilt from the ground up.
- **Animated mesh background**: A GPU-only animated dark gradient mesh replaces the static blurred cover art background — smooth, performant, no layout repaints.
- **Artist portrait**: The right half of the screen now shows the artist's image (loaded from the server), crossfading smoothly on every track change. Falls back to the album cover if no artist image is available.
- **Bottom seekbar**: The seekbar is now pinned to the very bottom edge, spanning the full width, with elapsed and remaining timestamps above it.
- **Heart button**: You can now star/unstar the currently playing track directly from the Fullscreen Player without leaving the view.
- Removed the marquee-scrolling title in favour of a large, wrapping typographic layout.
- **Star buttons** — all star/favourite buttons across the app (Player Bar, Album Header, Album Tracklist, Queue Panel) now use the CSS class `.is-starred` instead of inline color overrides, making them trivially themeable.
### Fixed
- **macOS — HTTP audio streams**: Added `NSAppTransportSecurity` / `NSAllowsArbitraryLoads` to `Info.plist`. Without this, App Transport Security silently blocked HTTP radio streams and non-HTTPS Navidrome servers from loading audio in WKWebView on macOS.
---
## [1.32.0] - 2026-04-05 — *The Big Easter Update* 🐣
### Added
- **Custom Offline Storage Directory (#95)**: You can now specify a custom directory for the offline library in Settings → Storage & Downloads. This is perfect for offloading your internal drive to an SD card or external HDD.
- **Robust Volume Handling**: The app now automatically detects if a configured external storage medium is missing and provides a clear "Volume not found" notification instead of failing silently or attempting to download to a non-existent path.
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
- **Settings — Backup & Restore**: Export all your settings (servers, theme, font, keybindings, EQ preset, sidebar order) to a single JSON file and import them on another machine or after a reinstall. Available in Settings → Storage.
- **Albums — Year Range Filter**: A From/To year input now appears in the Albums toolbar alongside the existing genre filter. Filtering by year and by genre can be combined; clearing both inputs returns to the default view.
- **Statistics — Library Insights** *(requested via [#88](https://github.com/Psychotoxical/psysonic/issues/88))*:
- **Total Playtime** card: computed in the background by paginating your full album list (up to 5 000 albums). Shows `≥ Xh Ym` if the library is larger.
- **Genre Insights**: Top 10 genres ranked by song count with proportional progress bars.
- **Format Distribution**: Codec breakdown from a random 500-track sample — shows format name and percentage.
- **Playlist Detail — Cover Upload**: Change or remove a playlist's cover image via the hover menu that appears on the hero artwork — no external tool needed.
- **Tracklist columns — Playlists & Favorites** *(work in progress)*: PlaylistDetail and Favorites now support the same resizable, configurable column system introduced in v1.31.0 for Album tracklists. Column widths and visibility are persisted independently per page. The feature is still being refined.
### Changed
- **Crossfade — fine-grained control**: The crossfade duration slider now ranges from 0.1 s to 10 s in 0.1 s steps (previously 1 s minimum, 0.5 s steps). The current value is shown with one decimal place.
- **Settings — Storage tab redesign**: The "Offline Library" section now has a short description and includes Cache settings. The "Downloads" section is now labelled "ZIP Export & Archiving". Both sections have been visually consolidated.
- **Artists page — Load More button** *(reported via [#90](https://github.com/Psychotoxical/psysonic/issues/90))*: The button is now styled as `btn-primary` with a `ChevronDown` icon and proper spacing. Previously it was an unstyled ghost button with no visual affordance.
- **Tracklist layout consistency**: The Play-button column is now uniformly 60 px and the title column uses `minmax(150px, 1fr)` across all list views — Search Results, Artist Detail, Random Mix, and Advanced Search now match the Album tracklist layout.
- **Internet Radio — HTML5 playback**: Radio now streams via the browser's native `<audio>` element instead of a custom Rust pipeline. This improves compatibility with AAC/MP3/HLS streams.
- **AppUpdater — error visibility** *(experimental, still in progress)*: Update failures are now shown inside the update card rather than silently logged. Auto-update remains experimental — a direct GitHub Releases link is always shown as a fallback.
- **Queue panel — radio drag**: Dragging a radio station card onto the queue is now silently rejected instead of causing an error.
### Fixed
- **PlayerBar stuck on Radio info**: Switching from an Internet Radio station to a regular track no longer leaves the station name and cover in the player bar. `playTrack` now clears `currentRadio` state and stops the audio element immediately.
- **Radio favourite icon**: The heart icon is now correctly used for favourite radio stations on both the Internet Radio page and the Favourites page. It was incorrectly showing a star.
- **Offline track deletion — orphaned directories**: Deleting a cached track now removes empty parent directories up to the configured base directory. Uses `std::fs::remove_dir` (safe — only removes empty directories) to avoid accidental data loss.
---
## [1.31.0] - 2026-04-04
> **Note:** This is likely the last update for the coming week — taking a short break. See you on the other side. ☀️
### Added
- **AutoEQ — 10-Band Parametric Equalizer**: Full parametric EQ with 10 adjustable bands, bypass toggle, and pre-gain control. AutoEQ presets are loaded directly from the AutoEQ GitHub repository — search for your headphone model and apply a community-measured correction curve with one click.
- **Internet Radio — infrastructure** *(work in progress, not yet released)*: The full backend for Internet Radio playback is in place — a dedicated Rust `RadioBuffer` streaming pipeline in the audio engine, Subsonic API integration (`getInternetRadioStations`, create/update/delete), and a `playRadio` action in the player store. The UI page exists but the feature is **not yet accessible** from the sidebar — it will be enabled once the experience is polished.
- **Tracklist columns — resizable & configurable** *(experimental)*: Album tracklist columns can now be resized by dragging the dividers between header cells, similar to a spreadsheet. A column visibility picker (chevron button at the top right) lets you show or hide individual columns. The `#` column is fixed-width. Column widths and visibility are persisted in localStorage. The feature works but is still being refined.
- **Genre column in album tracklist**: Albums that have genre tags per track now show a Genre column in the tracklist.
- **Sidebar auto-migration**: New sidebar items (e.g. Internet Radio) are automatically appended to existing persisted sidebar configurations on first launch — no more missing entries after updates.
### Changed
- **Discord Rich Presence**: Activity type is now `Listening` instead of the default `Playing`. The artist field no longer has the "by " prefix — Discord's layout makes the context clear without it. Album name is shown as a tooltip on the cover icon.
- **Clickable artist names everywhere**: Artist names in Album Cards, Favorites, Random Mix, Playlist Detail, and Artist Detail tracklists are now clickable links that navigate to the artist page.
- **Duration format supports hours**: Tracks and albums longer than 60 minutes are now displayed as `H:MM:SS` instead of overflowing minutes (e.g. `75:03``1:15:03`).
- **Format column**: Codec label no longer includes the "kbps" suffix or the `·` separator — cleaner and fits the narrower column better (e.g. `FLAC 1411` instead of `FLAC · 1411 kbps`).
- **Now Playing sidebar link**: No longer permanently styled as an active menu item. It now only shows the accent background when you are actually on the Now Playing page; at all other times it is distinguished only by its accent text colour.
- **Paused-state indicator in tracklist**: When the currently active track is paused, a dimmed play icon is shown in the `#` column instead of a blank space — making it clear which track is loaded even when playback is stopped.
- **Text selection disabled**: Text can no longer be accidentally selected anywhere in the player by click-dragging or pressing Ctrl+A. Standard input fields are unaffected.
- **Settings — button styles**: "Test connection", "Add server", and "Pick download folder" buttons are now `btn-surface` (with a subtle border) instead of the borderless `btn-ghost` — clearer affordance.
- **Settings — Behavior section icon**: Replaced the generic `Sliders` icon with `AppWindow` for the Behavior section header.
- **`btn-surface` border**: The surface button variant now has a 1 px border that brightens on hover — consistent with the card and input visual language.
- **Queue panel minimum width**: Increased from 250 px to 310 px to prevent layout overflow when the codec/bitrate overlay is visible.
- **Server compatibility hint**: A short note below the Servers section header in Settings clarifies which Subsonic-compatible servers are supported.
### Fixed
- **Tracklist `#` column header alignment**: The "Select all" checkbox and the `#` symbol in the header now use the same internal layout as the row cells — ensuring alignment with individual checkboxes and track numbers at all window sizes.
- **Column resize dividers**: The visible 2 px divider line is now placed in the gap between columns rather than inside the cell, so header labels appear visually centred between their dividers.
- **Internet Radio sidebar link hidden**: The navigation entry is temporarily removed until the feature is ready for release. The underlying code remains in place and will be re-enabled without any migration required.
---
## [1.30.0] - 2026-04-03
### Added
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
### Fixed
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
### Changed
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
---
## [1.29.0] - 2026-04-02
### Added
- **Radio: instant start + background enrichment** *(requested by [@netherguy4](https://github.com/netherguy4))*: Artist Radio now starts immediately from fast local `getTopSongs` results. `getSimilarSongs2` (Last.fm-dependent, slow) continues in the background and silently enriches the queue once it resolves — no waiting before the first song.
- **OGG/Vorbis playback** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #42](https://github.com/Psychotoxical/psysonic/pull/42))*: Added `symphonia-format-ogg``.ogg` files now play natively without server-side transcoding.
- **Click-to-seek in synced lyrics** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Clicking any line in the synced lyrics pane seeks directly to that timestamp.
- **Volume scroll wheel** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Scrolling the mouse wheel over the volume slider adjusts volume in ±5 % steps.
- **Lyrics visual states** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Synced lyrics lines now show three distinct visual states — active (highlighted), completed (muted), upcoming (neutral).
- **Themed audio error toasts** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #43](https://github.com/Psychotoxical/psysonic/pull/43) / [PR #44](https://github.com/Psychotoxical/psysonic/pull/44))*: Unsupported formats and decode failures are now surfaced as themed in-app toast notifications with human-readable messages instead of silent failures.
### Fixed
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Hopefully fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
- **Radio queue stacking**: Clicking "Start Radio" multiple times no longer appends unlimited duplicate batches — each click replaces the pending Radio section cleanly.
- **Start Radio keeps current song playing**: Triggering Radio while a song is playing no longer stops and restarts the current track.
- **Radio proactive loading with songs missing `artistId`**: `getSimilarSongs2` results frequently lack `artistId`. A `currentRadioArtistId` module variable now persists the original artist ID as fallback, so proactive loading always fires correctly.
- **Seek audio glitch after lyrics click**: Any seek ≥ 100 ms into a track no longer causes a brief fade-from-zero. `EqualPowerFadeIn` now only resets to zero-gain for seeks to the track start.
### Changed
- **Infinite Queue: 5 tracks at a time** (was 25): Proactive loading fetches 5 tracks when ≤ 2 remain, keeping the queue lean without interruption.
- **Queue section order is now explicit**: Manual tracks → Radio (with `— Radio —` separator) → Infinite Queue auto-added tracks (with `— Auto —` separator). Manually enqueued songs always appear before auto-managed sections.
### Contributors
Thanks to [@nisarg-78](https://github.com/nisarg-78) and [@JulianNymark](https://github.com/JulianNymark) for their first contributions in this release.
Special thanks to [@netherguy4](https://github.com/netherguy4) for continued feature ideas and feedback.
---
## [1.28.0] - 2026-04-02
### Added
- **Infinite Queue** *(requested by [@netherguy4](https://github.com/netherguy4))*: When the queue runs out with Repeat off, Psysonic automatically appends 25 random tracks (optionally filtered by the last-played track's genre) so playback never stops. Toggle in Settings → Audio → "Infinite Queue". Auto-added tracks appear below a divider in the Queue panel.
- **Start Radio plays immediately** *(requested by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" from the song/queue context menu now starts the seed track instantly while similar and top tracks load in the background — no waiting for the fetch to complete before music plays.
### Fixed
- **Single-click to play everywhere** *(reported by [@netherguy4](https://github.com/netherguy4))*: Song rows in Album Detail, Playlist Detail, Artist Detail (Top Tracks), Favorites, and Random Mix previously required a double-click. All rows now play on a single click. The track-number cell and the full row are both click targets; buttons and links inside the row still work independently.
- **Artist page Play All / Shuffle used Top Tracks only** *(reported by [@smirnoffjr](https://github.com/smirnoffjr))*: "Play All" and "Shuffle" on the Artist detail page only sent the loaded top songs to the queue, not the full discography. Now fetches all albums in parallel and plays songs in chronological album order with correct track-number ordering within each album. Buttons show a spinner while albums are loading.
- **Last.fm icon clipped in player bar**: The Last.fm logo button in the player bar was cut off on the right side. Fixed by correcting the SVG `viewBox` from `0 0 24 24` to `0 0 26 22` to match the actual path extents.
- **Playlist empty state UX** *(reported by [@netherguy4](https://github.com/netherguy4))*: Empty playlists (on creation, or after deleting all tracks) now show an "Add your first song" CTA button that opens the search panel directly, rather than a plain text message with no action.
- **Playlist search rows required "+" button click** *(reported by [@netherguy4](https://github.com/netherguy4))*: Search result rows in the song search panel now add the song on a full-row click — the separate "+" button was redundant and easy to miss.
- **Large playlist performance**: Playlists with hundreds of songs would freeze during mouse movement. Root cause: `hoveredSongId` state triggered a full React re-render of every row on every `mouseenter`/`mouseleave` event. Fixed by removing the JS hover state and replacing it with a CSS `.track-row:hover .bulk-check` rule. Also memoized `songs.map(songToTrack)` and the `existingIds` set to avoid recomputation per render. Same fix applied to `AlbumTrackList`.
---
## [1.27.4] - 2026-04-02
### Added
- **In-App Auto-Update** *(requested by [@netherguy4](https://github.com/netherguy4))*: Psysonic now checks for new releases automatically on startup (3 s delay). On macOS and Windows a native install-and-relaunch flow is available directly in the app — no browser needed. On Linux, a download link to the GitHub release page is shown instead (AppImage is not built due to WebKitGTK incompatibility with Arch/Fedora). The updater uses Tauri's signed updater plugin with minisign signatures verified against a bundled public key.
- **Configurable Home Page**: Users can now choose which sections appear on the home page. A new "Home Page" block in Settings → Library lets you toggle each section individually (Featured, Recently Added, Discover, Discover Artists, Recently Played, Personal Favorites, Most Played) with a reset-to-default button. Hidden sections are skipped entirely.
- **Consistent icon language** *(requested by [@netherguy4](https://github.com/netherguy4))*: Favorites (local star/heart) now use a filled Heart icon everywhere — Player Bar, Album Detail, Artist Detail, Tracklist, Context Menu. Last.fm love always uses the Last.fm logo. Previously the two were mixed up in several places.
### Fixed
- **Radio broken from context menu** *(reported by [@netherguy4](https://github.com/netherguy4))*: "Start Radio" in the track and queue-item context menus had no effect. The handler was passing the artist name as the artist ID to `getSimilarSongs2`, which returned an empty result — so no tracks were queued and no error was shown. Now correctly passes `song.artistId`.
- **Album Detail hero background not loading**: The blurred album art background in Album Detail only appeared after a track change, never on first visit. Root cause: `buildCoverArtUrl` was called without `useMemo`, generating a new salt on every re-render — causing `useCachedUrl` to cancel and restart its fetch endlessly. Fixed by memoising both the URL and cache key on `album.coverArt`. Same fix applied to Hero and Playlist Detail backgrounds.
- **CI: auto-update signing pipeline**: Signing keys were not being passed correctly during the build, and macOS `.sig` files were uploaded with a generic name the manifest generator couldn't match. Fixed the post-build signing step to upload arch-specific names (`Psysonic_aarch64.app.tar.gz.sig`, `Psysonic_x64.app.tar.gz.sig`). First release where the in-app updater is fully functional on macOS and Windows.
- **CI: Windows NSIS upload**: The release workflow was not correctly uploading Windows artifacts. Resolved by letting `tauri-action` handle NSIS bundle detection and upload directly — it only searches for what was actually built, so there is no MSI conflict with `--bundles nsis` builds.
- **CI: npm + Cargo caching** *(contributed by [@netherguy4](https://github.com/netherguy4))*: Added `actions/cache` for npm and `Swatinem/rust-cache` for Cargo across all build jobs. Warm-cache builds will be significantly faster on subsequent releases.
- **Linux/AUR build: ring linker error**: Builds on Arch/CachyOS failed with `rust-lld: undefined symbol: ring_core_*` after the Tauri updater was added. Arch's `rust` package bakes `-fuse-ld=lld` into the default rustflags; ring's C/asm objects are incompatible with lld. Fixed via `.cargo/config.toml` — forces `cc` as linker driver with `-fuse-ld=bfd` to override the hardcoded lld flag. Added `clang` to the AUR `makedepends` (required by ring's bindgen step).
---
## [1.26.1] - 2026-04-01
### Fixed
- **Background flickering in Hero, Album Detail and Playlist Detail**: Blurred hero backgrounds were flickering for up to 20 seconds on first visit. Root cause: `useCachedUrl` with the default `fallbackToFetch = true` immediately returned the raw server URL, causing the background to render twice — once with the HTTP URL (triggering a server fetch) and again when the IndexedDB blob was ready. Fixed by passing `fallbackToFetch = false` in all three locations so the background only renders once the blob is cached.
---
## [1.26.0] - 2026-04-01
### Added
- **Favorite button in Player Bar** *(requested by [@halfkey](https://github.com/halfkey))*: A star icon button now sits next to the Last.fm heart in the player bar. Clicking it toggles the favorite/unfavorite state for the currently playing track with an optimistic UI update — no page reload needed. Uses the same `starredOverrides` mechanism as the album tracklist for instant feedback.
- **Bulk Select for song lists**: Multi-select support in Album tracklist and Playlist detail. A checkbox fades in to the left of the track number on hover. Selecting one or more tracks activates the bulk action bar at the top with two actions: **Add to Playlist** (opens the playlist picker submenu) and **Remove from Playlist** (Playlist detail only). Shift-click selects a range; the header checkbox selects / deselects all. CSS uses `color-mix` for the selection highlight, compatible with all 60 themes.
- **Song Info modal**: Right-clicking any song and choosing "Song Info" opens a metadata panel fetched live via `getSong`. Displays: title, artist, album, album artist, year, genre, duration, track number; format, bitrate, sample rate, bit depth, channels (Mono / Stereo), file size; file path; and Replay Gain values (track / album gain + peak) when present. Closes with Escape or a click on the backdrop.
- **Recently Played section on Home page**: A new "Recently Played" album row appears on the Home page between the hero carousel and the Discover section, powered by the `getAlbumList('recent')` endpoint.
- **"Now Playing" visibility toggle in Settings**: New opt-in toggle in Settings → Behavior ("Show activity in Now Playing"). When disabled (default), `reportNowPlaying` is not called, so no activity is reported to the Navidrome "Now Playing" feed. Useful for users who share a server.
### Fixed
- **Queue cover art not updating**: After a track change the queue panel cover art often stayed on the previous album or took a long time to update. Root cause: `useCachedUrl` and `CachedImage` were not resetting their resolved URL when the `cacheKey` changed. Fixed by resetting `resolved` to `''` before each async cache fetch and basing `CachedImage`'s `loaded` state on `useEffect([cacheKey])` instead of a render-time comparison.
- **Fullscreen Player background flickering**: The blurred background briefly showed a blank frame when switching tracks because the new image div was added to the DOM before the blob URL was ready. Fixed in `FsBg` by preloading the image via `new Image()` before inserting the layer, and using `useCachedUrl(..., false)` for the crossfade background so the raw URL is never used as a fallback during transitions.
- **Playlist card delete confirmation not visible**: The confirm state only changed the icon colour, which was barely noticeable over the red button. Replaced with a size expansion (24 px → 30 px), an inset white ring, and a pulsing `delete-confirm-pulse` animation that alternates between two shades of red.
- **Gruvbox Light Soft — back button and badge**: The album detail back-arrow and album badge were invisible against the warm light background. Added explicit colour overrides for `.album-detail-back` and `.album-detail-badge` in the gruvbox-light-soft theme.
### Changed
- **`buildStreamUrl` signature**: Removed the unused `suffix` parameter. Opus transcoding (`format=flac`) is now handled in `playerStore.playTrack` via `track.suffix` check, keeping the URL builder stateless.
---
## [1.25.1] - 2026-04-01
### Fixed
- **Single-instance enforcement** *(reported by [@netherguy4](https://github.com/netherguy4))*: Re-launching the app while it was already running (including minimized to tray) would spawn a new independent process, leading to playback conflicts and state divergence. Integrated `tauri-plugin-single-instance` — subsequent launches are intercepted, the existing window is shown, unminimized, and focused instead.
---
## [1.25.0] - 2026-04-01
### Added
- **System Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: Functional tray icon with context menu — Play / Pause, Previous Track, Next Track, Show / Hide, and Exit Psysonic. Left-clicking the tray icon toggles window visibility. The tray icon is built via `TrayIconBuilder` in Rust so menu events are properly wired.
- **Minimize to Tray** *(requested by [@jackbot](https://github.com/jackbot) and [@thecyanide](https://github.com/thecyanide))*: New toggle in Settings → Behavior. When enabled, closing the window hides it to the tray instead of exiting. The close button behaviour is intercepted in Rust (`prevent_close` + `window:close-requested` event) and the JS side decides hide vs. exit based on the user setting.
- **Sidebar Customization** *(requested by [@lighthous3d](https://github.com/lighthous3d))*: New section in Settings → Appearance. All library and system nav items can be shown/hidden via a toggle switch and reordered by dragging the grip handle. Order and visibility are persisted across sessions (`psysonic_sidebar` in localStorage). Fixed items (Now Playing, Settings) are listed as non-configurable below the list.
- **Playlist cover art**: Playlist cards on the Playlists overview page now display the server-generated cover image (Navidrome's `coverArt` field on the playlist object) via the IndexedDB image cache. Falls back to the ListMusic icon when no cover is available.
### Fixed
- **Cover image flickering**: `buildCoverArtUrl()` generates a new random auth salt on every call, causing `useCachedUrl` to re-trigger on every render and produce a rapid re-fetch loop. Fixed by wrapping all `buildCoverArtUrl` / `coverArtCacheKey` calls in `useMemo` with the cover ID as dependency in `ArtistCardLocal`, `QueuePanel`, `FullscreenPlayer`, `Hero`, and `PlaylistDetail`.
- **DnD text selection**: Dragging a grip handle in the Sidebar Customizer (and any future `useDragSource` consumer) would select all text on the page during the threshold detection phase. Fixed by calling `e.preventDefault()` in `useDragSource`'s `onMouseDown` handler before the drag threshold is reached.
- **Sidebar Customization DnD on Linux**: The initial implementation used the HTML5 Drag & Drop API, which always shows a forbidden cursor on WebKitGTK and does not fire drop events reliably. Rewritten to use the existing psy-drag mouse-event system (`useDragSource` / `psy-drop` custom event), consistent with the Queue panel.
---
## [1.24.0] - 2026-03-31
### Added
+47
View File
@@ -0,0 +1,47 @@
# Privacy Policy
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
## Data sent to third-party services
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Last.fm
If you connect a Last.fm account in Settings, Psysonic sends:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album)
- **Love / Unlove** — when you mark a track as loved or unloved
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
### NetEase Cloud Music (Lyrics)
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
### Apple Music / iTunes Search API
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
### Discord Rich Presence
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
## Data stored locally
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
- Last.fm session key
- Playback preferences, themes, keybindings, and all other settings
- Synced device manifests
## No telemetry
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
## Open source
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
+80 -53
View File
@@ -1,6 +1,6 @@
<div align="center">
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="300"/>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
<p>
@@ -8,9 +8,23 @@
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
> [!WARNING]
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
---
<div align="center">
<a href="https://discord.gg/AMnDRErm4u">
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
</a>
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
</div>
---
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) and [Nord](https://www.nordtheme.com/) aesthetics.
@@ -18,74 +32,79 @@ Psysonic is a beautiful desktop music player built completely from the ground up
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png)
![Psysonic Screenshot](public/screenshot1.png)
## ✨ Features
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
- 🎛 **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
- 🎨 **Wide Theme Selection**: Dozens of themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, and Psysonic originals. Glassmorphism effects, micro-animations, and a time-based **Theme Scheduler** for automatic day/night switching.
-**Native Performance**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage, no Electron overhead.
- 🎵 **Last.fm Integration**: Scrobbling, Now Playing, love/unlove, Similar Artists, and top stats — no Navidrome config needed.
- 🎤 **Synchronized Lyrics**: Auto-scrolling synced lyrics with click-to-seek in the sidebar and fullscreen player, powered by LRCLIB and your Navidrome server.
- 📻 **Radio & Infinite Queue**: Smart Radio sessions from any song or artist, built-in Internet Radio (ICY/HLS), and an Infinite Queue that silently refills when the queue runs out.
- 🎛️ **Advanced Audio**: 10-band graphic EQ with custom presets, **AutoEQ** headphone correction, Replay Gain, gapless playback, and crossfade.
- 〰️ **10 Seekbar Styles**: Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
- 🖥️ **Fullscreen Player**: Album art, animated synced lyrics overlay, and artist image in a dedicated fullscreen view.
- 📋 **Playlists & Library**: Full playlist management with drag-and-drop reorder and smart suggestions. Genre browsing, Random Mix, Advanced Search, ratings (15 stars), and multi-select actions.
- 💾 **Device Sync**: Export your library to a USB drive or portable device using a configurable filename template.
- 🖥 **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
## 🗺️ Roadmap
### ✅ Completed
- [x] Native Rust/rodio audio engine (replaces Howler.js)
- [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks
- [x] Replay Gain (track + album mode)
- [x] Gapless playback
- [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove
- [x] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
- [x] Advanced Search (text + genre + year + result-type filters)
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
- [x] Internationalization (English, German, French, Dutch, Chinese)
- [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 📋 Planned
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all themes
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
## ● Known Limitations
Some known bugs actively working on fixes
## 📥 Installation
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` or `.msi`
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
### 🐧 Linux
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
**Quick Install (Recommended):**
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
**Manual Installation:**
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 Installation (Arch Linux / AUR)
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
| Package | Type | Description |
| :--- | :--- | :--- |
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
> [!TIP]
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
## 🚀 Getting Started
@@ -101,6 +120,10 @@ If you want to build Psysonic from source or contribute to the project:
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/) (v1.75+)
- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`:
- Linux: `sudo apt install cmake` / `sudo pacman -S cmake`
- macOS: `brew install cmake`
- Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake`
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
### Setup
@@ -135,3 +158,7 @@ Contributions are completely welcome! Whether it is translating the app into a n
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
## 🔒 Privacy
Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details.
+36
View File
@@ -0,0 +1,36 @@
# Shell completion for `psysonic`
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
## zsh
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
```sh
mkdir -p ~/.zsh/completions
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
```
If you use a plugin manager, point its `fpath` at this repos `completions/` directory instead.
## bash
Source the script once (e.g. in `~/.bashrc`):
```sh
source /path/to/psysonic/completions/psysonic.bash
```
Use this only under **bash** (including macOSs `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
## Device names after `audio-device set`
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
## Folder ids after `library set`
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
+144
View File
@@ -0,0 +1,144 @@
#compdef psysonic
# Zsh completion for Psysonic CLI (see `psysonic --help`).
_psysonic_audio_device_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_audio_device_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_audio_device_json)" || return
local -a devs
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
(( $#devs )) && compadd -a devs
}
_psysonic_library_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_library_folder_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_library_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_snapshot_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_server_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_snapshot_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --player completions
}
integer i pidx=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if (( pidx == 0 )); then
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
_psysonic_globals
return
fi
local -a sub
if (( pidx + 1 <= CURRENT - 1 )); then
sub=( ${words[pidx + 1,CURRENT - 1]} )
else
sub=()
fi
integer n=${#sub[@]}
if (( n == 0 )); then
compadd -J verbs -X 'player command' -- \
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
audio-device library server search mix
return
fi
case ${sub[1]} in
audio-device)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd default
_psysonic_audio_device_ids
fi
;;
library)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd all
_psysonic_library_folder_ids
fi
;;
mix)
(( n == 1 )) && compadd append new
;;
server)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
_psysonic_server_ids
fi
;;
search)
if (( n == 1 )); then
compadd track album artist
elif (( n >= 2 )); then
_message -e descriptions 'search query'
fi
;;
repeat)
(( n == 1 )) && compadd off all one
;;
rating)
(( n == 1 )) && compadd 0 1 2 3 4 5
;;
seek)
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
;;
esac
+167
View File
@@ -0,0 +1,167 @@
# bash completion for Psysonic (see `psysonic --help`).
# Install: source /path/to/completions/psysonic.bash
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
#
# Uses no `mapfile` so bash 3.2 (macOS default) works.
#
# compopt is bash-only (programmable completion). Guard so sourcing this file
# under zsh or plain sh does not print "command not found: compopt".
_psysonic_compopt() {
command -v compopt &>/dev/null || return 0
compopt "$@" 2>/dev/null || return 0
}
_psysonic_compreply_from_compgen() {
# $1 = compgen -W word list, $2 = current word
COMPREPLY=()
local line
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W "$1" -- "$2")
}
_psysonic_audio_device_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_library_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_snapshot_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
done
if (( pidx == 0 )); then
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
return
fi
local -a sub=()
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
sub+=("${COMP_WORDS[i]}")
done
local n=${#sub[@]}
if (( n == 0 )); then
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
return
fi
case ${sub[0]} in
audio-device)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf d
jf="$(_psysonic_audio_device_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r d; do
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'default' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
library)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf id line
jf="$(_psysonic_library_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r id; do
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'all' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
mix)
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
;;
server)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf sid
jf="$(_psysonic_snapshot_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r sid; do
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
search)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'track album artist' "$cur"
elif (( n >= 2 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
repeat)
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
;;
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek|volume)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
esac
}
complete -F _psysonic_complete psysonic
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776169885,
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+121
View File
@@ -0,0 +1,121 @@
{
description = ''
Psysonic for NixOS / nixpkgs: installable app + dev shell.
Packages:
nix build .#psysonic # or .#default desktop app (.desktop + icon)
nix profile install .#psysonic
Run (after build, or from any clone with flake):
nix run .#psysonic
nix run github:Psychotoxical/psysonic
Development:
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
nix shell .#devShells.default # same environment without entering subshell semantics
Release pipeline updates `flake.lock` (nixpkgs pin refresh) and
`nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push
see `.github/workflows/release.yml` (verify-nix job). Package version
is read from `package.json`; nothing in this file needs manual bumping
per release.
'';
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
];
forSystem = f: lib.genAttrs systems f;
mkShellFor =
system:
let
pkgs = nixpkgs.legacyPackages.${system};
gstPlugins = with pkgs.gst_all_1; [
gstreamer
gst-plugins-base
gst-plugins-good
gst-plugins-bad
];
gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
in
pkgs.mkShell {
packages = with pkgs; [
nodejs_22
rustc
cargo
cmake
pkg-config
openssl
gtk3
webkitgtk_4_1
libsoup_3
glib-networking
atk
cairo
gdk-pixbuf
glib
pango
librsvg
alsa-lib
libayatana-appindicator
]
++ gstPlugins;
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
export GDK_BACKEND=x11
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
unset CI
'';
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
};
upstreamMeta = lib.importJSON ./nix/upstream-sources.json;
psysonicFor =
system:
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
src = self;
inherit upstreamMeta;
};
in
{
devShells = forSystem (system: { default = mkShellFor system; });
packages = forSystem (system: {
psysonic = psysonicFor system;
default = psysonicFor system;
});
apps = forSystem (
system:
let
p = psysonicFor system;
in
{
default = {
type = "app";
program = lib.getExe p;
meta = {
inherit (p.meta) description homepage license;
mainProgram = "psysonic";
};
};
}
);
};
}
-3
View File
@@ -6,9 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+176
View File
@@ -0,0 +1,176 @@
# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle.
# Source: `self` (this repo). Package version is read from package.json.
# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release
# workflow (see .github/workflows/release.yml, verify-nix job).
{
lib,
stdenv,
fetchNpmDeps,
npmHooks,
rustPlatform,
cargo,
rustc,
pkg-config,
cmake,
openssl,
gtk3,
webkitgtk_4_1,
libsoup_3,
glib-networking,
alsa-lib,
libayatana-appindicator,
atk,
cairo,
gdk-pixbuf,
glib,
pango,
librsvg,
cargo-tauri,
nodejs,
makeWrapper,
wrapGAppsHook4,
copyDesktopItems,
makeDesktopItem,
gst_all_1,
src,
upstreamMeta,
}:
let
version = (lib.importJSON (src + "/package.json")).version;
# WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base).
gstPlugins = with gst_all_1; [
gstreamer
gst-plugins-base
gst-plugins-good
gst-plugins-bad
];
gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
srcClean = lib.cleanSourceWith {
inherit src;
filter =
path: _:
let
f = toString path;
in
!(lib.hasInfix "/node_modules/" f)
&& !(lib.hasInfix "/dist/" f)
&& !(lib.hasInfix "/target/" f)
&& !(lib.hasInfix "/.git/" f)
&& !(lib.hasInfix "/result/" f)
&& !(lib.hasInfix "/.flatpak-builder/" f);
};
npmDeps = fetchNpmDeps {
src = srcClean;
hash = upstreamMeta.npmDepsHash;
};
cargoLockFile = src + "/src-tauri/Cargo.lock";
in
stdenv.mkDerivation (finalAttrs: {
pname = "psysonic";
inherit version;
src = srcClean;
inherit npmDeps;
strictDeps = true;
# cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root
dontUseCmakeConfigure = true;
nativeBuildInputs = [
npmHooks.npmConfigHook
cargo
rustc
rustPlatform.cargoSetupHook
pkg-config
cmake
makeWrapper
wrapGAppsHook4
copyDesktopItems
cargo-tauri
nodejs
];
buildInputs = [
gtk3
webkitgtk_4_1
libsoup_3
glib-networking
openssl
alsa-lib
libayatana-appindicator
atk
cairo
gdk-pixbuf
glib
pango
librsvg
]
++ gstPlugins;
cargoRoot = "src-tauri";
cargoDeps = rustPlatform.importCargoLock {
lockFile = cargoLockFile;
# Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml.
# Keep in sync with that block — importCargoLock needs the source to match
# the lockfile entries for patched crates (otherwise it tries to fetch from
# crates.io and the hash mismatches).
outputHashes = { };
};
dontUseCargoParallelJobs = true;
env = {
OPENSSL_DIR = "${openssl.dev}";
OPENSSL_LIB_DIR = "${openssl.out}/lib";
OPENSSL_INCLUDE_DIR = "${openssl.dev}/include";
VITE_LASTFM_API_KEY = "";
VITE_LASTFM_API_SECRET = "";
};
# beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
(cd src-tauri && cargo tauri build --no-bundle -v)
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 src-tauri/target/release/psysonic -t $out/bin
install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "psysonic";
desktopName = "Psysonic";
comment = "Subsonic-compatible music player";
icon = "psysonic";
exec = "psysonic";
categories = [ "AudioVideo" "Audio" "Player" ];
})
];
postFixup = ''
wrapProgram $out/bin/psysonic \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
--set GDK_BACKEND x11 \
--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
'';
meta = {
description = "Desktop music player for Subsonic-compatible servers";
homepage = "https://github.com/Psychotoxical/psysonic";
license = lib.licenses.gpl3Only;
mainProgram = "psysonic";
platforms = lib.platforms.linux;
};
})
+3
View File
@@ -0,0 +1,3 @@
{
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
}
+605 -81
View File
@@ -1,24 +1,42 @@
{
"name": "psysonic",
"version": "1.21.0",
"version": "1.34.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.21.0",
"version": "1.34.13",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/golos-text": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
@@ -29,11 +47,13 @@
"@tauri-apps/cli": "^2",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/papaparse": "^5.5.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.3"
}
},
"node_modules/@babel/code-frame": {
@@ -769,6 +789,132 @@
"node": ">=18"
}
},
"node_modules/@fontsource-variable/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.2.8.tgz",
"integrity": "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/figtree": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.2.10.tgz",
"integrity": "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/golos-text": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/golos-text/-/golos-text-5.2.8.tgz",
"integrity": "sha512-cLT8Gu9tSQTOjfPY+qnrqQwafUUJkZu0s9hTbQbtaeknTyV36c/FQc5hvTRvgeUOT4cp/Xf0dkHdZatwj3g2Nw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/lexend": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/nunito": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.2.7.tgz",
"integrity": "sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/outfit": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/plus-jakarta-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
"integrity": "sha512-iQecBizIdZxezODNHzOn4SvvRMrZL/S8k4MEXGDynCmUrImVW0VmX+tIAMqnADwH4haXlHSXqMgU6+kcfBQJdw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/rubik": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.2.8.tgz",
"integrity": "sha512-vGDExLzB4a2Fj9mca5LqNoA2ZKcU9o+x5FEBLte/nxYkCB9hOQwZS6ZlItUv+Ssn7YMzKauGuI/Po+YueFuZbg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/space-grotesk": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
"integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/unbounded": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.2.8.tgz",
"integrity": "sha512-DWC/HEdNNbjMH6ngeeCAPExKMsedoY+pV3ZnRXzFcAzXuGHB6dEwsXNVQ4fiuuYMGguq9TSAEUat4Oy5prdwWQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -927,9 +1073,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -944,9 +1087,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -961,9 +1101,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -978,9 +1115,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -995,9 +1129,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1012,9 +1143,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1029,9 +1157,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1046,9 +1171,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1063,9 +1185,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1080,9 +1199,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1097,9 +1213,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1114,9 +1227,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1131,9 +1241,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1224,6 +1331,40 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@@ -1323,9 +1464,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1343,9 +1481,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1363,9 +1498,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1383,9 +1515,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1403,9 +1532,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1467,21 +1593,21 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz",
"integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
@@ -1493,6 +1619,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@@ -1511,6 +1646,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-window-state": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
@@ -1565,6 +1709,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1589,6 +1751,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1638,6 +1810,129 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
"integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.3",
"@vitest/utils": "4.1.3",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
"integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.3",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
"integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
"integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.3",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
"integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.3",
"@vitest/utils": "4.1.3",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
"integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
"integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.3",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1645,14 +1940,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -1736,6 +2031,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
@@ -1846,6 +2151,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1925,6 +2237,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1944,9 +2276,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -2210,6 +2542,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2284,6 +2626,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2334,10 +2700,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/react": {
"version": "18.3.1",
@@ -2497,6 +2866,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2507,6 +2883,37 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2524,6 +2931,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2586,9 +3003,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2660,6 +3077,96 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
"integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.3",
"@vitest/mocker": "4.1.3",
"@vitest/pretty-format": "4.1.3",
"@vitest/runner": "4.1.3",
"@vitest/snapshot": "4.1.3",
"@vitest/spy": "4.1.3",
"@vitest/utils": "4.1.3",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.3",
"@vitest/browser-preview": "4.1.3",
"@vitest/browser-webdriverio": "4.1.3",
"@vitest/coverage-istanbul": "4.1.3",
"@vitest/coverage-v8": "4.1.3",
"@vitest/ui": "4.1.3",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -2669,6 +3176,23 @@
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+26 -5
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.24.0",
"version": "1.34.19",
"private": true,
"scripts": {
"dev": "vite",
@@ -8,20 +8,39 @@
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
"tauri:build": "tauri build"
"tauri:build": "tauri build",
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/golos-text": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
@@ -32,10 +51,12 @@
"@tauri-apps/cli": "^2",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/papaparse": "^5.5.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vitest": "^4.1.3"
}
}
+18 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.24.0
pkgver=1.34.12
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -16,6 +16,9 @@ makedepends=(
'npm'
'rust'
'cargo'
'clang'
'nasm'
'cmake'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
@@ -26,6 +29,20 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
# because the last -fuse-ld=* flag passed to cc wins.
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
unset CFLAGS CXXFLAGS
npm install
npm run tauri:build -- --no-bundle
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

+52
View File
@@ -0,0 +1,52 @@
# Scripts
## install.sh - Auto-Installer for Debian and RHEL-based Systems
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
### Supported Distributions
- **Debian/Ubuntu**: Downloads and installs `.deb` package
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
### Usage
#### Quick Install (Recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
#### Manual Installation
```bash
# Download the script
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
# Make it executable
chmod +x install.sh
# Run with sudo
sudo ./install.sh
```
### What it does
1. Detects your OS type (Debian or RHEL-based)
2. Fetches the latest release from GitHub
3. Downloads the appropriate package (.deb or .rpm)
4. Installs it using your system's package manager
5. Cleans up temporary files
### Requirements
- `curl` - for downloading packages
- `sudo` or root access
- Internet connection
- Supported package manager (apt-get, dnf, or yum)
### Notes
- If Psysonic is already installed, the script will ask if you want to reinstall
- The script automatically handles dependency installation for Debian systems
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// macOS-only for now — Windows + Linux are added once their signing pipelines
// (Certum cert for Windows, native package managers for Linux) are wired up.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
};
const platforms = {};
// A real minisign .sig file is multi-line and ~200+ chars.
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
function validateSignature(sig, platform, sigFile) {
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
throw new Error(
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
` Got: ${sig}\n` +
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
);
}
if (sig.length < 80) {
throw new Error(
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
);
}
}
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
validateSignature(signature, platform, sigFile);
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
set -e
# Psysonic Auto-Installer
# Automatically detects your OS and installs the latest release from GitHub
REPO="Psychotoxical/psysonic"
APP_NAME="psysonic"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
error "Please run this script as root (use sudo)"
fi
}
# Detect package manager and OS type
detect_os() {
if command -v apt &> /dev/null; then
OS_TYPE="debian"
PACKAGE_MANAGER="apt"
info "Detected Debian/Ubuntu-based system (apt)"
elif command -v dnf &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="dnf"
info "Detected RHEL/Fedora-based system (dnf)"
elif command -v yum &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="yum"
info "Detected RHEL/CentOS-based system (yum)"
else
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
fi
}
# Get the latest release download URL for the specific package type
get_download_url() {
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
info "Fetching latest release information..."
local release_info
release_info=$(curl -s "$api_url")
if echo "$release_info" | grep -q "message.*Not Found"; then
error "Could not fetch release information. Please check your internet connection."
fi
local tag_name
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$tag_name" ]; then
error "Could not determine latest release version."
fi
info "Latest version: $tag_name"
local download_url=""
if [ "$OS_TYPE" = "debian" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
elif [ "$OS_TYPE" = "rhel" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$download_url" ]; then
error "Could not find download URL for $OS_TYPE package."
fi
echo "$download_url"
}
# Install the package
install_package() {
local download_url="$1"
local temp_dir
temp_dir=$(mktemp -d)
local package_file="$temp_dir/${APP_NAME}_latest"
info "Downloading package..."
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
warn "Trying to fix broken dependencies..."
$PACKAGE_MANAGER install -f -y
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
fi
# Cleanup
rm -rf "$temp_dir"
}
# Check if app is already installed
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
read -p "Do you want to reinstall? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
fi
fi
}
# Main installation flow
main() {
echo -e "${GREEN}"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ Psysonic Auto-Installer ║"
echo "║ Install the latest release from GitHub Releases ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
check_root
detect_os
check_installed
local download_url
download_url=$(get_download_url)
if [ -z "$download_url" ]; then
error "Failed to get download URL."
fi
info "Download URL: $download_url"
install_package "$download_url"
echo ""
success "Psysonic has been installed successfully!"
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
echo ""
}
# Run main function
main
+1323 -418
View File
File diff suppressed because it is too large Load Diff
+54 -5
View File
@@ -1,13 +1,13 @@
[package]
name = "psysonic"
version = "1.24.0"
version = "1.34.19"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
repository = ""
default-run = "psysonic"
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.89"
[lib]
name = "psysonic_lib"
@@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
@@ -30,10 +31,58 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
url = "2"
thread-priority = "1"
lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_UI_Controls",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[patch.crates-io]
# Local patch for Symphonia's isomp4 demuxer:
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
# Local patch for cpal's CoreAudio backend on macOS:
# - Forces IOType::DefaultOutput for all output streams so that macOS never
# triggers the TCC microphone-permission prompt. Upstream cpal uses
# IOType::HalOutput when the user pins a non-default device, which routes
# through AUHAL — that component is flagged as input-capable and triggers
# the mic consent dialog on first launch / after each update.
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
# system default). Surfaced in the Settings UI.
cpal = { path = "patches/cpal-0.15.3" }
+9 -4
View File
@@ -2,10 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
<key>com.apple.security.device.audio-input</key>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+8 -5
View File
@@ -2,10 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music player only — it does not record audio.
This description is shown if macOS prompts for microphone access
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+9 -1
View File
@@ -17,19 +17,27 @@
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
"window-state:allow-restore-state",
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:webview:allow-create-webview-window"
"core:webview:allow-create-webview-window",
"process:allow-restart",
"updater:default"
]
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,6 @@
{
"git": {
"sha1": "ac6cbb2ba55e61665a35ab88ae136a83380d1354"
},
"path_in_vcs": ""
}
+265
View File
@@ -0,0 +1,265 @@
name: cpal
on: [push, pull_request]
jobs:
clippy-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update apt
run: sudo apt update
- name: Install alsa
run: sudo apt-get install libasound2-dev
- name: Install libjack
run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
target: armv7-linux-androideabi
- name: Run clippy
run: cargo clippy --all --all-features
- name: Run clippy for Android target
run: cargo clippy --all --features asio --features oboe/fetch-prebuilt --target armv7-linux-androideabi
rustfmt-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Run rustfmt
run: cargo fmt --all -- --check
cargo-publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install rust
uses: dtolnay/rust-toolchain@stable
- name: Update apt
run: sudo apt update
- name: Install alsa
run: sudo apt-get install libasound2-dev
- name: Verify publish crate
uses: katyo/publish-crates@v2
with:
dry-run: true
ignore-unpublished-changes: true
- name: Publish crate
uses: katyo/publish-crates@v2
with:
ignore-unpublished-changes: true
registry-token: ${{ secrets.CRATESIO_TOKEN }}
ubuntu-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update apt
run: sudo apt update
- name: Install alsa
run: sudo apt-get install libasound2-dev
- name: Install libjack
run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0
- name: Install stable
uses: dtolnay/rust-toolchain@stable
- name: Run without features
run: cargo test --all --no-default-features --verbose
- name: Run all features
run: cargo test --all --all-features --verbose
linux-check-and-test-armv7:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
target: armv7-unknown-linux-gnueabihf
- name: Build image
run: docker build -t cross/cpal_armv7:v1 ./
- name: Install cross
run: cargo install cross
- name: Check without features for armv7
run: cross check --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose
- name: Test without features for armv7
run: cross test --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose
- name: Check all features for armv7
run: cross check --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose
- name: Test all features for armv7
run: cross test --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose
asmjs-wasm32-test:
strategy:
matrix:
target: [wasm32-unknown-emscripten]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Emscripten toolchain
uses: mymindstorm/setup-emsdk@v14
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.target }}
- name: Build beep example
run: cargo build --example beep --release --target ${{ matrix.target }}
wasm32-bindgen-test:
strategy:
matrix:
target: [wasm32-unknown-unknown]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.target }}
- name: Build beep example
run: cargo build --example beep --target ${{ matrix.target }} --features=wasm-bindgen
wasm32-wasi-test:
strategy:
matrix:
target: [wasm32-wasi]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.target }}
- name: Build beep example
run: cargo build --example beep --target ${{ matrix.target }}
windows-test:
strategy:
matrix:
version: [x86_64, i686]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install ASIO SDK
env:
LINK: https://www.steinberg.net/asiosdk
run: |
curl -L -o asio.zip $env:LINK
7z x -oasio asio.zip
move asio\*\* asio\
- name: Install ASIO4ALL
run: choco install asio4all
- name: Install llvm and clang
run: choco install llvm
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.version }}-pc-windows-msvc
- name: Run without features
run: cargo test --all --no-default-features --verbose
- name: Run all features
run: |
$Env:CPAL_ASIO_DIR = "$Env:GITHUB_WORKSPACE\asio"
cargo test --all --all-features --verbose
macos-test:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v4
- name: Install llvm and clang
run: brew install llvm
- name: Install stable
uses: dtolnay/rust-toolchain@stable
- name: Build beep example
run: cargo build --example beep
- name: Run without features
run: cargo test --all --no-default-features --verbose
- name: Run all features
run: cargo test --all --all-features --verbose
android-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable (Android target)
uses: dtolnay/rust-toolchain@stable
with:
target: armv7-linux-androideabi
- name: Check android
run: cargo check --example android --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
- name: Check beep
run: cargo check --example beep --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
- name: Check enumerate
run: cargo check --example enumerate --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
- name: Check feedback
run: cargo check --example feedback --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
- name: Check record_wav
run: cargo check --example record_wav --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose
android-apk-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable (Android targets)
uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-linux-androideabi,aarch64-linux-android,i686-linux-android,x86_64-linux-android
- name: Set Up Android tools
run: |
${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT --install "platforms;android-30"
- name: Install Cargo APK
run: cargo install cargo-apk
- name: Build APK
run: cargo apk build --example android
ios-build:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v4
- name: Install llvm and clang
run: brew install llvm
- name: Install stable (iOS targets)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios,x86_64-apple-ios
- name: Install cargo lipo
run: cargo install cargo-lipo
- name: Build iphonesimulator feedback example
run: cd examples/ios-feedback && xcodebuild -scheme cpal-ios-example -configuration Debug -derivedDataPath build -sdk iphonesimulator
wasm-beep-build:
# this only confirms that the Rust source builds
# and checks to prevent regressions like #721.
#
# It does not test the javascript/web integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable (wasm32 target)
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cargo Build
working-directory: ./examples/wasm-beep
run: cargo build --target wasm32-unknown-unknown
+6
View File
@@ -0,0 +1,6 @@
/target
/Cargo.lock
.cargo/
.DS_Store
recorded.wav
rls*.log
+196
View File
@@ -0,0 +1,196 @@
# Unreleased
# Version 0.15.3 (2024-03-04)
- Add `try_with_sample_rate`, a non-panicking variant of `with_sample_rate`.
- struct `platform::Stream` is now #[must_use].
- enum `SupportedBufferSize` and struct `SupportedStreamConfigRange` are now `Copy`.
- `platform::Device` is now `Clone`.
- Remove `parking_lot` dependency in favor of the std library.
- Fix crash on web/wasm when `atomics` flag is enabled.
- Improve Examples: Migrate wasm example to `trunk`, Improve syth-thones example.
- Improve CI: Update actions, Use Android 30 API level in CI, Remove `asmjs-unknown-emscripten` target.
- Update `windows` dependency to v0.54
- Update `jni` dependency to 0.21
- Update `alsa` dependency to 0.9
- Update `oboe` dependency to 0.6
- Update `ndk` dependency to 0.8 and disable `default-features`.
- Update `wasm-bindgen` to 0.2.89
# Version 0.15.2 (2023-03-30)
- webaudio: support multichannel output streams
- Update `windows` dependency
- wasapi: fix some thread panics
# Version 0.15.1 (2023-03-14)
- Add feature `oboe-shared-stdcxx` to enable `shared-stdcxx` on `oboe` for Android support
- Remove `thiserror` dependency
- Swith `mach` dependency to `mach2`
# Version 0.15.0 (2023-01-29)
- Update `windows-rs`, `jack`, `coreaudio-sys`, `oboe`, `alsa` dependencies
- Switch to the `dasp_sample` crate for the sample trait
- Switch to `web-sys` on the emscripten target
- Adopt edition 2021
- Add disconnection detection on Mac OS
# Version 0.14.1 (2022-10-23)
- Support the 0.6.1 release of `alsa-rs`
- Fix `asio` feature broken in 0.14.0
- `NetBSD` support
- CI improvements
# Version 0.14.0 (2022-08-22)
- Switch to `windows-rs` crate
- Turn `ndk-glue` into a dev-dependency and use `ndk-context` instead
- Update dependencies (ndk, ndk-glue, parking_lot, once_cell, jack)
# Version 0.13.5 (2022-01-28)
- Faster sample format conversion
- Update dependencies (ndk, oboe, ndk-glue, jack, alsa, nix)
# Version 0.13.4 (2021-08-08)
- wasapi: Allow both threading models and switch the default to STA
- Update dependencies (core-foundation-sys, jni, rust-jack)
- Alsa: improve stream setup parameters
# Version 0.13.3 (2021-03-29)
- Give each thread a unique name
- Fix distortion regression on some alsa configs
# Version 0.13.2 (2021-03-16)
- Update dependencies (ndk, nix, oboe, jni, etc)
# Version 0.13.1 (2020-11-08)
- Don't panic when device is plugged out on Windows
- Update `parking_lot` dependency
# Version 0.13.0 (2020-10-28)
- Add Android support via `oboe-rs`.
- Add Android APK build an CI job.
# Version 0.12.1 (2020-07-23)
- Bugfix release to get the asio feature working again.
# Version 0.12.0 (2020-07-09)
- Large refactor removing the blocking EventLoop API.
- Rename many `Format` types to `StreamConfig`:
- `Format` type's `data_type` field renamed to `sample_format`.
- `Shape` -> `StreamConfig` - The configuration input required to build a stream.
- `Format` -> `SupportedStreamConfig` - Describes a single supported stream configuration.
- `SupportedFormat` -> `SupportedStreamConfigRange` - Describes a range of supported configurations.
- `Device::default_input/output_format` -> `Device::default_input/output_config`.
- `Device::supported_input/output_formats` -> `Device::supported_input/output_configs`.
- `Device::SupportedInput/OutputFormats` -> `Device::SupportedInput/OutputConfigs`.
- `SupportedFormatsError` -> `SupportedStreamConfigsError`
- `DefaultFormatError` -> `DefaultStreamConfigError`
- `BuildStreamError::FormatNotSupported` -> `BuildStreamError::StreamConfigNotSupported`
- Address deprecated use of `mem::uninitialized` in WASAPI.
- Removed `UnknownTypeBuffer` in favour of specifying sample type.
- Added `build_input/output_stream_raw` methods allowing for dynamically
handling sample format type.
- Added support for DragonFly platform.
- Add `InputCallbackInfo` and `OutputCallbackInfo` types and update expected
user data callback function signature to provide these.
# Version 0.11.0 (2019-12-11)
- Fix some underruns that could occur in ALSA.
- Add name to `HostId`.
- Use `snd_pcm_hw_params_set_buffer_time_near` rather than `set_buffer_time_max`
in ALSA backend.
- Remove many uses of `std::mem::uninitialized`.
- Fix WASAPI capture logic.
- Panic on stream ID overflow rather than returning an error.
- Use `ringbuffer` crate in feedback example.
- Move errors into a separate module.
- Switch from `failure` to `thiserror` for error handling.
- Add `winbase` winapi feature to solve windows compile error issues.
- Lots of CI improvements.
# Version 0.10.0 (2019-07-05)
- core-foundation-sys and coreaudio-rs version bumps.
- Add an ASIO host, available under Windows.
- Introduce a new Host API, adding support for alternative audio APIs.
- Remove sleep loop on macOS in favour of using a `Condvar`.
- Allow users to handle stream callback errors with a new `StreamEvent` type.
- Overhaul error handling throughout the crate.
- Remove unnecessary Mutex from ALSA and WASAPI backends in favour of channels.
- Remove `panic!` from OutputBuffer Deref impl as it is no longer necessary.
# Version 0.9.0 (2019-06-06)
- Better buffer handling
- Fix logic error in frame/sample size
- Added error handling for unknown ALSA device errors
- Fix resuming a paused stream on Windows (wasapi).
- Implement `default_output_format` for emscripten backend.
# Version 0.8.1 (2018-03-18)
- Fix the handling of non-default sample rates for coreaudio input streams.
# Version 0.8.0 (2018-02-15)
- Add `record_wav.rs` example. Records 3 seconds to
`$CARGO_MANIFEST_DIR/recorded.wav` using default input device.
- Update `enumerate.rs` example to display default input/output devices and
formats.
- Add input stream support to coreaudio, alsa and windows backends.
- Introduce `StreamData` type for handling either input or output streams in
`EventLoop::run` callback.
- Add `Device::supported_{input/output}_formats` methods.
- Add `Device::default_{input/output}_format` methods.
- Add `default_{input/output}_device` functions.
- Replace usage of `Voice` with `Stream` throughout the crate.
- Remove `Endpoint` in favour of `Device` for supporting both input and output
streams.
# Version 0.7.0 (2018-02-04)
- Rename `ChannelsCount` to `ChannelCount`.
- Rename `SamplesRate` to `SampleRate`.
- Rename the `min_samples_rate` field of `SupportedFormat` to `min_sample_rate`
- Rename the `with_max_samples_rate()` method of`SupportedFormat` to `with_max_sample_rate()`
- Rename the `samples_rate` field of `Format` to `sample_rate`
- Changed the type of the `channels` field of the `SupportedFormat` struct from `Vec<ChannelPosition>` to `ChannelCount` (an alias to `u16`)
- Remove unused ChannelPosition API.
- Implement `Endpoint` and `Format` Enumeration for macOS.
- Implement format handling for macos `build_voice` method.
# Version 0.6.0 (2017-12-11)
- Changed the emscripten backend to consume less CPU.
- Added improvements to the crate documentation.
- Implement `pause` and `play` for ALSA backend.
- Reduced the number of allocations in the CoreAudio backend.
- Fixes for macOS build (#186, #189).
# Version 0.5.1 (2017-10-21)
- Added `Sample::to_i16()`, `Sample::to_u16()` and `Sample::from`.
# Version 0.5.0 (2017-10-21)
- Removed the dependency on the `futures` library.
- Removed the `Voice` and `SamplesStream` types.
- Added `EventLoop::build_voice`, `EventLoop::destroy_voice`, `EventLoop::play`,
and `EventLoop::pause` that can be used to create, destroy, play and pause voices.
- Added a `VoiceId` struct that is now used to identify a voice owned by an `EventLoop`.
- Changed `EventLoop::run()` to take a callback that is called whenever a voice requires sound data.
- Changed `supported_formats()` to produce a list of `SupportedFormat` instead of `Format`. A
`SupportedFormat` must then be turned into a `Format` in order to build a voice.
+184
View File
@@ -0,0 +1,184 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.70"
name = "cpal"
version = "0.15.3"
description = "Low-level cross-platform audio I/O library in pure Rust."
documentation = "https://docs.rs/cpal"
readme = "README.md"
keywords = [
"audio",
"sound",
]
license = "Apache-2.0"
repository = "https://github.com/rustaudio/cpal"
[[example]]
name = "android"
crate-type = ["cdylib"]
path = "examples/android.rs"
[[example]]
name = "beep"
[[example]]
name = "enumerate"
[[example]]
name = "feedback"
[[example]]
name = "record_wav"
[[example]]
name = "synth_tones"
[dependencies.dasp_sample]
version = "0.11"
[dev-dependencies.anyhow]
version = "1.0"
[dev-dependencies.clap]
version = "4.0"
features = ["derive"]
[dev-dependencies.hound]
version = "3.5"
[dev-dependencies.ringbuf]
version = "0.3"
[features]
asio = [
"asio-sys",
"num-traits",
]
oboe-shared-stdcxx = ["oboe/shared-stdcxx"]
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.js-sys]
version = "0.3.35"
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.wasm-bindgen]
version = "0.2.58"
optional = true
[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.web-sys]
version = "0.3.35"
features = [
"AudioContext",
"AudioContextOptions",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioNode",
"AudioDestinationNode",
"Window",
"AudioContextState",
]
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.alsa]
version = "0.9"
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.jack]
version = "0.11"
optional = true
[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.libc]
version = "0.2"
[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.core-foundation-sys]
version = "0.8.2"
[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.mach2]
version = "0.4"
[target."cfg(target_os = \"android\")".dependencies.jni]
version = "0.21"
[target."cfg(target_os = \"android\")".dependencies.ndk]
version = "0.8"
default-features = false
[target."cfg(target_os = \"android\")".dependencies.ndk-context]
version = "0.1"
[target."cfg(target_os = \"android\")".dependencies.oboe]
version = "0.6"
features = ["java-interface"]
[target."cfg(target_os = \"android\")".dev-dependencies.ndk-glue]
version = "0.7"
[target."cfg(target_os = \"emscripten\")".dependencies.js-sys]
version = "0.3.35"
[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen]
version = "0.2.89"
[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen-futures]
version = "0.4.33"
[target."cfg(target_os = \"emscripten\")".dependencies.web-sys]
version = "0.3.35"
features = [
"AudioContext",
"AudioContextOptions",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioNode",
"AudioDestinationNode",
"Window",
"AudioContextState",
]
[target."cfg(target_os = \"ios\")".dependencies.coreaudio-rs]
version = "0.11"
features = [
"audio_unit",
"core_audio",
"audio_toolbox",
]
default-features = false
[target."cfg(target_os = \"macos\")".dependencies.coreaudio-rs]
version = "0.11"
features = [
"audio_unit",
"core_audio",
]
default-features = false
[target."cfg(target_os = \"windows\")".dependencies.asio-sys]
version = "0.2"
optional = true
[target."cfg(target_os = \"windows\")".dependencies.num-traits]
version = "0.2.6"
optional = true
[target."cfg(target_os = \"windows\")".dependencies.windows]
version = "0.54.0"
features = [
"Win32_Media_Audio",
"Win32_Foundation",
"Win32_Devices_Properties",
"Win32_Media_KernelStreaming",
"Win32_System_Com_StructuredStorage",
"Win32_System_Threading",
"Win32_Security",
"Win32_System_SystemServices",
"Win32_System_Variant",
"Win32_Media_Multimedia",
"Win32_UI_Shell_PropertiesSystem",
]
+95
View File
@@ -0,0 +1,95 @@
[package]
name = "cpal"
version = "0.15.3"
description = "Low-level cross-platform audio I/O library in pure Rust."
repository = "https://github.com/rustaudio/cpal"
documentation = "https://docs.rs/cpal"
license = "Apache-2.0"
keywords = ["audio", "sound"]
edition = "2021"
rust-version = "1.70"
[features]
asio = ["asio-sys", "num-traits"] # Only available on Windows. See README for setup instructions.
oboe-shared-stdcxx = ["oboe/shared-stdcxx"] # Only available on Android. See README for what it does.
[dependencies]
dasp_sample = "0.11"
[dev-dependencies]
anyhow = "1.0"
hound = "3.5"
ringbuf = "0.3"
clap = { version = "4.0", features = ["derive"] }
[target.'cfg(target_os = "android")'.dev-dependencies]
ndk-glue = "0.7"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.54.0", features = [
"Win32_Media_Audio",
"Win32_Foundation",
"Win32_Devices_Properties",
"Win32_Media_KernelStreaming",
"Win32_System_Com_StructuredStorage",
"Win32_System_Threading",
"Win32_Security",
"Win32_System_SystemServices",
"Win32_System_Variant",
"Win32_Media_Multimedia",
"Win32_UI_Shell_PropertiesSystem"
]}
asio-sys = { version = "0.2", path = "asio-sys", optional = true }
num-traits = { version = "0.2.6", optional = true }
[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd"))'.dependencies]
alsa = "0.9"
libc = "0.2"
jack = { version = "0.11", optional = true }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
core-foundation-sys = "0.8.2" # For linking to CoreFoundation.framework and handling device name `CFString`s.
mach2 = "0.4" # For access to mach_timebase type.
[target.'cfg(target_os = "macos")'.dependencies]
coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio"] }
[target.'cfg(target_os = "ios")'.dependencies]
coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio", "audio_toolbox"] }
[target.'cfg(target_os = "emscripten")'.dependencies]
wasm-bindgen = { version = "0.2.89" }
wasm-bindgen-futures = "0.4.33"
js-sys = { version = "0.3.35" }
web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
wasm-bindgen = { version = "0.2.58", optional = true }
js-sys = { version = "0.3.35" }
web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] }
[target.'cfg(target_os = "android")'.dependencies]
oboe = { version = "0.6", features = [ "java-interface" ] }
ndk = { version = "0.8", default-features = false }
ndk-context = "0.1"
jni = "0.21"
[[example]]
name = "android"
path = "examples/android.rs"
crate-type = ["cdylib"]
[[example]]
name = "beep"
[[example]]
name = "enumerate"
[[example]]
name = "feedback"
[[example]]
name = "record_wav"
[[example]]
name = "synth_tones"
+7
View File
@@ -0,0 +1,7 @@
[target.armv7-unknown-linux-gnueabihf]
image = "cross/cpal_armv7:v1"
[target.armv7-unknown-linux-gnueabihf.env]
passthrough = [
"RUSTFLAGS",
]
+9
View File
@@ -0,0 +1,9 @@
FROM rustembedded/cross:armv7-unknown-linux-gnueabihf
ENV PKG_CONFIG_ALLOW_CROSS 1
ENV PKG_CONFIG_PATH /usr/lib/arm-linux-gnueabihf/pkgconfig/
RUN dpkg --add-architecture armhf && \
apt-get update && \
apt-get install libasound2-dev:armhf -y && \
apt-get install libjack-jackd2-dev:armhf libjack-jackd2-0:armhf -y \
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+142
View File
@@ -0,0 +1,142 @@
# CPAL - Cross-Platform Audio Library
[![Actions Status](https://github.com/RustAudio/cpal/workflows/cpal/badge.svg)](https://github.com/RustAudio/cpal/actions)
[![Crates.io](https://img.shields.io/crates/v/cpal.svg)](https://crates.io/crates/cpal) [![docs.rs](https://docs.rs/cpal/badge.svg)](https://docs.rs/cpal/)
Low-level library for audio input and output in pure Rust.
This library currently supports the following:
- Enumerate supported audio hosts.
- Enumerate all available audio devices.
- Get the current default input and output devices.
- Enumerate known supported input and output stream formats for a device.
- Get the current default input and output stream formats for a device.
- Build and run input and output PCM streams on a chosen device with a given stream format.
Currently, supported hosts include:
- Linux (via ALSA or JACK)
- Windows (via WASAPI by default, see ASIO instructions below)
- macOS (via CoreAudio)
- iOS (via CoreAudio)
- Android (via Oboe)
- Emscripten
Note that on Linux, the ALSA development files are required. These are provided
as part of the `libasound2-dev` package on Debian and Ubuntu distributions and
`alsa-lib-devel` on Fedora.
## Compiling for Web Assembly
If you are interested in using CPAL with WASM, please see [this guide](https://github.com/RustAudio/cpal/wiki/Setting-up-a-new-CPAL-WASM-project) in our Wiki which walks through setting up a new project from scratch.
## Feature flags for audio backends
Some audio backends are optional and will only be compiled with a [feature flag](https://doc.rust-lang.org/cargo/reference/features.html).
- JACK (on Linux): `jack`
- ASIO (on Windows): `asio`
Oboe can either use a shared or static runtime. The static runtime is used by default, but activating the
`oboe-shared-stdcxx` feature makes it use the shared runtime, which requires `libc++_shared.so` from the Android NDK to
be present during execution.
## ASIO on Windows
[ASIO](https://en.wikipedia.org/wiki/Audio_Stream_Input/Output) is an audio
driver protocol by Steinberg. While it is available on multiple operating
systems, it is most commonly used on Windows to work around limitations of
WASAPI including access to large numbers of channels and lower-latency audio
processing.
CPAL allows for using the ASIO SDK as the audio host on Windows instead of
WASAPI.
### Locating the ASIO SDK
The location of ASIO SDK is exposed to CPAL by setting the `CPAL_ASIO_DIR` environment variable.
The build script will try to find the ASIO SDK by following these steps in order:
1. Check if `CPAL_ASIO_DIR` is set and if so use the path to point to the SDK.
2. Check if the ASIO SDK is already installed in the temporary directory, if so use that and set the path of `CPAL_ASIO_DIR` to the output of `std::env::temp_dir().join("asio_sdk")`.
3. If the ASIO SDK is not already installed, download it from <https://www.steinberg.net/asiosdk> and install it in the temporary directory. The path of `CPAL_ASIO_DIR` will be set to the output of `std::env::temp_dir().join("asio_sdk")`.
In an ideal situation you don't need to worry about this step.
### Preparing the build environment
1. `bindgen`, the library used to generate bindings to the C++ SDK, requires
clang. **Download and install LLVM** from
[here](http://releases.llvm.org/download.html) under the "Pre-Built Binaries"
section. The version as of writing this is 17.0.1.
2. Add the LLVM `bin` directory to a `LIBCLANG_PATH` environment variable. If
you installed LLVM to the default directory, this should work in the command
prompt:
```
setx LIBCLANG_PATH "C:\Program Files\LLVM\bin"
```
3. If you don't have any ASIO devices or drivers available, you can [**download
and install ASIO4ALL**](http://www.asio4all.org/). Be sure to enable the
"offline" feature during installation despite what the installer says about
it being useless.
4. Our build script assumes that Microsoft Visual Studio is installed if the host OS for compilation is Windows. The script will try to find `vcvarsall.bat`
and execute it with the right host and target machine architecture regardless of the Microsoft Visual Studio version.
If there are any errors encountered in this process which is unlikely,
you may find the `vcvarsall.bat` manually and execute it with your machine architecture as an argument.
The script will detect this and skip the step.
A manually executed command example for 64 bit machines:
```
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" amd64
```
For more information please refer to the documentation of [`vcvarsall.bat``](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax).
5. Select the ASIO host at the start of our program with the following code:
```rust
let host;
#[cfg(target_os = "windows")]
{
host = cpal::host_from_id(cpal::HostId::Asio).expect("failed to initialise ASIO host");
}
```
If you run into compilations errors produced by `asio-sys` or `bindgen`, make
sure that `CPAL_ASIO_DIR` is set correctly and try `cargo clean`.
6. Make sure to enable the `asio` feature when building CPAL:
```
cargo build --features "asio"
```
or if you are using CPAL as a dependency in a downstream project, enable the
feature like this:
```toml
cpal = { version = "*", features = ["asio"] }
```
_Updated as of ASIO version 2.3.3._
### Cross compilation
When Windows is the host and the target OS, the build script of `asio-sys` supports all cross compilation targets
which are supported by the MSVC compiler. An exhaustive list of combinations could be found [here](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax) with the addition of undocumented `arm64`, `arm64_x86`, `arm64_amd64` and `arm64_arm` targets. (5.11.2023)
It is also possible to compile Windows applications with ASIO support on Linux and macOS.
For both platforms the common way to do this is to use the [MinGW-w64](https://www.mingw-w64.org/) toolchain.
Make sure that you have included the `MinGW-w64` include directory in your `CPLUS_INCLUDE_PATH` environment variable.
Make sure that LLVM is installed and include directory is also included in your `CPLUS_INCLUDE_PATH` environment variable.
Example for macOS for the target of `x86_64-pc-windows-gnu` where `mingw-w64` is installed via brew:
```
export CPLUS_INCLUDE_PATH="$CPLUS_INCLUDE_PATH:/opt/homebrew/Cellar/mingw-w64/11.0.1/toolchain-x86_64/x86_64-w64-mingw32/include"
```
+14
View File
@@ -0,0 +1,14 @@
use std::env;
const CPAL_ASIO_DIR: &str = "CPAL_ASIO_DIR";
fn main() {
println!("cargo:rerun-if-env-changed={}", CPAL_ASIO_DIR);
// If ASIO directory isn't set silently return early
// otherwise set the asio config flag
match env::var(CPAL_ASIO_DIR) {
Err(_) => {}
Ok(_) => println!("cargo:rustc-cfg=asio"),
};
}
@@ -0,0 +1,82 @@
#![allow(dead_code)]
extern crate anyhow;
extern crate cpal;
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
SizedSample,
};
use cpal::{FromSample, Sample};
#[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "full"))]
fn main() {
let host = cpal::default_host();
let device = host
.default_output_device()
.expect("failed to find output device");
let config = device.default_output_config().unwrap();
match config.sample_format() {
cpal::SampleFormat::I8 => run::<i8>(&device, &config.into()).unwrap(),
cpal::SampleFormat::I16 => run::<i16>(&device, &config.into()).unwrap(),
// cpal::SampleFormat::I24 => run::<I24>(&device, &config.into()).unwrap(),
cpal::SampleFormat::I32 => run::<i32>(&device, &config.into()).unwrap(),
// cpal::SampleFormat::I48 => run::<I48>(&device, &config.into()).unwrap(),
cpal::SampleFormat::I64 => run::<i64>(&device, &config.into()).unwrap(),
cpal::SampleFormat::U8 => run::<u8>(&device, &config.into()).unwrap(),
cpal::SampleFormat::U16 => run::<u16>(&device, &config.into()).unwrap(),
// cpal::SampleFormat::U24 => run::<U24>(&device, &config.into()).unwrap(),
cpal::SampleFormat::U32 => run::<u32>(&device, &config.into()).unwrap(),
// cpal::SampleFormat::U48 => run::<U48>(&device, &config.into()).unwrap(),
cpal::SampleFormat::U64 => run::<u64>(&device, &config.into()).unwrap(),
cpal::SampleFormat::F32 => run::<f32>(&device, &config.into()).unwrap(),
cpal::SampleFormat::F64 => run::<f64>(&device, &config.into()).unwrap(),
sample_format => panic!("Unsupported sample format '{sample_format}'"),
}
}
fn run<T>(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error>
where
T: SizedSample + FromSample<f32>,
{
let sample_rate = config.sample_rate.0 as f32;
let channels = config.channels as usize;
// Produce a sinusoid of maximum amplitude.
let mut sample_clock = 0f32;
let mut next_value = move || {
sample_clock = (sample_clock + 1.0) % sample_rate;
(sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin()
};
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
let stream = device.build_output_stream(
config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
write_data(data, channels, &mut next_value)
},
err_fn,
None,
)?;
stream.play()?;
std::thread::sleep(std::time::Duration::from_millis(1000));
Ok(())
}
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
where
T: Sample + FromSample<f32>,
{
for frame in output.chunks_mut(channels) {
let value: T = T::from_sample(next_sample());
for sample in frame.iter_mut() {
*sample = value;
}
}
}
@@ -0,0 +1,138 @@
use clap::Parser;
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
FromSample, Sample, SizedSample,
};
#[derive(Parser, Debug)]
#[command(version, about = "CPAL beep example", long_about = None)]
struct Opt {
/// The audio device to use
#[arg(short, long, default_value_t = String::from("default"))]
device: String,
/// Use the JACK host
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
#[arg(short, long)]
#[allow(dead_code)]
jack: bool,
}
fn main() -> anyhow::Result<()> {
let opt = Opt::parse();
// Conditionally compile with jack if the feature is specified.
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
// Manually check for flags. Can be passed through cargo with -- e.g.
// cargo run --release --example beep --features jack -- --jack
let host = if opt.jack {
cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == cpal::HostId::Jack)
.expect(
"make sure --features jack is specified. only works on OSes where jack is available",
)).expect("jack host unavailable")
} else {
cpal::default_host()
};
#[cfg(any(
not(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
)),
not(feature = "jack")
))]
let host = cpal::default_host();
let device = if opt.device == "default" {
host.default_output_device()
} else {
host.output_devices()?
.find(|x| x.name().map(|y| y == opt.device).unwrap_or(false))
}
.expect("failed to find output device");
println!("Output device: {}", device.name()?);
let config = device.default_output_config().unwrap();
println!("Default output config: {:?}", config);
match config.sample_format() {
cpal::SampleFormat::I8 => run::<i8>(&device, &config.into()),
cpal::SampleFormat::I16 => run::<i16>(&device, &config.into()),
// cpal::SampleFormat::I24 => run::<I24>(&device, &config.into()),
cpal::SampleFormat::I32 => run::<i32>(&device, &config.into()),
// cpal::SampleFormat::I48 => run::<I48>(&device, &config.into()),
cpal::SampleFormat::I64 => run::<i64>(&device, &config.into()),
cpal::SampleFormat::U8 => run::<u8>(&device, &config.into()),
cpal::SampleFormat::U16 => run::<u16>(&device, &config.into()),
// cpal::SampleFormat::U24 => run::<U24>(&device, &config.into()),
cpal::SampleFormat::U32 => run::<u32>(&device, &config.into()),
// cpal::SampleFormat::U48 => run::<U48>(&device, &config.into()),
cpal::SampleFormat::U64 => run::<u64>(&device, &config.into()),
cpal::SampleFormat::F32 => run::<f32>(&device, &config.into()),
cpal::SampleFormat::F64 => run::<f64>(&device, &config.into()),
sample_format => panic!("Unsupported sample format '{sample_format}'"),
}
}
pub fn run<T>(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error>
where
T: SizedSample + FromSample<f32>,
{
let sample_rate = config.sample_rate.0 as f32;
let channels = config.channels as usize;
// Produce a sinusoid of maximum amplitude.
let mut sample_clock = 0f32;
let mut next_value = move || {
sample_clock = (sample_clock + 1.0) % sample_rate;
(sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin()
};
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
let stream = device.build_output_stream(
config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
write_data(data, channels, &mut next_value)
},
err_fn,
None,
)?;
stream.play()?;
std::thread::sleep(std::time::Duration::from_millis(1000));
Ok(())
}
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
where
T: Sample + FromSample<f32>,
{
for frame in output.chunks_mut(channels) {
let value: T = T::from_sample(next_sample());
for sample in frame.iter_mut() {
*sample = value;
}
}
}
@@ -0,0 +1,74 @@
extern crate anyhow;
extern crate cpal;
use cpal::traits::{DeviceTrait, HostTrait};
fn main() -> Result<(), anyhow::Error> {
println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS);
let available_hosts = cpal::available_hosts();
println!("Available hosts:\n {:?}", available_hosts);
for host_id in available_hosts {
println!("{}", host_id.name());
let host = cpal::host_from_id(host_id)?;
let default_in = host.default_input_device().map(|e| e.name().unwrap());
let default_out = host.default_output_device().map(|e| e.name().unwrap());
println!(" Default Input Device:\n {:?}", default_in);
println!(" Default Output Device:\n {:?}", default_out);
let devices = host.devices()?;
println!(" Devices: ");
for (device_index, device) in devices.enumerate() {
println!(" {}. \"{}\"", device_index + 1, device.name()?);
// Input configs
if let Ok(conf) = device.default_input_config() {
println!(" Default input stream config:\n {:?}", conf);
}
let input_configs = match device.supported_input_configs() {
Ok(f) => f.collect(),
Err(e) => {
println!(" Error getting supported input configs: {:?}", e);
Vec::new()
}
};
if !input_configs.is_empty() {
println!(" All supported input stream configs:");
for (config_index, config) in input_configs.into_iter().enumerate() {
println!(
" {}.{}. {:?}",
device_index + 1,
config_index + 1,
config
);
}
}
// Output configs
if let Ok(conf) = device.default_output_config() {
println!(" Default output stream config:\n {:?}", conf);
}
let output_configs = match device.supported_output_configs() {
Ok(f) => f.collect(),
Err(e) => {
println!(" Error getting supported output configs: {:?}", e);
Vec::new()
}
};
if !output_configs.is_empty() {
println!(" All supported output stream configs:");
for (config_index, config) in output_configs.into_iter().enumerate() {
println!(
" {}.{}. {:?}",
device_index + 1,
config_index + 1,
config
);
}
}
}
}
Ok(())
}
@@ -0,0 +1,174 @@
//! Feeds back the input stream directly into the output stream.
//!
//! Assumes that the input and output devices can use the same stream configuration and that they
//! support the f32 sample format.
//!
//! Uses a delay of `LATENCY_MS` milliseconds in case the default input and output streams are not
//! precisely synchronised.
use clap::Parser;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::HeapRb;
#[derive(Parser, Debug)]
#[command(version, about = "CPAL feedback example", long_about = None)]
struct Opt {
/// The input audio device to use
#[arg(short, long, value_name = "IN", default_value_t = String::from("default"))]
input_device: String,
/// The output audio device to use
#[arg(short, long, value_name = "OUT", default_value_t = String::from("default"))]
output_device: String,
/// Specify the delay between input and output
#[arg(short, long, value_name = "DELAY_MS", default_value_t = 150.0)]
latency: f32,
/// Use the JACK host
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
#[arg(short, long)]
#[allow(dead_code)]
jack: bool,
}
fn main() -> anyhow::Result<()> {
let opt = Opt::parse();
// Conditionally compile with jack if the feature is specified.
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
// Manually check for flags. Can be passed through cargo with -- e.g.
// cargo run --release --example beep --features jack -- --jack
let host = if opt.jack {
cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == cpal::HostId::Jack)
.expect(
"make sure --features jack is specified. only works on OSes where jack is available",
)).expect("jack host unavailable")
} else {
cpal::default_host()
};
#[cfg(any(
not(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
)),
not(feature = "jack")
))]
let host = cpal::default_host();
// Find devices.
let input_device = if opt.input_device == "default" {
host.default_input_device()
} else {
host.input_devices()?
.find(|x| x.name().map(|y| y == opt.input_device).unwrap_or(false))
}
.expect("failed to find input device");
let output_device = if opt.output_device == "default" {
host.default_output_device()
} else {
host.output_devices()?
.find(|x| x.name().map(|y| y == opt.output_device).unwrap_or(false))
}
.expect("failed to find output device");
println!("Using input device: \"{}\"", input_device.name()?);
println!("Using output device: \"{}\"", output_device.name()?);
// We'll try and use the same configuration between streams to keep it simple.
let config: cpal::StreamConfig = input_device.default_input_config()?.into();
// Create a delay in case the input and output devices aren't synced.
let latency_frames = (opt.latency / 1_000.0) * config.sample_rate.0 as f32;
let latency_samples = latency_frames as usize * config.channels as usize;
// The buffer to share samples
let ring = HeapRb::<f32>::new(latency_samples * 2);
let (mut producer, mut consumer) = ring.split();
// Fill the samples with 0.0 equal to the length of the delay.
for _ in 0..latency_samples {
// The ring buffer has twice as much space as necessary to add latency here,
// so this should never fail
producer.push(0.0).unwrap();
}
let input_data_fn = move |data: &[f32], _: &cpal::InputCallbackInfo| {
let mut output_fell_behind = false;
for &sample in data {
if producer.push(sample).is_err() {
output_fell_behind = true;
}
}
if output_fell_behind {
eprintln!("output stream fell behind: try increasing latency");
}
};
let output_data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut input_fell_behind = false;
for sample in data {
*sample = match consumer.pop() {
Some(s) => s,
None => {
input_fell_behind = true;
0.0
}
};
}
if input_fell_behind {
eprintln!("input stream fell behind: try increasing latency");
}
};
// Build streams.
println!(
"Attempting to build both streams with f32 samples and `{:?}`.",
config
);
let input_stream = input_device.build_input_stream(&config, input_data_fn, err_fn, None)?;
let output_stream = output_device.build_output_stream(&config, output_data_fn, err_fn, None)?;
println!("Successfully built streams.");
// Play the streams.
println!(
"Starting the input and output streams with `{}` milliseconds of latency.",
opt.latency
);
input_stream.play()?;
output_stream.play()?;
// Run for 3 seconds before closing.
println!("Playing for 3 seconds... ");
std::thread::sleep(std::time::Duration::from_secs(3));
drop(input_stream);
drop(output_stream);
println!("Done!");
Ok(())
}
fn err_fn(err: cpal::StreamError) {
eprintln!("an error occurred on stream: {}", err);
}
@@ -0,0 +1,177 @@
//! Records a WAV file (roughly 3 seconds long) using the default input device and config.
//!
//! The input data is recorded to "$CARGO_MANIFEST_DIR/recorded.wav".
use clap::Parser;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample};
use std::fs::File;
use std::io::BufWriter;
use std::sync::{Arc, Mutex};
#[derive(Parser, Debug)]
#[command(version, about = "CPAL record_wav example", long_about = None)]
struct Opt {
/// The audio device to use
#[arg(short, long, default_value_t = String::from("default"))]
device: String,
/// Use the JACK host
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
#[arg(short, long)]
#[allow(dead_code)]
jack: bool,
}
fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
// Conditionally compile with jack if the feature is specified.
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
// Manually check for flags. Can be passed through cargo with -- e.g.
// cargo run --release --example beep --features jack -- --jack
let host = if opt.jack {
cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == cpal::HostId::Jack)
.expect(
"make sure --features jack is specified. only works on OSes where jack is available",
)).expect("jack host unavailable")
} else {
cpal::default_host()
};
#[cfg(any(
not(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
)),
not(feature = "jack")
))]
let host = cpal::default_host();
// Set up the input device and stream with the default input config.
let device = if opt.device == "default" {
host.default_input_device()
} else {
host.input_devices()?
.find(|x| x.name().map(|y| y == opt.device).unwrap_or(false))
}
.expect("failed to find input device");
println!("Input device: {}", device.name()?);
let config = device
.default_input_config()
.expect("Failed to get default input config");
println!("Default input config: {:?}", config);
// The WAV file we're recording to.
const PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/recorded.wav");
let spec = wav_spec_from_config(&config);
let writer = hound::WavWriter::create(PATH, spec)?;
let writer = Arc::new(Mutex::new(Some(writer)));
// A flag to indicate that recording is in progress.
println!("Begin recording...");
// Run the input stream on a separate thread.
let writer_2 = writer.clone();
let err_fn = move |err| {
eprintln!("an error occurred on stream: {}", err);
};
let stream = match config.sample_format() {
cpal::SampleFormat::I8 => device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<i8, i8>(data, &writer_2),
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<i16, i16>(data, &writer_2),
err_fn,
None,
)?,
cpal::SampleFormat::I32 => device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<i32, i32>(data, &writer_2),
err_fn,
None,
)?,
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<f32, f32>(data, &writer_2),
err_fn,
None,
)?,
sample_format => {
return Err(anyhow::Error::msg(format!(
"Unsupported sample format '{sample_format}'"
)))
}
};
stream.play()?;
// Let recording go for roughly three seconds.
std::thread::sleep(std::time::Duration::from_secs(3));
drop(stream);
writer.lock().unwrap().take().unwrap().finalize()?;
println!("Recording {} complete!", PATH);
Ok(())
}
fn sample_format(format: cpal::SampleFormat) -> hound::SampleFormat {
if format.is_float() {
hound::SampleFormat::Float
} else {
hound::SampleFormat::Int
}
}
fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> hound::WavSpec {
hound::WavSpec {
channels: config.channels() as _,
sample_rate: config.sample_rate().0 as _,
bits_per_sample: (config.sample_format().sample_size() * 8) as _,
sample_format: sample_format(config.sample_format()),
}
}
type WavWriterHandle = Arc<Mutex<Option<hound::WavWriter<BufWriter<File>>>>>;
fn write_input_data<T, U>(input: &[T], writer: &WavWriterHandle)
where
T: Sample,
U: Sample + hound::Sample + FromSample<T>,
{
if let Ok(mut guard) = writer.try_lock() {
if let Some(writer) = guard.as_mut() {
for &sample in input.iter() {
let sample: U = U::from_sample(sample);
writer.write_sample(sample).ok();
}
}
}
}
@@ -0,0 +1,191 @@
/* This example expose parameter to pass generator of sample.
Good starting point for integration of cpal into your application.
*/
extern crate anyhow;
extern crate clap;
extern crate cpal;
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
SizedSample,
};
use cpal::{FromSample, Sample};
fn main() -> anyhow::Result<()> {
let stream = stream_setup_for()?;
stream.play()?;
std::thread::sleep(std::time::Duration::from_millis(4000));
Ok(())
}
pub enum Waveform {
Sine,
Square,
Saw,
Triangle,
}
pub struct Oscillator {
pub sample_rate: f32,
pub waveform: Waveform,
pub current_sample_index: f32,
pub frequency_hz: f32,
}
impl Oscillator {
fn advance_sample(&mut self) {
self.current_sample_index = (self.current_sample_index + 1.0) % self.sample_rate;
}
fn set_waveform(&mut self, waveform: Waveform) {
self.waveform = waveform;
}
fn calculate_sine_output_from_freq(&self, freq: f32) -> f32 {
let two_pi = 2.0 * std::f32::consts::PI;
(self.current_sample_index * freq * two_pi / self.sample_rate).sin()
}
fn is_multiple_of_freq_above_nyquist(&self, multiple: f32) -> bool {
self.frequency_hz * multiple > self.sample_rate / 2.0
}
fn sine_wave(&mut self) -> f32 {
self.advance_sample();
self.calculate_sine_output_from_freq(self.frequency_hz)
}
fn generative_waveform(&mut self, harmonic_index_increment: i32, gain_exponent: f32) -> f32 {
self.advance_sample();
let mut output = 0.0;
let mut i = 1;
while !self.is_multiple_of_freq_above_nyquist(i as f32) {
let gain = 1.0 / (i as f32).powf(gain_exponent);
output += gain * self.calculate_sine_output_from_freq(self.frequency_hz * i as f32);
i += harmonic_index_increment;
}
output
}
fn square_wave(&mut self) -> f32 {
self.generative_waveform(2, 1.0)
}
fn saw_wave(&mut self) -> f32 {
self.generative_waveform(1, 1.0)
}
fn triangle_wave(&mut self) -> f32 {
self.generative_waveform(2, 2.0)
}
fn tick(&mut self) -> f32 {
match self.waveform {
Waveform::Sine => self.sine_wave(),
Waveform::Square => self.square_wave(),
Waveform::Saw => self.saw_wave(),
Waveform::Triangle => self.triangle_wave(),
}
}
}
pub fn stream_setup_for() -> Result<cpal::Stream, anyhow::Error>
where
{
let (_host, device, config) = host_device_setup()?;
match config.sample_format() {
cpal::SampleFormat::I8 => make_stream::<i8>(&device, &config.into()),
cpal::SampleFormat::I16 => make_stream::<i16>(&device, &config.into()),
cpal::SampleFormat::I32 => make_stream::<i32>(&device, &config.into()),
cpal::SampleFormat::I64 => make_stream::<i64>(&device, &config.into()),
cpal::SampleFormat::U8 => make_stream::<u8>(&device, &config.into()),
cpal::SampleFormat::U16 => make_stream::<u16>(&device, &config.into()),
cpal::SampleFormat::U32 => make_stream::<u32>(&device, &config.into()),
cpal::SampleFormat::U64 => make_stream::<u64>(&device, &config.into()),
cpal::SampleFormat::F32 => make_stream::<f32>(&device, &config.into()),
cpal::SampleFormat::F64 => make_stream::<f64>(&device, &config.into()),
sample_format => Err(anyhow::Error::msg(format!(
"Unsupported sample format '{sample_format}'"
))),
}
}
pub fn host_device_setup(
) -> Result<(cpal::Host, cpal::Device, cpal::SupportedStreamConfig), anyhow::Error> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| anyhow::Error::msg("Default output device is not available"))?;
println!("Output device : {}", device.name()?);
let config = device.default_output_config()?;
println!("Default output config : {:?}", config);
Ok((host, device, config))
}
pub fn make_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
) -> Result<cpal::Stream, anyhow::Error>
where
T: SizedSample + FromSample<f32>,
{
let num_channels = config.channels as usize;
let mut oscillator = Oscillator {
waveform: Waveform::Sine,
sample_rate: config.sample_rate.0 as f32,
current_sample_index: 0.0,
frequency_hz: 440.0,
};
let err_fn = |err| eprintln!("Error building output sound stream: {}", err);
let time_at_start = std::time::Instant::now();
println!("Time at start: {:?}", time_at_start);
let stream = device.build_output_stream(
config,
move |output: &mut [T], _: &cpal::OutputCallbackInfo| {
// for 0-1s play sine, 1-2s play square, 2-3s play saw, 3-4s play triangle_wave
let time_since_start = std::time::Instant::now()
.duration_since(time_at_start)
.as_secs_f32();
if time_since_start < 1.0 {
oscillator.set_waveform(Waveform::Sine);
} else if time_since_start < 2.0 {
oscillator.set_waveform(Waveform::Triangle);
} else if time_since_start < 3.0 {
oscillator.set_waveform(Waveform::Square);
} else if time_since_start < 4.0 {
oscillator.set_waveform(Waveform::Saw);
} else {
oscillator.set_waveform(Waveform::Sine);
}
process_frame(output, &mut oscillator, num_channels)
},
err_fn,
None,
)?;
Ok(stream)
}
fn process_frame<SampleType>(
output: &mut [SampleType],
oscillator: &mut Oscillator,
num_channels: usize,
) where
SampleType: Sample + FromSample<f32>,
{
for frame in output.chunks_mut(num_channels) {
let value: SampleType = SampleType::from_sample(oscillator.tick());
// copy the same value to all channels
for sample in frame.iter_mut() {
*sample = value;
}
}
}
+292
View File
@@ -0,0 +1,292 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// The requested host, although supported on this platform, is unavailable.
#[derive(Copy, Clone, Debug)]
pub struct HostUnavailable;
impl Display for HostUnavailable {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("the requested host is unavailable")
}
}
impl Error for HostUnavailable {}
/// Some error has occurred that is specific to the backend from which it was produced.
///
/// This error is often used as a catch-all in cases where:
///
/// - It is unclear exactly what error might be produced by the backend API.
/// - It does not make sense to add a variant to the enclosing error type.
/// - No error was expected to occur at all, but we return an error to avoid the possibility of a
/// `panic!` caused by some unforeseen or unknown reason.
///
/// **Note:** If you notice a `BackendSpecificError` that you believe could be better handled in a
/// cross-platform manner, please create an issue or submit a pull request with a patch that adds
/// the necessary error variant to the appropriate error enum.
#[derive(Clone, Debug)]
pub struct BackendSpecificError {
pub description: String,
}
impl Display for BackendSpecificError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"A backend-specific error has occurred: {}",
self.description
)
}
}
impl Error for BackendSpecificError {}
/// An error that might occur while attempting to enumerate the available devices on a system.
#[derive(Clone, Debug)]
pub enum DevicesError {
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for DevicesError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
}
}
}
impl Error for DevicesError {}
impl From<BackendSpecificError> for DevicesError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// An error that may occur while attempting to retrieve a device name.
#[derive(Clone, Debug)]
pub enum DeviceNameError {
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for DeviceNameError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
}
}
}
impl Error for DeviceNameError {}
impl From<BackendSpecificError> for DeviceNameError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// Error that can happen when enumerating the list of supported formats.
#[derive(Debug)]
pub enum SupportedStreamConfigsError {
/// The device no longer exists. This can happen if the device is disconnected while the
/// program is running.
DeviceNotAvailable,
/// We called something the C-Layer did not understand
InvalidArgument,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for SupportedStreamConfigsError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
Self::DeviceNotAvailable => f.write_str("The requested device is no longer available. For example, it has been unplugged."),
Self::InvalidArgument => f.write_str("Invalid argument passed to the backend. For example, this happens when trying to read capture capabilities when the device does not support it.")
}
}
}
impl Error for SupportedStreamConfigsError {}
impl From<BackendSpecificError> for SupportedStreamConfigsError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// May occur when attempting to request the default input or output stream format from a [`Device`](crate::Device).
#[derive(Debug)]
pub enum DefaultStreamConfigError {
/// The device no longer exists. This can happen if the device is disconnected while the
/// program is running.
DeviceNotAvailable,
/// Returned if e.g. the default input format was requested on an output-only audio device.
StreamTypeNotSupported,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for DefaultStreamConfigError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
DefaultStreamConfigError::DeviceNotAvailable => f.write_str(
"The requested device is no longer available. For example, it has been unplugged.",
),
DefaultStreamConfigError::StreamTypeNotSupported => {
f.write_str("The requested stream type is not supported by the device.")
}
}
}
}
impl Error for DefaultStreamConfigError {}
impl From<BackendSpecificError> for DefaultStreamConfigError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// Error that can happen when creating a [`Stream`](crate::Stream).
#[derive(Debug)]
pub enum BuildStreamError {
/// The device no longer exists. This can happen if the device is disconnected while the
/// program is running.
DeviceNotAvailable,
/// The specified stream configuration is not supported.
StreamConfigNotSupported,
/// We called something the C-Layer did not understand
///
/// On ALSA device functions called with a feature they do not support will yield this. E.g.
/// Trying to use capture capabilities on an output only format yields this.
InvalidArgument,
/// Occurs if adding a new Stream ID would cause an integer overflow.
StreamIdOverflow,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for BuildStreamError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
BuildStreamError::DeviceNotAvailable => f.write_str(
"The requested device is no longer available. For example, it has been unplugged.",
),
BuildStreamError::StreamConfigNotSupported => {
f.write_str("The requested stream configuration is not supported by the device.")
}
BuildStreamError::InvalidArgument => f.write_str(
"The requested device does not support this capability (invalid argument)",
),
BuildStreamError::StreamIdOverflow => {
f.write_str("Adding a new stream ID would cause an overflow")
}
}
}
}
impl Error for BuildStreamError {}
impl From<BackendSpecificError> for BuildStreamError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// Errors that might occur when calling [`Stream::play()`](crate::traits::StreamTrait::play).
///
/// As of writing this, only macOS may immediately return an error while calling this method. This
/// is because both the alsa and wasapi backends only enqueue these commands and do not process
/// them immediately.
#[derive(Debug)]
pub enum PlayStreamError {
/// The device associated with the stream is no longer available.
DeviceNotAvailable,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for PlayStreamError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
PlayStreamError::DeviceNotAvailable => {
f.write_str("the device associated with the stream is no longer available")
}
}
}
}
impl Error for PlayStreamError {}
impl From<BackendSpecificError> for PlayStreamError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// Errors that might occur when calling [`Stream::pause()`](crate::traits::StreamTrait::pause).
///
/// As of writing this, only macOS may immediately return an error while calling this method. This
/// is because both the alsa and wasapi backends only enqueue these commands and do not process
/// them immediately.
#[derive(Debug)]
pub enum PauseStreamError {
/// The device associated with the stream is no longer available.
DeviceNotAvailable,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for PauseStreamError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
PauseStreamError::DeviceNotAvailable => {
f.write_str("the device associated with the stream is no longer available")
}
}
}
}
impl Error for PauseStreamError {}
impl From<BackendSpecificError> for PauseStreamError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
/// Errors that might occur while a stream is running.
#[derive(Debug)]
pub enum StreamError {
/// The device no longer exists. This can happen if the device is disconnected while the
/// program is running.
DeviceNotAvailable,
/// See the [`BackendSpecificError`] docs for more information about this error variant.
BackendSpecific { err: BackendSpecificError },
}
impl Display for StreamError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BackendSpecific { err } => err.fmt(f),
StreamError::DeviceNotAvailable => f.write_str(
"The requested device is no longer available. For example, it has been unplugged.",
),
}
}
}
impl Error for StreamError {}
impl From<BackendSpecificError> for StreamError {
fn from(err: BackendSpecificError) -> Self {
Self::BackendSpecific { err }
}
}
@@ -0,0 +1,70 @@
use super::alsa;
use super::{Device, DeviceHandles};
use crate::{BackendSpecificError, DevicesError};
use std::sync::{Arc, Mutex};
/// ALSA's implementation for `Devices`.
pub struct Devices {
hint_iter: alsa::device_name::HintIter,
}
impl Devices {
pub fn new() -> Result<Self, DevicesError> {
Ok(Devices {
hint_iter: alsa::device_name::HintIter::new_str(None, "pcm")?,
})
}
}
unsafe impl Send for Devices {}
unsafe impl Sync for Devices {}
impl Iterator for Devices {
type Item = Device;
fn next(&mut self) -> Option<Device> {
loop {
match self.hint_iter.next() {
None => return None,
Some(hint) => {
let name = match hint.name {
None => continue,
// Ignoring the `null` device.
Some(name) if name == "null" => continue,
Some(name) => name,
};
if let Ok(handles) = DeviceHandles::open(&name) {
return Some(Device {
name,
handles: Arc::new(Mutex::new(handles)),
});
}
}
}
}
}
}
#[inline]
pub fn default_input_device() -> Option<Device> {
Some(Device {
name: "default".to_owned(),
handles: Arc::new(Mutex::new(Default::default())),
})
}
#[inline]
pub fn default_output_device() -> Option<Device> {
Some(Device {
name: "default".to_owned(),
handles: Arc::new(Mutex::new(Default::default())),
})
}
impl From<alsa::Error> for DevicesError {
fn from(err: alsa::Error) -> Self {
let err: BackendSpecificError = err.into();
err.into()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
use super::sys;
use crate::BackendSpecificError;
use crate::DefaultStreamConfigError;
use crate::DeviceNameError;
use crate::DevicesError;
use crate::SampleFormat;
use crate::SampleRate;
use crate::SupportedBufferSize;
use crate::SupportedStreamConfig;
use crate::SupportedStreamConfigRange;
use crate::SupportedStreamConfigsError;
use std::hash::{Hash, Hasher};
use std::sync::atomic::AtomicI32;
use std::sync::{Arc, Mutex};
/// A ASIO Device
#[derive(Clone)]
pub struct Device {
/// The driver represented by this device.
pub driver: Arc<sys::Driver>,
// Input and/or Output stream.
// A driver can only have one of each.
// They need to be created at the same time.
pub asio_streams: Arc<Mutex<sys::AsioStreams>>,
pub current_buffer_index: Arc<AtomicI32>,
}
/// All available devices.
pub struct Devices {
asio: Arc<sys::Asio>,
drivers: std::vec::IntoIter<String>,
}
impl PartialEq for Device {
fn eq(&self, other: &Self) -> bool {
self.driver.name() == other.driver.name()
}
}
impl Eq for Device {}
impl Hash for Device {
fn hash<H: Hasher>(&self, state: &mut H) {
self.driver.name().hash(state);
}
}
impl Device {
pub fn name(&self) -> Result<String, DeviceNameError> {
Ok(self.driver.name().to_string())
}
/// Gets the supported input configs.
/// TODO currently only supports the default.
/// Need to find all possible configs.
pub fn supported_input_configs(
&self,
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
// Retrieve the default config for the total supported channels and supported sample
// format.
let f = match self.default_input_config() {
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
Ok(f) => f,
};
// Collect a config for every combination of supported sample rate and number of channels.
let mut supported_configs = vec![];
for &rate in crate::COMMON_SAMPLE_RATES {
if !self
.driver
.can_sample_rate(rate.0.into())
.ok()
.unwrap_or(false)
{
continue;
}
for channels in 1..f.channels + 1 {
supported_configs.push(SupportedStreamConfigRange {
channels,
min_sample_rate: rate,
max_sample_rate: rate,
buffer_size: f.buffer_size,
sample_format: f.sample_format,
})
}
}
Ok(supported_configs.into_iter())
}
/// Gets the supported output configs.
/// TODO currently only supports the default.
/// Need to find all possible configs.
pub fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
// Retrieve the default config for the total supported channels and supported sample
// format.
let f = match self.default_output_config() {
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
Ok(f) => f,
};
// Collect a config for every combination of supported sample rate and number of channels.
let mut supported_configs = vec![];
for &rate in crate::COMMON_SAMPLE_RATES {
if !self
.driver
.can_sample_rate(rate.0.into())
.ok()
.unwrap_or(false)
{
continue;
}
for channels in 1..f.channels + 1 {
supported_configs.push(SupportedStreamConfigRange {
channels,
min_sample_rate: rate,
max_sample_rate: rate,
buffer_size: f.buffer_size,
sample_format: f.sample_format,
})
}
}
Ok(supported_configs.into_iter())
}
/// Returns the default input config
pub fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let channels = self.driver.channels().map_err(default_config_err)?.ins as u16;
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
let buffer_size = SupportedBufferSize::Range {
min: min as u32,
max: max as u32,
};
// Map th ASIO sample type to a CPAL sample type
let data_type = self.driver.input_data_type().map_err(default_config_err)?;
let sample_format = convert_data_type(&data_type)
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
Ok(SupportedStreamConfig {
channels,
sample_rate,
buffer_size,
sample_format,
})
}
/// Returns the default output config
pub fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let channels = self.driver.channels().map_err(default_config_err)?.outs as u16;
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
let buffer_size = SupportedBufferSize::Range {
min: min as u32,
max: max as u32,
};
let data_type = self.driver.output_data_type().map_err(default_config_err)?;
let sample_format = convert_data_type(&data_type)
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
Ok(SupportedStreamConfig {
channels,
sample_rate,
buffer_size,
sample_format,
})
}
}
impl Devices {
pub fn new(asio: Arc<sys::Asio>) -> Result<Self, DevicesError> {
let drivers = asio.driver_names().into_iter();
Ok(Devices { asio, drivers })
}
}
impl Iterator for Devices {
type Item = Device;
/// Load drivers and return device
fn next(&mut self) -> Option<Device> {
loop {
match self.drivers.next() {
Some(name) => match self.asio.load_driver(&name) {
Ok(driver) => {
let driver = Arc::new(driver);
let asio_streams = Arc::new(Mutex::new(sys::AsioStreams {
input: None,
output: None,
}));
return Some(Device {
driver,
asio_streams,
current_buffer_index: Arc::new(AtomicI32::new(-1)),
});
}
Err(_) => continue,
},
None => return None,
}
}
}
}
pub(crate) fn convert_data_type(ty: &sys::AsioSampleType) -> Option<SampleFormat> {
let fmt = match *ty {
sys::AsioSampleType::ASIOSTInt16MSB => SampleFormat::I16,
sys::AsioSampleType::ASIOSTInt16LSB => SampleFormat::I16,
sys::AsioSampleType::ASIOSTFloat32MSB => SampleFormat::F32,
sys::AsioSampleType::ASIOSTFloat32LSB => SampleFormat::F32,
sys::AsioSampleType::ASIOSTInt32MSB => SampleFormat::I32,
sys::AsioSampleType::ASIOSTInt32LSB => SampleFormat::I32,
_ => return None,
};
Some(fmt)
}
fn default_config_err(e: sys::AsioError) -> DefaultStreamConfigError {
match e {
sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => {
DefaultStreamConfigError::DeviceNotAvailable
}
sys::AsioError::NoRate => DefaultStreamConfigError::StreamTypeNotSupported,
err => {
let description = format!("{}", err);
BackendSpecificError { description }.into()
}
}
}
@@ -0,0 +1,138 @@
extern crate asio_sys as sys;
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigsError,
};
pub use self::device::{Device, Devices, SupportedInputConfigs, SupportedOutputConfigs};
pub use self::stream::Stream;
use std::sync::Arc;
use std::time::Duration;
mod device;
mod stream;
/// The host for ASIO.
#[derive(Debug)]
pub struct Host {
asio: Arc<sys::Asio>,
}
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
let asio = Arc::new(sys::Asio::new());
let host = Host { asio };
Ok(host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
true
//unimplemented!("check how to do this using asio-sys")
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new(self.asio.clone())
}
fn default_input_device(&self) -> Option<Self::Device> {
// ASIO has no concept of a default device, so just use the first.
self.input_devices().ok().and_then(|mut ds| ds.next())
}
fn default_output_device(&self) -> Option<Self::Device> {
// ASIO has no concept of a default device, so just use the first.
self.output_devices().ok().and_then(|mut ds| ds.next())
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, DeviceNameError> {
Device::name(self)
}
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Device::supported_input_configs(self)
}
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Device::supported_output_configs(self)
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_input_config(self)
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_output_config(self)
}
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Device::build_input_stream_raw(
self,
config,
sample_format,
data_callback,
error_callback,
timeout,
)
}
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Device::build_output_stream_raw(
self,
config,
sample_format,
data_callback,
error_callback,
timeout,
)
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
Stream::play(self)
}
fn pause(&self) -> Result<(), PauseStreamError> {
Stream::pause(self)
}
}
@@ -0,0 +1,718 @@
extern crate asio_sys as sys;
extern crate num_traits;
use self::num_traits::PrimInt;
use super::Device;
use crate::{
BackendSpecificError, BufferSize, BuildStreamError, Data, InputCallbackInfo,
OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, StreamConfig, StreamError,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub struct Stream {
playing: Arc<AtomicBool>,
// Ensure the `Driver` does not terminate until the last stream is dropped.
driver: Arc<sys::Driver>,
#[allow(dead_code)]
asio_streams: Arc<Mutex<sys::AsioStreams>>,
callback_id: sys::CallbackId,
}
impl Stream {
pub fn play(&self) -> Result<(), PlayStreamError> {
self.playing.store(true, Ordering::SeqCst);
Ok(())
}
pub fn pause(&self) -> Result<(), PauseStreamError> {
self.playing.store(false, Ordering::SeqCst);
Ok(())
}
}
impl Device {
pub fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let stream_type = self.driver.input_data_type().map_err(build_stream_err)?;
// Ensure that the desired sample type is supported.
let expected_sample_format = super::device::convert_data_type(&stream_type)
.ok_or(BuildStreamError::StreamConfigNotSupported)?;
if sample_format != expected_sample_format {
return Err(BuildStreamError::StreamConfigNotSupported);
}
let num_channels = config.channels;
let buffer_size = self.get_or_create_input_stream(config, sample_format)?;
let cpal_num_samples = buffer_size * num_channels as usize;
// Create the buffer depending on the size of the data type.
let len_bytes = cpal_num_samples * sample_format.sample_size();
let mut interleaved = vec![0u8; len_bytes];
let stream_playing = Arc::new(AtomicBool::new(false));
let playing = Arc::clone(&stream_playing);
let asio_streams = self.asio_streams.clone();
// Set the input callback.
// This is most performance critical part of the ASIO bindings.
let config = config.clone();
let callback_id = self.driver.add_callback(move |callback_info| unsafe {
// If not playing return early.
if !playing.load(Ordering::SeqCst) {
return;
}
// There is 0% chance of lock contention the host only locks when recreating streams.
let stream_lock = asio_streams.lock().unwrap();
let asio_stream = match stream_lock.input {
Some(ref asio_stream) => asio_stream,
None => return,
};
/// 1. Write from the ASIO buffer to the interleaved CPAL buffer.
/// 2. Deliver the CPAL buffer to the user callback.
unsafe fn process_input_callback<A, D, F>(
data_callback: &mut D,
interleaved: &mut [u8],
asio_stream: &sys::AsioStream,
asio_info: &sys::CallbackInfo,
sample_rate: crate::SampleRate,
format: SampleFormat,
from_endianness: F,
) where
A: Copy,
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
F: Fn(A) -> A,
{
// 1. Write the ASIO channels to the CPAL buffer.
let interleaved: &mut [A] = cast_slice_mut(interleaved);
let n_frames = asio_stream.buffer_size as usize;
let n_channels = interleaved.len() / n_frames;
let buffer_index = asio_info.buffer_index as usize;
for ch_ix in 0..n_channels {
let asio_channel = asio_channel_slice::<A>(asio_stream, buffer_index, ch_ix);
for (frame, s_asio) in interleaved.chunks_mut(n_channels).zip(asio_channel) {
frame[ch_ix] = from_endianness(*s_asio);
}
}
// 2. Deliver the interleaved buffer to the callback.
let data = interleaved.as_mut_ptr() as *mut ();
let len = interleaved.len();
let data = Data::from_parts(data, len, format);
let callback = system_time_to_stream_instant(asio_info.system_time);
let delay = frames_to_duration(n_frames, sample_rate);
let capture = callback
.sub(delay)
.expect("`capture` occurs before origin of alsa `StreamInstant`");
let timestamp = crate::InputStreamTimestamp { callback, capture };
let info = InputCallbackInfo { timestamp };
data_callback(&data, &info);
}
match (&stream_type, sample_format) {
(&sys::AsioSampleType::ASIOSTInt16LSB, SampleFormat::I16) => {
process_input_callback::<i16, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I16,
from_le,
);
}
(&sys::AsioSampleType::ASIOSTInt16MSB, SampleFormat::I16) => {
process_input_callback::<i16, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I16,
from_be,
);
}
(&sys::AsioSampleType::ASIOSTFloat32LSB, SampleFormat::F32) => {
process_input_callback::<u32, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F32,
from_le,
);
}
(&sys::AsioSampleType::ASIOSTFloat32MSB, SampleFormat::F32) => {
process_input_callback::<u32, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F32,
from_be,
);
}
(&sys::AsioSampleType::ASIOSTInt32LSB, SampleFormat::I32) => {
process_input_callback::<i32, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I32,
from_le,
);
}
(&sys::AsioSampleType::ASIOSTInt32MSB, SampleFormat::I32) => {
process_input_callback::<i32, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I32,
from_be,
);
}
(&sys::AsioSampleType::ASIOSTFloat64LSB, SampleFormat::F64) => {
process_input_callback::<u64, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F64,
from_le,
);
}
(&sys::AsioSampleType::ASIOSTFloat64MSB, SampleFormat::F64) => {
process_input_callback::<u64, _, _>(
&mut data_callback,
&mut interleaved,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F64,
from_be,
);
}
unsupported_format_pair => unreachable!(
"`build_input_stream_raw` should have returned with unsupported \
format {:?}",
unsupported_format_pair
),
}
});
let driver = self.driver.clone();
let asio_streams = self.asio_streams.clone();
// Immediately start the device?
self.driver.start().map_err(build_stream_err)?;
Ok(Stream {
playing: stream_playing,
driver,
asio_streams,
callback_id,
})
}
pub fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let stream_type = self.driver.output_data_type().map_err(build_stream_err)?;
// Ensure that the desired sample type is supported.
let expected_sample_format = super::device::convert_data_type(&stream_type)
.ok_or(BuildStreamError::StreamConfigNotSupported)?;
if sample_format != expected_sample_format {
return Err(BuildStreamError::StreamConfigNotSupported);
}
let num_channels = config.channels;
let buffer_size = self.get_or_create_output_stream(config, sample_format)?;
let cpal_num_samples = buffer_size * num_channels as usize;
// Create buffers depending on data type.
let len_bytes = cpal_num_samples * sample_format.sample_size();
let mut interleaved = vec![0u8; len_bytes];
let current_buffer_index = self.current_buffer_index.clone();
let stream_playing = Arc::new(AtomicBool::new(false));
let playing = Arc::clone(&stream_playing);
let asio_streams = self.asio_streams.clone();
let config = config.clone();
let callback_id = self.driver.add_callback(move |callback_info| unsafe {
// If not playing, return early.
if !playing.load(Ordering::SeqCst) {
return;
}
// There is 0% chance of lock contention the host only locks when recreating streams.
let mut stream_lock = asio_streams.lock().unwrap();
let asio_stream = match stream_lock.output {
Some(ref mut asio_stream) => asio_stream,
None => return,
};
// Silence the ASIO buffer that is about to be used.
//
// This checks if any other callbacks have already silenced the buffer associated with
// the current `buffer_index`.
let silence =
current_buffer_index.load(Ordering::Acquire) != callback_info.buffer_index;
if silence {
current_buffer_index.store(callback_info.buffer_index, Ordering::Release);
}
/// 1. Render the given callback to the given buffer of interleaved samples.
/// 2. If required, silence the ASIO buffer.
/// 3. Finally, write the interleaved data to the non-interleaved ASIO buffer,
/// performing endianness conversions as necessary.
unsafe fn process_output_callback<A, D, F>(
data_callback: &mut D,
interleaved: &mut [u8],
silence_asio_buffer: bool,
asio_stream: &mut sys::AsioStream,
asio_info: &sys::CallbackInfo,
sample_rate: crate::SampleRate,
format: SampleFormat,
mix_samples: F,
) where
A: Copy,
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
F: Fn(A, A) -> A,
{
// 1. Render interleaved buffer from callback.
let interleaved: &mut [A] = cast_slice_mut(interleaved);
let data = interleaved.as_mut_ptr() as *mut ();
let len = interleaved.len();
let mut data = Data::from_parts(data, len, format);
let callback = system_time_to_stream_instant(asio_info.system_time);
let n_frames = asio_stream.buffer_size as usize;
let delay = frames_to_duration(n_frames, sample_rate);
let playback = callback
.add(delay)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
data_callback(&mut data, &info);
// 2. Silence ASIO channels if necessary.
let n_channels = interleaved.len() / n_frames;
let buffer_index = asio_info.buffer_index as usize;
if silence_asio_buffer {
for ch_ix in 0..n_channels {
let asio_channel =
asio_channel_slice_mut::<A>(asio_stream, buffer_index, ch_ix);
asio_channel.align_to_mut::<u8>().1.fill(0);
}
}
// 3. Write interleaved samples to ASIO channels, one channel at a time.
for ch_ix in 0..n_channels {
let asio_channel =
asio_channel_slice_mut::<A>(asio_stream, buffer_index, ch_ix);
for (frame, s_asio) in interleaved.chunks(n_channels).zip(asio_channel) {
*s_asio = mix_samples(*s_asio, frame[ch_ix]);
}
}
}
match (sample_format, &stream_type) {
(SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16LSB) => {
process_output_callback::<i16, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I16,
|old_sample, new_sample| {
from_le(old_sample).saturating_add(new_sample).to_le()
},
);
}
(SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16MSB) => {
process_output_callback::<i16, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I16,
|old_sample, new_sample| {
from_be(old_sample).saturating_add(new_sample).to_be()
},
);
}
(SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32LSB) => {
process_output_callback::<u32, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F32,
|old_sample, new_sample| {
(f32::from_bits(from_le(old_sample)) + f32::from_bits(new_sample))
.to_bits()
.to_le()
},
);
}
(SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32MSB) => {
process_output_callback::<u32, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F32,
|old_sample, new_sample| {
(f32::from_bits(from_be(old_sample)) + f32::from_bits(new_sample))
.to_bits()
.to_be()
},
);
}
(SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32LSB) => {
process_output_callback::<i32, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I32,
|old_sample, new_sample| {
from_le(old_sample).saturating_add(new_sample).to_le()
},
);
}
(SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32MSB) => {
process_output_callback::<i32, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::I32,
|old_sample, new_sample| {
from_be(old_sample).saturating_add(new_sample).to_be()
},
);
}
(SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64LSB) => {
process_output_callback::<u64, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F64,
|old_sample, new_sample| {
(f64::from_bits(from_le(old_sample)) + f64::from_bits(new_sample))
.to_bits()
.to_le()
},
);
}
(SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64MSB) => {
process_output_callback::<u64, _, _>(
&mut data_callback,
&mut interleaved,
silence,
asio_stream,
callback_info,
config.sample_rate,
SampleFormat::F64,
|old_sample, new_sample| {
(f64::from_bits(from_be(old_sample)) + f64::from_bits(new_sample))
.to_bits()
.to_be()
},
);
}
unsupported_format_pair => unreachable!(
"`build_output_stream_raw` should have returned with unsupported \
format {:?}",
unsupported_format_pair
),
}
});
let driver = self.driver.clone();
let asio_streams = self.asio_streams.clone();
// Immediately start the device?
self.driver.start().map_err(build_stream_err)?;
Ok(Stream {
playing: stream_playing,
driver,
asio_streams,
callback_id,
})
}
/// Create a new CPAL Input Stream.
///
/// If there is no existing ASIO Input Stream it will be created.
///
/// On success, the buffer size of the stream is returned.
fn get_or_create_input_stream(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
) -> Result<usize, BuildStreamError> {
match self.default_input_config() {
Ok(f) => {
let num_asio_channels = f.channels;
check_config(&self.driver, config, sample_format, num_asio_channels)
}
Err(_) => Err(BuildStreamError::StreamConfigNotSupported),
}?;
let num_channels = config.channels as usize;
let mut streams = self.asio_streams.lock().unwrap();
let buffer_size = match config.buffer_size {
BufferSize::Fixed(v) => Some(v as i32),
BufferSize::Default => None,
};
// Either create a stream if thers none or had back the
// size of the current one.
match streams.input {
Some(ref input) => Ok(input.buffer_size as usize),
None => {
let output = streams.output.take();
self.driver
.prepare_input_stream(output, num_channels, buffer_size)
.map(|new_streams| {
let bs = match new_streams.input {
Some(ref inp) => inp.buffer_size as usize,
None => unreachable!(),
};
*streams = new_streams;
bs
})
.map_err(|ref e| {
println!("Error preparing stream: {}", e);
BuildStreamError::DeviceNotAvailable
})
}
}
}
/// Create a new CPAL Output Stream.
///
/// If there is no existing ASIO Output Stream it will be created.
fn get_or_create_output_stream(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
) -> Result<usize, BuildStreamError> {
match self.default_output_config() {
Ok(f) => {
let num_asio_channels = f.channels;
check_config(&self.driver, config, sample_format, num_asio_channels)
}
Err(_) => Err(BuildStreamError::StreamConfigNotSupported),
}?;
let num_channels = config.channels as usize;
let mut streams = self.asio_streams.lock().unwrap();
let buffer_size = match config.buffer_size {
BufferSize::Fixed(v) => Some(v as i32),
BufferSize::Default => None,
};
// Either create a stream if thers none or had back the
// size of the current one.
match streams.output {
Some(ref output) => Ok(output.buffer_size as usize),
None => {
let input = streams.input.take();
self.driver
.prepare_output_stream(input, num_channels, buffer_size)
.map(|new_streams| {
let bs = match new_streams.output {
Some(ref out) => out.buffer_size as usize,
None => unreachable!(),
};
*streams = new_streams;
bs
})
.map_err(|ref e| {
println!("Error preparing stream: {}", e);
BuildStreamError::DeviceNotAvailable
})
}
}
}
}
impl Drop for Stream {
fn drop(&mut self) {
self.driver.remove_callback(self.callback_id);
}
}
fn asio_ns_to_double(val: sys::bindings::asio_import::ASIOTimeStamp) -> f64 {
let two_raised_to_32 = 4294967296.0;
val.lo as f64 + val.hi as f64 * two_raised_to_32
}
/// Asio retrieves system time via `timeGetTime` which returns the time in milliseconds.
fn system_time_to_stream_instant(
system_time: sys::bindings::asio_import::ASIOTimeStamp,
) -> crate::StreamInstant {
let systime_ns = asio_ns_to_double(system_time);
let secs = systime_ns as i64 / 1_000_000_000;
let nanos = (systime_ns as i64 - secs * 1_000_000_000) as u32;
crate::StreamInstant::new(secs, nanos)
}
/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
let secsf = frames as f64 / rate.0 as f64;
let secs = secsf as u64;
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
std::time::Duration::new(secs, nanos)
}
/// Check whether or not the desired config is supported by the stream.
///
/// Checks sample rate, data type and then finally the number of channels.
fn check_config(
driver: &sys::Driver,
config: &StreamConfig,
sample_format: SampleFormat,
num_asio_channels: u16,
) -> Result<(), BuildStreamError> {
let StreamConfig {
channels,
sample_rate,
buffer_size: _,
} = config;
// Try and set the sample rate to what the user selected.
let sample_rate = sample_rate.0.into();
if sample_rate != driver.sample_rate().map_err(build_stream_err)? {
if driver
.can_sample_rate(sample_rate)
.map_err(build_stream_err)?
{
driver
.set_sample_rate(sample_rate)
.map_err(build_stream_err)?;
} else {
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
// unsigned formats are not supported by asio
match sample_format {
SampleFormat::I16 | SampleFormat::I32 | SampleFormat::F32 => (),
_ => return Err(BuildStreamError::StreamConfigNotSupported),
}
if *channels > num_asio_channels {
return Err(BuildStreamError::StreamConfigNotSupported);
}
Ok(())
}
/// Cast a byte slice into a mutable slice of desired type.
///
/// Safety: it's up to the caller to ensure that the input slice has valid bit representations.
unsafe fn cast_slice_mut<T>(v: &mut [u8]) -> &mut [T] {
debug_assert!(v.len() % std::mem::size_of::<T>() == 0);
std::slice::from_raw_parts_mut(v.as_mut_ptr() as *mut T, v.len() / std::mem::size_of::<T>())
}
/// Helper function to convert from little endianness.
fn from_le<T: PrimInt>(t: T) -> T {
T::from_le(t)
}
/// Helper function to convert from little endianness.
fn from_be<T: PrimInt>(t: T) -> T {
T::from_be(t)
}
/// Shorthand for retrieving the asio buffer slice associated with a channel.
unsafe fn asio_channel_slice<T>(
asio_stream: &sys::AsioStream,
buffer_index: usize,
channel_index: usize,
) -> &[T] {
let buff_ptr: *const T =
asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *const _;
std::slice::from_raw_parts(buff_ptr, asio_stream.buffer_size as usize)
}
/// Shorthand for retrieving the asio buffer slice associated with a channel.
unsafe fn asio_channel_slice_mut<T>(
asio_stream: &mut sys::AsioStream,
buffer_index: usize,
channel_index: usize,
) -> &mut [T] {
let buff_ptr: *mut T =
asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *mut _;
std::slice::from_raw_parts_mut(buff_ptr, asio_stream.buffer_size as usize)
}
fn build_stream_err(e: sys::AsioError) -> BuildStreamError {
match e {
sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => {
BuildStreamError::DeviceNotAvailable
}
sys::AsioError::InvalidInput | sys::AsioError::BadMode => BuildStreamError::InvalidArgument,
err => {
let description = format!("{}", err);
BackendSpecificError { description }.into()
}
}
}
@@ -0,0 +1,43 @@
use std::vec::IntoIter as VecIntoIter;
use crate::DevicesError;
use crate::SupportedStreamConfigRange;
use super::Device;
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
// TODO: Support enumerating earpiece vs headset vs speaker etc?
pub struct Devices(VecIntoIter<Device>);
impl Devices {
pub fn new() -> Result<Self, DevicesError> {
Ok(Self::default())
}
}
impl Default for Devices {
fn default() -> Devices {
Devices(vec![Device].into_iter())
}
}
impl Iterator for Devices {
type Item = Device;
#[inline]
fn next(&mut self) -> Option<Device> {
self.0.next()
}
}
#[inline]
pub fn default_input_device() -> Option<Device> {
Some(Device)
}
#[inline]
pub fn default_output_device() -> Option<Device> {
Some(Device)
}
@@ -0,0 +1,434 @@
//!
//! coreaudio on iOS looks a bit different from macOS. A lot of configuration needs to use
//! the AVAudioSession objc API which doesn't exist on macOS.
//!
//! TODO:
//! - Use AVAudioSession to enumerate buffer size / sample rate / number of channels and set
//! buffer size.
//!
extern crate core_foundation_sys;
extern crate coreaudio;
use std::cell::RefCell;
use self::coreaudio::audio_unit::render_callback::data;
use self::coreaudio::audio_unit::{render_callback, AudioUnit, Element, Scope};
use self::coreaudio::sys::{
kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, AudioBuffer,
AudioStreamBasicDescription,
};
use super::{asbd_from_config, frames_to_duration, host_time_to_stream_instant};
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize,
SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError,
};
use self::enumerate::{
default_input_device, default_output_device, Devices, SupportedInputConfigs,
SupportedOutputConfigs,
};
use std::slice;
use std::time::Duration;
pub mod enumerate;
// These days the default of iOS is now F32 and no longer I16
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device;
pub struct Host;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Self::Device> {
default_input_device()
}
fn default_output_device(&self) -> Option<Self::Device> {
default_output_device()
}
}
impl Device {
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Ok("Default Device".to_owned())
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
// TODO: query AVAudioSession for parameters, some values like sample rate and buffer size
// probably need to actually be set to see if it works, but channels can be enumerated.
let asbd: AudioStreamBasicDescription = default_input_asbd()?;
let stream_config = stream_config_from_asbd(asbd);
Ok(vec![SupportedStreamConfigRange {
channels: stream_config.channels,
min_sample_rate: stream_config.sample_rate,
max_sample_rate: stream_config.sample_rate,
buffer_size: stream_config.buffer_size.clone(),
sample_format: SUPPORTED_SAMPLE_FORMAT,
}]
.into_iter())
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
// TODO: query AVAudioSession for parameters, some values like sample rate and buffer size
// probably need to actually be set to see if it works, but channels can be enumerated.
let asbd: AudioStreamBasicDescription = default_output_asbd()?;
let stream_config = stream_config_from_asbd(asbd);
let configs: Vec<_> = (1..=asbd.mChannelsPerFrame as u16)
.map(|channels| SupportedStreamConfigRange {
channels,
min_sample_rate: stream_config.sample_rate,
max_sample_rate: stream_config.sample_rate,
buffer_size: stream_config.buffer_size.clone(),
sample_format: SUPPORTED_SAMPLE_FORMAT,
})
.collect();
Ok(configs.into_iter())
}
#[inline]
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let asbd: AudioStreamBasicDescription = default_input_asbd()?;
let stream_config = stream_config_from_asbd(asbd);
Ok(stream_config)
}
#[inline]
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let asbd: AudioStreamBasicDescription = default_output_asbd()?;
let stream_config = stream_config_from_asbd(asbd);
Ok(stream_config)
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Device::name(self)
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Device::supported_input_configs(self)
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Device::supported_output_configs(self)
}
#[inline]
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_input_config(self)
}
#[inline]
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_output_config(self)
}
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
mut error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
// The scope and element for working with a device's input stream.
let scope = Scope::Output;
let element = Element::Input;
let mut audio_unit = create_audio_unit()?;
audio_unit.uninitialize()?;
configure_for_recording(&mut audio_unit)?;
audio_unit.initialize()?;
// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
// Set the buffersize
match config.buffer_size {
BufferSize::Fixed(_) => {
return Err(BuildStreamError::StreamConfigNotSupported);
}
BufferSize::Default => (),
}
// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
type Args = render_callback::Args<data::Raw>;
audio_unit.set_input_callback(move |args: Args| unsafe {
let ptr = (*args.data.data).mBuffers.as_ptr() as *const AudioBuffer;
let len = (*args.data.data).mNumberBuffers as usize;
let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len);
// There is only 1 buffer when using interleaved channels
let AudioBuffer {
mNumberChannels: channels,
mDataByteSize: data_byte_size,
mData: data,
} = buffers[0];
let data = data as *mut ();
let len = (data_byte_size as usize / bytes_per_channel) as usize;
let data = Data::from_parts(data, len, sample_format);
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
error_callback(err.into());
return Err(());
}
Ok(cb) => cb,
};
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
let capture = callback
.sub(delay)
.expect("`capture` occurs before origin of alsa `StreamInstant`");
let timestamp = crate::InputStreamTimestamp { callback, capture };
let info = InputCallbackInfo { timestamp };
data_callback(&data, &info);
Ok(())
})?;
audio_unit.start()?;
Ok(Stream::new(StreamInner {
playing: true,
audio_unit,
}))
}
/// Create an output stream.
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
mut error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
match config.buffer_size {
BufferSize::Fixed(_) => {
return Err(BuildStreamError::StreamConfigNotSupported);
}
BufferSize::Default => (),
};
let mut audio_unit = create_audio_unit()?;
// The scope and element for working with a device's output stream.
let scope = Scope::Input;
let element = Element::Output;
// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
type Args = render_callback::Args<data::Raw>;
audio_unit.set_render_callback(move |args: Args| unsafe {
// If `run()` is currently running, then a callback will be available from this list.
// Otherwise, we just fill the buffer with zeroes and return.
let AudioBuffer {
mNumberChannels: channels,
mDataByteSize: data_byte_size,
mData: data,
} = (*args.data.data).mBuffers[0];
let data = data as *mut ();
let len = (data_byte_size as usize / bytes_per_channel) as usize;
let mut data = Data::from_parts(data, len, sample_format);
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
error_callback(err.into());
return Err(());
}
Ok(cb) => cb,
};
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
let playback = callback
.add(delay)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
data_callback(&mut data, &info);
Ok(())
})?;
audio_unit.start()?;
Ok(Stream::new(StreamInner {
playing: true,
audio_unit,
}))
}
}
pub struct Stream {
inner: RefCell<StreamInner>,
}
impl Stream {
fn new(inner: StreamInner) -> Self {
Self {
inner: RefCell::new(inner),
}
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
let mut stream = self.inner.borrow_mut();
if !stream.playing {
if let Err(e) = stream.audio_unit.start() {
let description = format!("{}", e);
let err = BackendSpecificError { description };
return Err(err.into());
}
stream.playing = true;
}
Ok(())
}
fn pause(&self) -> Result<(), PauseStreamError> {
let mut stream = self.inner.borrow_mut();
if stream.playing {
if let Err(e) = stream.audio_unit.stop() {
let description = format!("{}", e);
let err = BackendSpecificError { description };
return Err(err.into());
}
stream.playing = false;
}
Ok(())
}
}
struct StreamInner {
playing: bool,
audio_unit: AudioUnit,
}
fn create_audio_unit() -> Result<AudioUnit, coreaudio::Error> {
AudioUnit::new(coreaudio::audio_unit::IOType::RemoteIO)
}
fn configure_for_recording(audio_unit: &mut AudioUnit) -> Result<(), coreaudio::Error> {
// Enable mic recording
let enable_input = 1u32;
audio_unit.set_property(
kAudioOutputUnitProperty_EnableIO,
Scope::Input,
Element::Input,
Some(&enable_input),
)?;
// Disable output
let disable_output = 0u32;
audio_unit.set_property(
kAudioOutputUnitProperty_EnableIO,
Scope::Output,
Element::Output,
Some(&disable_output),
)?;
Ok(())
}
fn default_output_asbd() -> Result<AudioStreamBasicDescription, coreaudio::Error> {
let audio_unit = create_audio_unit()?;
let id = kAudioUnitProperty_StreamFormat;
let asbd: AudioStreamBasicDescription =
audio_unit.get_property(id, Scope::Output, Element::Output)?;
Ok(asbd)
}
fn default_input_asbd() -> Result<AudioStreamBasicDescription, coreaudio::Error> {
let mut audio_unit = create_audio_unit()?;
audio_unit.uninitialize()?;
configure_for_recording(&mut audio_unit)?;
audio_unit.initialize()?;
let id = kAudioUnitProperty_StreamFormat;
let asbd: AudioStreamBasicDescription =
audio_unit.get_property(id, Scope::Input, Element::Input)?;
Ok(asbd)
}
fn stream_config_from_asbd(asbd: AudioStreamBasicDescription) -> SupportedStreamConfig {
let buffer_size = SupportedBufferSize::Range { min: 0, max: 0 };
SupportedStreamConfig {
channels: asbd.mChannelsPerFrame as u16,
sample_rate: SampleRate(asbd.mSampleRate as u32),
buffer_size: buffer_size.clone(),
sample_format: SUPPORTED_SAMPLE_FORMAT,
}
}
@@ -0,0 +1,152 @@
extern crate coreaudio;
use self::coreaudio::sys::{
kAudioHardwareNoError, kAudioHardwarePropertyDefaultInputDevice,
kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices,
kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject,
AudioDeviceID, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize,
AudioObjectPropertyAddress, OSStatus,
};
use super::Device;
use crate::{BackendSpecificError, DevicesError, SupportedStreamConfigRange};
use std::mem;
use std::ptr::null;
use std::vec::IntoIter as VecIntoIter;
unsafe fn audio_devices() -> Result<Vec<AudioDeviceID>, OSStatus> {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
macro_rules! try_status_or_return {
($status:expr) => {
if $status != kAudioHardwareNoError as i32 {
return Err($status);
}
};
}
let data_size = 0u32;
let status = AudioObjectGetPropertyDataSize(
kAudioObjectSystemObject,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
);
try_status_or_return!(status);
let device_count = data_size / mem::size_of::<AudioDeviceID>() as u32;
let mut audio_devices = vec![];
audio_devices.reserve_exact(device_count as usize);
let status = AudioObjectGetPropertyData(
kAudioObjectSystemObject,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
audio_devices.as_mut_ptr() as *mut _,
);
try_status_or_return!(status);
audio_devices.set_len(device_count as usize);
Ok(audio_devices)
}
pub struct Devices(VecIntoIter<AudioDeviceID>);
impl Devices {
pub fn new() -> Result<Self, DevicesError> {
let devices = unsafe {
match audio_devices() {
Ok(devices) => devices,
Err(os_status) => {
let description = format!("{}", os_status);
let err = BackendSpecificError { description };
return Err(err.into());
}
}
};
Ok(Devices(devices.into_iter()))
}
}
unsafe impl Send for Devices {}
unsafe impl Sync for Devices {}
impl Iterator for Devices {
type Item = Device;
fn next(&mut self) -> Option<Device> {
self.0.next().map(|id| Device {
audio_device_id: id,
is_default: false,
})
}
}
pub fn default_input_device() -> Option<Device> {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
let audio_device_id: AudioDeviceID = 0;
let data_size = mem::size_of::<AudioDeviceID>();
let status = unsafe {
AudioObjectGetPropertyData(
kAudioObjectSystemObject,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&audio_device_id as *const _ as *mut _,
)
};
if status != kAudioHardwareNoError as i32 {
return None;
}
let device = Device {
audio_device_id,
is_default: true,
};
Some(device)
}
pub fn default_output_device() -> Option<Device> {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
let audio_device_id: AudioDeviceID = 0;
let data_size = mem::size_of::<AudioDeviceID>();
let status = unsafe {
AudioObjectGetPropertyData(
kAudioObjectSystemObject,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&audio_device_id as *const _ as *mut _,
)
};
if status != kAudioHardwareNoError as i32 {
return None;
}
let device = Device {
audio_device_id,
is_default: true,
};
Some(device)
}
pub type SupportedInputConfigs = VecIntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = VecIntoIter<SupportedStreamConfigRange>;
@@ -0,0 +1,948 @@
extern crate core_foundation_sys;
extern crate coreaudio;
use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant};
use self::core_foundation_sys::string::{CFStringGetCString, CFStringGetCStringPtr, CFStringRef};
use self::coreaudio::audio_unit::render_callback::{self, data};
use self::coreaudio::audio_unit::{AudioUnit, Element, Scope};
use self::coreaudio::sys::{
kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize,
kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive,
kAudioDevicePropertyDeviceNameCFString, kAudioDevicePropertyNominalSampleRate,
kAudioDevicePropertyScopeOutput, kAudioDevicePropertyStreamConfiguration,
kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMaster,
kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput,
kAudioObjectPropertyScopeOutput, kAudioOutputUnitProperty_CurrentDevice,
kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, kCFStringEncodingUTF8,
AudioBuffer, AudioBufferList, AudioDeviceID, AudioObjectGetPropertyData,
AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectPropertyScope, AudioObjectSetPropertyData, AudioStreamBasicDescription,
AudioValueRange, OSStatus,
};
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data,
DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo,
PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError,
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
SupportedStreamConfigsError,
};
use std::ffi::CStr;
use std::fmt;
use std::mem;
use std::os::raw::c_char;
use std::ptr::null;
use std::slice;
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub use self::enumerate::{
default_input_device, default_output_device, Devices, SupportedInputConfigs,
SupportedOutputConfigs,
};
use property_listener::AudioObjectPropertyListener;
pub mod enumerate;
mod property_listener;
/// Coreaudio host, the default host on macOS.
#[derive(Debug)]
pub struct Host;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
// Assume coreaudio is always available
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Self::Device> {
default_input_device()
}
fn default_output_device(&self) -> Option<Self::Device> {
default_output_device()
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, DeviceNameError> {
Device::name(self)
}
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Device::supported_input_configs(self)
}
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Device::supported_output_configs(self)
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_input_config(self)
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_output_config(self)
}
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Device::build_input_stream_raw(
self,
config,
sample_format,
data_callback,
error_callback,
timeout,
)
}
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Device::build_output_stream_raw(
self,
config,
sample_format,
data_callback,
error_callback,
timeout,
)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Device {
pub(crate) audio_device_id: AudioDeviceID,
is_default: bool,
}
impl Device {
fn name(&self) -> Result<String, DeviceNameError> {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMaster,
};
let device_name: CFStringRef = null();
let data_size = mem::size_of::<CFStringRef>();
let c_str = unsafe {
let status = AudioObjectGetPropertyData(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&device_name as *const _ as *mut _,
);
check_os_status(status)?;
let c_string: *const c_char = CFStringGetCStringPtr(device_name, kCFStringEncodingUTF8);
if c_string.is_null() {
let status = AudioObjectGetPropertyData(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&device_name as *const _ as *mut _,
);
check_os_status(status)?;
let mut buf: [i8; 255] = [0; 255];
let result = CFStringGetCString(
device_name,
buf.as_mut_ptr(),
buf.len() as _,
kCFStringEncodingUTF8,
);
if result == 0 {
let description =
"core foundation failed to return device name string".to_string();
let err = BackendSpecificError { description };
return Err(err.into());
}
let name: &CStr = CStr::from_ptr(buf.as_ptr());
return Ok(name.to_str().unwrap().to_owned());
}
CStr::from_ptr(c_string as *mut _)
};
Ok(c_str.to_string_lossy().into_owned())
}
// Logic re-used between `supported_input_configs` and `supported_output_configs`.
#[allow(clippy::cast_ptr_alignment)]
fn supported_configs(
&self,
scope: AudioObjectPropertyScope,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
let mut property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: scope,
mElement: kAudioObjectPropertyElementMaster,
};
unsafe {
// Retrieve the devices audio buffer list.
let data_size = 0u32;
let status = AudioObjectGetPropertyDataSize(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
);
check_os_status(status)?;
let mut audio_buffer_list: Vec<u8> = vec![];
audio_buffer_list.reserve_exact(data_size as usize);
let status = AudioObjectGetPropertyData(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
audio_buffer_list.as_mut_ptr() as *mut _,
);
check_os_status(status)?;
let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList;
// If there's no buffers, skip.
if (*audio_buffer_list).mNumberBuffers == 0 {
return Ok(vec![].into_iter());
}
// Count the number of channels as the sum of all channels in all output buffers.
let n_buffers = (*audio_buffer_list).mNumberBuffers as usize;
let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr();
let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers);
let mut n_channels = 0;
for buffer in buffers {
n_channels += buffer.mNumberChannels as usize;
}
// TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using
// I16 but just use F32 for now as it's the default anyway.
let sample_format = SampleFormat::F32;
// Get available sample rate ranges.
property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates;
let data_size = 0u32;
let status = AudioObjectGetPropertyDataSize(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
);
check_os_status(status)?;
let n_ranges = data_size as usize / mem::size_of::<AudioValueRange>();
let mut ranges: Vec<u8> = vec![];
ranges.reserve_exact(data_size as usize);
let status = AudioObjectGetPropertyData(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
ranges.as_mut_ptr() as *mut _,
);
check_os_status(status)?;
let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _;
let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges);
let audio_unit = audio_unit_from_device(self, true)?;
let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?;
// Collect the supported formats for the device.
let mut fmts = vec![];
for range in ranges {
let fmt = SupportedStreamConfigRange {
channels: n_channels as ChannelCount,
min_sample_rate: SampleRate(range.mMinimum as _),
max_sample_rate: SampleRate(range.mMaximum as _),
buffer_size,
sample_format,
};
fmts.push(fmt);
}
Ok(fmts.into_iter())
}
}
fn supported_input_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
self.supported_configs(kAudioObjectPropertyScopeInput)
}
fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
self.supported_configs(kAudioObjectPropertyScopeOutput)
}
fn default_config(
&self,
scope: AudioObjectPropertyScope,
) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
fn default_config_error_from_os_status(
status: OSStatus,
) -> Result<(), DefaultStreamConfigError> {
let err = match coreaudio::Error::from_os_status(status) {
Err(err) => err,
Ok(_) => return Ok(()),
};
match err {
coreaudio::Error::AudioUnit(
coreaudio::error::AudioUnitError::FormatNotSupported,
)
| coreaudio::Error::AudioCodec(_)
| coreaudio::Error::AudioFormat(_) => {
Err(DefaultStreamConfigError::StreamTypeNotSupported)
}
coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::NoConnection) => {
Err(DefaultStreamConfigError::DeviceNotAvailable)
}
err => {
let description = format!("{}", err);
let err = BackendSpecificError { description };
Err(err.into())
}
}
}
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyStreamFormat,
mScope: scope,
mElement: kAudioObjectPropertyElementMaster,
};
unsafe {
let asbd: AudioStreamBasicDescription = mem::zeroed();
let data_size = mem::size_of::<AudioStreamBasicDescription>() as u32;
let status = AudioObjectGetPropertyData(
self.audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&asbd as *const _ as *mut _,
);
default_config_error_from_os_status(status)?;
let sample_format = {
let audio_format = coreaudio::audio_unit::AudioFormat::from_format_and_flag(
asbd.mFormatID,
Some(asbd.mFormatFlags),
);
let flags = match audio_format {
Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) => flags,
_ => return Err(DefaultStreamConfigError::StreamTypeNotSupported),
};
let maybe_sample_format =
coreaudio::audio_unit::SampleFormat::from_flags_and_bits_per_sample(
flags,
asbd.mBitsPerChannel,
);
match maybe_sample_format {
Some(coreaudio::audio_unit::SampleFormat::F32) => SampleFormat::F32,
Some(coreaudio::audio_unit::SampleFormat::I16) => SampleFormat::I16,
_ => return Err(DefaultStreamConfigError::StreamTypeNotSupported),
}
};
let audio_unit = audio_unit_from_device(self, true)?;
let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?;
let config = SupportedStreamConfig {
sample_rate: SampleRate(asbd.mSampleRate as _),
channels: asbd.mChannelsPerFrame as _,
buffer_size,
sample_format,
};
Ok(config)
}
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
self.default_config(kAudioObjectPropertyScopeInput)
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
self.default_config(kAudioObjectPropertyScopeOutput)
}
}
impl fmt::Debug for Device {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("Device")
.field("audio_device_id", &self.audio_device_id)
.field("name", &self.name())
.finish()
}
}
struct StreamInner {
playing: bool,
audio_unit: AudioUnit,
/// Manage the lifetime of the closure that handles device disconnection.
_disconnect_listener: Option<AudioObjectPropertyListener>,
// Track the device with which the audio unit was spawned.
//
// We must do this so that we can avoid changing the device sample rate if there is already
// a stream associated with the device.
#[allow(dead_code)]
device_id: AudioDeviceID,
}
/// Register the on-disconnect callback.
/// This will both stop the stream and call the error callback with DeviceNotAvailable.
/// This function should only be called once per stream.
fn add_disconnect_listener<E>(
stream: &Stream,
error_callback: Arc<Mutex<E>>,
) -> Result<(), BuildStreamError>
where
E: FnMut(StreamError) + Send + 'static,
{
let stream_copy = stream.clone();
let mut stream_inner = stream.inner.lock().unwrap();
stream_inner._disconnect_listener = Some(AudioObjectPropertyListener::new(
stream_inner.device_id,
AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyDeviceIsAlive,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
},
move || {
let _ = stream_copy.pause();
(error_callback.lock().unwrap())(StreamError::DeviceNotAvailable);
},
)?);
Ok(())
}
fn audio_unit_from_device(device: &Device, input: bool) -> Result<AudioUnit, coreaudio::Error> {
// Psysonic patch (macOS mic-prompt suppression):
// Always use DefaultOutput for output streams, regardless of whether
// `device` is flagged as the default. HalOutput instantiation triggers
// macOS TCC microphone-permission prompts even for playback-only apps.
// DefaultOutput never touches input and never prompts for mic access.
// Consequence: per-device output selection is a no-op on macOS — the
// stream follows the system default. This matches Apple Music/Spotify
// behaviour and is surfaced to the user in the Settings UI.
let output_type = if !input {
coreaudio::audio_unit::IOType::DefaultOutput
} else {
coreaudio::audio_unit::IOType::HalOutput
};
let mut audio_unit = AudioUnit::new(output_type)?;
if input {
// Enable input processing.
let enable_input = 1u32;
audio_unit.set_property(
kAudioOutputUnitProperty_EnableIO,
Scope::Input,
Element::Input,
Some(&enable_input),
)?;
// Disable output processing.
let disable_output = 0u32;
audio_unit.set_property(
kAudioOutputUnitProperty_EnableIO,
Scope::Output,
Element::Output,
Some(&disable_output),
)?;
}
audio_unit.set_property(
kAudioOutputUnitProperty_CurrentDevice,
Scope::Global,
Element::Output,
Some(&device.audio_device_id),
)?;
Ok(audio_unit)
}
impl Device {
#[allow(clippy::cast_ptr_alignment)]
#[allow(clippy::while_immutable_condition)]
#[allow(clippy::float_cmp)]
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
// The scope and element for working with a device's input stream.
let scope = Scope::Output;
let element = Element::Input;
// Potentially change the device sample rate to match the config.
set_sample_rate(self.audio_device_id, config.sample_rate)?;
let mut audio_unit = audio_unit_from_device(self, true)?;
// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
// Set the buffersize
match config.buffer_size {
BufferSize::Fixed(v) => {
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
match buffer_size_range {
SupportedBufferSize::Range { min, max } => {
if v >= min && v <= max {
audio_unit.set_property(
kAudioDevicePropertyBufferFrameSize,
scope,
element,
Some(&v),
)?
} else {
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
SupportedBufferSize::Unknown => (),
}
}
BufferSize::Default => (),
}
let error_callback = Arc::new(Mutex::new(error_callback));
let error_callback_disconnect = error_callback.clone();
// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
type Args = render_callback::Args<data::Raw>;
audio_unit.set_input_callback(move |args: Args| unsafe {
let ptr = (*args.data.data).mBuffers.as_ptr();
let len = (*args.data.data).mNumberBuffers as usize;
let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len);
// TODO: Perhaps loop over all buffers instead?
let AudioBuffer {
mNumberChannels: channels,
mDataByteSize: data_byte_size,
mData: data,
} = buffers[0];
let data = data as *mut ();
let len = data_byte_size as usize / bytes_per_channel;
let data = Data::from_parts(data, len, sample_format);
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
(error_callback.lock().unwrap())(err.into());
return Err(());
}
Ok(cb) => cb,
};
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
let capture = callback
.sub(delay)
.expect("`capture` occurs before origin of alsa `StreamInstant`");
let timestamp = crate::InputStreamTimestamp { callback, capture };
let info = InputCallbackInfo { timestamp };
data_callback(&data, &info);
Ok(())
})?;
let stream = Stream::new(StreamInner {
playing: true,
_disconnect_listener: None,
audio_unit,
device_id: self.audio_device_id,
});
// If we didn't request the default device, stop the stream if the
// device disconnects.
if !self.is_default {
add_disconnect_listener(&stream, error_callback_disconnect)?;
}
stream.inner.lock().unwrap().audio_unit.start()?;
Ok(stream)
}
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let mut audio_unit = audio_unit_from_device(self, false)?;
// The scope and element for working with a device's output stream.
let scope = Scope::Input;
let element = Element::Output;
// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;
// Set the buffersize
match config.buffer_size {
BufferSize::Fixed(v) => {
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
match buffer_size_range {
SupportedBufferSize::Range { min, max } => {
if v >= min && v <= max {
audio_unit.set_property(
kAudioDevicePropertyBufferFrameSize,
scope,
element,
Some(&v),
)?
} else {
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
SupportedBufferSize::Unknown => (),
}
}
BufferSize::Default => (),
}
let error_callback = Arc::new(Mutex::new(error_callback));
let error_callback_disconnect = error_callback.clone();
// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
type Args = render_callback::Args<data::Raw>;
audio_unit.set_render_callback(move |args: Args| unsafe {
// If `run()` is currently running, then a callback will be available from this list.
// Otherwise, we just fill the buffer with zeroes and return.
let AudioBuffer {
mNumberChannels: channels,
mDataByteSize: data_byte_size,
mData: data,
} = (*args.data.data).mBuffers[0];
let data = data as *mut ();
let len = data_byte_size as usize / bytes_per_channel;
let mut data = Data::from_parts(data, len, sample_format);
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
(error_callback.lock().unwrap())(err.into());
return Err(());
}
Ok(cb) => cb,
};
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
let playback = callback
.add(delay)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
data_callback(&mut data, &info);
Ok(())
})?;
let stream = Stream::new(StreamInner {
playing: true,
_disconnect_listener: None,
audio_unit,
device_id: self.audio_device_id,
});
// If we didn't request the default device, stop the stream if the
// device disconnects.
if !self.is_default {
add_disconnect_listener(&stream, error_callback_disconnect)?;
}
stream.inner.lock().unwrap().audio_unit.start()?;
Ok(stream)
}
}
/// Attempt to set the device sample rate to the provided rate.
/// Return an error if the requested sample rate is not supported by the device.
fn set_sample_rate(
audio_device_id: AudioObjectID,
target_sample_rate: SampleRate,
) -> Result<(), BuildStreamError> {
// Get the current sample rate.
let mut property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
let sample_rate: f64 = 0.0;
let data_size = mem::size_of::<f64>() as u32;
let status = unsafe {
AudioObjectGetPropertyData(
audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&sample_rate as *const _ as *mut _,
)
};
coreaudio::Error::from_os_status(status)?;
// If the requested sample rate is different to the device sample rate, update the device.
if sample_rate as u32 != target_sample_rate.0 {
// Get available sample rate ranges.
property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates;
let data_size = 0u32;
let status = unsafe {
AudioObjectGetPropertyDataSize(
audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
)
};
coreaudio::Error::from_os_status(status)?;
let n_ranges = data_size as usize / mem::size_of::<AudioValueRange>();
let mut ranges: Vec<u8> = vec![];
ranges.reserve_exact(data_size as usize);
let status = unsafe {
AudioObjectGetPropertyData(
audio_device_id,
&property_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
ranges.as_mut_ptr() as *mut _,
)
};
coreaudio::Error::from_os_status(status)?;
let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _;
let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) };
// Now that we have the available ranges, pick the one matching the desired rate.
let sample_rate = target_sample_rate.0;
let maybe_index = ranges
.iter()
.position(|r| r.mMinimum as u32 == sample_rate && r.mMaximum as u32 == sample_rate);
let range_index = match maybe_index {
None => return Err(BuildStreamError::StreamConfigNotSupported),
Some(i) => i,
};
let (send, recv) = channel::<Result<f64, coreaudio::Error>>();
let sample_rate_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
// Send sample rate updates back on a channel.
let sample_rate_handler = move || {
let mut rate: f64 = 0.0;
let data_size = mem::size_of::<f64>();
let result = unsafe {
AudioObjectGetPropertyData(
audio_device_id,
&sample_rate_address as *const _,
0,
null(),
&data_size as *const _ as *mut _,
&mut rate as *const _ as *mut _,
)
};
send.send(coreaudio::Error::from_os_status(result).map(|_| rate))
.ok();
};
let listener = AudioObjectPropertyListener::new(
audio_device_id,
sample_rate_address,
sample_rate_handler,
)?;
// Finally, set the sample rate.
property_address.mSelector = kAudioDevicePropertyNominalSampleRate;
let status = unsafe {
AudioObjectSetPropertyData(
audio_device_id,
&property_address as *const _,
0,
null(),
data_size,
&ranges[range_index] as *const _ as *const _,
)
};
coreaudio::Error::from_os_status(status)?;
// Wait for the reported_rate to change.
//
// This should not take longer than a few ms, but we timeout after 1 sec just in case.
// We loop over potentially several events from the channel to ensure
// that we catch the expected change in sample rate.
let mut timeout = Duration::from_secs(1);
let start = Instant::now();
loop {
match recv.recv_timeout(timeout) {
Err(err) => {
let description = match err {
RecvTimeoutError::Disconnected => {
"sample rate listener channel disconnected unexpectedly"
}
RecvTimeoutError::Timeout => {
"timeout waiting for sample rate update for device"
}
}
.to_string();
return Err(BackendSpecificError { description }.into());
}
Ok(Ok(reported_sample_rate)) => {
if reported_sample_rate == target_sample_rate.0 as f64 {
break;
}
}
Ok(Err(_)) => {
// TODO: should we consider collecting this error?
}
};
timeout = timeout
.checked_sub(start.elapsed())
.unwrap_or(Duration::ZERO);
}
listener.remove()?;
}
Ok(())
}
#[derive(Clone)]
pub struct Stream {
inner: Arc<Mutex<StreamInner>>,
}
impl Stream {
fn new(inner: StreamInner) -> Self {
Self {
inner: Arc::new(Mutex::new(inner)),
}
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
let mut stream = self.inner.lock().unwrap();
if !stream.playing {
if let Err(e) = stream.audio_unit.start() {
let description = format!("{}", e);
let err = BackendSpecificError { description };
return Err(err.into());
}
stream.playing = true;
}
Ok(())
}
fn pause(&self) -> Result<(), PauseStreamError> {
let mut stream = self.inner.lock().unwrap();
if stream.playing {
if let Err(e) = stream.audio_unit.stop() {
let description = format!("{}", e);
let err = BackendSpecificError { description };
return Err(err.into());
}
stream.playing = false;
}
Ok(())
}
}
fn get_io_buffer_frame_size_range(
audio_unit: &AudioUnit,
) -> Result<SupportedBufferSize, coreaudio::Error> {
let buffer_size_range: AudioValueRange = audio_unit.get_property(
kAudioDevicePropertyBufferFrameSizeRange,
Scope::Global,
Element::Output,
)?;
Ok(SupportedBufferSize::Range {
min: buffer_size_range.mMinimum as u32,
max: buffer_size_range.mMaximum as u32,
})
}
@@ -0,0 +1,85 @@
//! Helper code for registering audio object property listeners.
use super::coreaudio::sys::{
AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectRemovePropertyListener, OSStatus,
};
use crate::BuildStreamError;
/// A double-indirection to be able to pass a closure (a fat pointer)
/// via a single c_void.
struct PropertyListenerCallbackWrapper(Box<dyn FnMut()>);
/// Maintain an audio object property listener.
/// The listener will be removed when this type is dropped.
pub struct AudioObjectPropertyListener {
callback: Box<PropertyListenerCallbackWrapper>,
property_address: AudioObjectPropertyAddress,
audio_object_id: AudioObjectID,
removed: bool,
}
impl AudioObjectPropertyListener {
/// Attach the provided callback as a audio object property listener.
pub fn new<F: FnMut() + 'static>(
audio_object_id: AudioObjectID,
property_address: AudioObjectPropertyAddress,
callback: F,
) -> Result<Self, BuildStreamError> {
let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(callback)));
unsafe {
coreaudio::Error::from_os_status(AudioObjectAddPropertyListener(
audio_object_id,
&property_address as *const _,
Some(property_listener_handler_shim),
&*callback as *const _ as *mut _,
))?;
};
Ok(Self {
callback,
audio_object_id,
property_address,
removed: false,
})
}
/// Explicitly remove the property listener.
/// Use this method if you need to explicitly handle failure to remove
/// the property listener.
pub fn remove(mut self) -> Result<(), BuildStreamError> {
self.remove_inner()
}
fn remove_inner(&mut self) -> Result<(), BuildStreamError> {
unsafe {
coreaudio::Error::from_os_status(AudioObjectRemovePropertyListener(
self.audio_object_id,
&self.property_address as *const _,
Some(property_listener_handler_shim),
&*self.callback as *const _ as *mut _,
))?;
}
self.removed = true;
Ok(())
}
}
impl Drop for AudioObjectPropertyListener {
fn drop(&mut self) {
if !self.removed {
let _ = self.remove_inner();
}
}
}
/// Callback used to call user-provided closure as a property listener.
unsafe extern "C" fn property_listener_handler_shim(
_: AudioObjectID,
_: u32,
_: *const AudioObjectPropertyAddress,
callback: *mut ::std::os::raw::c_void,
) -> OSStatus {
let wrapper = callback as *mut PropertyListenerCallbackWrapper;
(*wrapper).0();
0
}
@@ -0,0 +1,121 @@
extern crate coreaudio;
use self::coreaudio::sys::{
kAudioFormatFlagIsFloat, kAudioFormatFlagIsPacked, kAudioFormatLinearPCM,
AudioStreamBasicDescription, OSStatus,
};
use crate::DefaultStreamConfigError;
use crate::{BuildStreamError, SupportedStreamConfigsError};
use crate::{BackendSpecificError, SampleFormat, StreamConfig};
#[cfg(target_os = "ios")]
mod ios;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "ios")]
pub use self::ios::{
enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs},
Device, Host, Stream,
};
#[cfg(target_os = "macos")]
pub use self::macos::{
enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs},
Device, Host, Stream,
};
/// Common helper methods used by both macOS and iOS
fn check_os_status(os_status: OSStatus) -> Result<(), BackendSpecificError> {
match coreaudio::Error::from_os_status(os_status) {
Ok(()) => Ok(()),
Err(err) => {
let description = err.to_string();
Err(BackendSpecificError { description })
}
}
}
// Create a coreaudio AudioStreamBasicDescription from a CPAL Format.
fn asbd_from_config(
config: &StreamConfig,
sample_format: SampleFormat,
) -> AudioStreamBasicDescription {
let n_channels = config.channels as usize;
let sample_rate = config.sample_rate.0;
let bytes_per_channel = sample_format.sample_size();
let bits_per_channel = bytes_per_channel * 8;
let bytes_per_frame = n_channels * bytes_per_channel;
let frames_per_packet = 1;
let bytes_per_packet = frames_per_packet * bytes_per_frame;
let format_flags = match sample_format {
SampleFormat::F32 => kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
_ => kAudioFormatFlagIsPacked,
};
AudioStreamBasicDescription {
mBitsPerChannel: bits_per_channel as _,
mBytesPerFrame: bytes_per_frame as _,
mChannelsPerFrame: n_channels as _,
mBytesPerPacket: bytes_per_packet as _,
mFramesPerPacket: frames_per_packet as _,
mFormatFlags: format_flags,
mFormatID: kAudioFormatLinearPCM,
mSampleRate: sample_rate as _,
..Default::default()
}
}
fn host_time_to_stream_instant(
m_host_time: u64,
) -> Result<crate::StreamInstant, BackendSpecificError> {
let mut info: mach2::mach_time::mach_timebase_info = Default::default();
let res = unsafe { mach2::mach_time::mach_timebase_info(&mut info) };
check_os_status(res)?;
let nanos = m_host_time * info.numer as u64 / info.denom as u64;
let secs = nanos / 1_000_000_000;
let subsec_nanos = nanos - secs * 1_000_000_000;
Ok(crate::StreamInstant::new(secs as i64, subsec_nanos as u32))
}
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
let secsf = frames as f64 / rate.0 as f64;
let secs = secsf as u64;
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
std::time::Duration::new(secs, nanos)
}
// TODO need stronger error identification
impl From<coreaudio::Error> for BuildStreamError {
fn from(err: coreaudio::Error) -> BuildStreamError {
match err {
coreaudio::Error::RenderCallbackBufferFormatDoesNotMatchAudioUnitStreamFormat
| coreaudio::Error::NoKnownSubtype
| coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::FormatNotSupported)
| coreaudio::Error::AudioCodec(_)
| coreaudio::Error::AudioFormat(_) => BuildStreamError::StreamConfigNotSupported,
_ => BuildStreamError::DeviceNotAvailable,
}
}
}
impl From<coreaudio::Error> for SupportedStreamConfigsError {
fn from(err: coreaudio::Error) -> SupportedStreamConfigsError {
let description = format!("{}", err);
let err = BackendSpecificError { description };
// Check for possible DeviceNotAvailable variant
SupportedStreamConfigsError::BackendSpecific { err }
}
}
impl From<coreaudio::Error> for DefaultStreamConfigError {
fn from(err: coreaudio::Error) -> DefaultStreamConfigError {
let description = format!("{}", err);
let err = BackendSpecificError { description };
// Check for possible DeviceNotAvailable variant
DefaultStreamConfigError::BackendSpecific { err }
}
}
@@ -0,0 +1,422 @@
use js_sys::Float32Array;
use std::time::Duration;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::AudioContext;
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BufferSize, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
SampleRate, StreamConfig, StreamError, SupportedBufferSize, SupportedStreamConfig,
SupportedStreamConfigRange, SupportedStreamConfigsError,
};
// The emscripten backend currently works by instantiating an `AudioContext` object per `Stream`.
// Creating a stream creates a new `AudioContext`. Destroying a stream destroys it. Creation of a
// `Host` instance initializes the `stdweb` context.
/// The default emscripten host type.
#[derive(Debug)]
pub struct Host;
/// Content is false if the iterator is empty.
pub struct Devices(bool);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device;
#[wasm_bindgen]
#[derive(Clone)]
pub struct Stream {
// A reference to an `AudioContext` object.
audio_ctxt: AudioContext,
}
// Index within the `streams` array of the events loop.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StreamId(usize);
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
const MIN_CHANNELS: u16 = 1;
const MAX_CHANNELS: u16 = 32;
const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000);
const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000);
const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100);
const MIN_BUFFER_SIZE: u32 = 1;
const MAX_BUFFER_SIZE: u32 = u32::MAX;
const DEFAULT_BUFFER_SIZE: usize = 2048;
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl Devices {
fn new() -> Result<Self, DevicesError> {
Ok(Self::default())
}
}
impl Device {
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Ok("Default Device".to_owned())
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
unimplemented!();
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
let buffer_size = SupportedBufferSize::Range {
min: MIN_BUFFER_SIZE,
max: MAX_BUFFER_SIZE,
};
let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS)
.map(|channels| SupportedStreamConfigRange {
channels,
min_sample_rate: MIN_SAMPLE_RATE,
max_sample_rate: MAX_SAMPLE_RATE,
buffer_size: buffer_size.clone(),
sample_format: SUPPORTED_SAMPLE_FORMAT,
})
.collect();
Ok(configs.into_iter())
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
unimplemented!();
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
const EXPECT: &str = "expected at least one valid webaudio stream config";
let config = self
.supported_output_configs()
.expect(EXPECT)
.max_by(|a, b| a.cmp_default_heuristics(b))
.unwrap()
.with_sample_rate(DEFAULT_SAMPLE_RATE);
Ok(config)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
// Assume this host is always available on emscripten.
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Self::Device> {
default_input_device()
}
fn default_output_device(&self) -> Option<Self::Device> {
default_output_device()
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, DeviceNameError> {
Device::name(self)
}
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Device::supported_input_configs(self)
}
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Device::supported_output_configs(self)
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_input_config(self)
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_output_config(self)
}
fn build_input_stream_raw<D, E>(
&self,
_config: &StreamConfig,
_sample_format: SampleFormat,
_data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
unimplemented!()
}
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
if !valid_config(config, sample_format) {
return Err(BuildStreamError::StreamConfigNotSupported);
}
let buffer_size_frames = match config.buffer_size {
BufferSize::Fixed(v) => {
if v == 0 {
return Err(BuildStreamError::StreamConfigNotSupported);
} else {
v as usize
}
}
BufferSize::Default => DEFAULT_BUFFER_SIZE,
};
// Create the stream.
let audio_ctxt = AudioContext::new().expect("webaudio is not present on this system");
let stream = Stream { audio_ctxt };
// Use `set_timeout` to invoke a Rust callback repeatedly.
//
// The job of this callback is to fill the content of the audio buffers.
//
// See also: The call to `set_timeout` at the end of the `audio_callback_fn` which creates
// the loop.
set_timeout(
10,
stream.clone(),
data_callback,
config,
sample_format,
buffer_size_frames as u32,
);
Ok(stream)
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
let future = JsFuture::from(
self.audio_ctxt
.resume()
.expect("Could not resume the stream"),
);
spawn_local(async {
match future.await {
Ok(value) => assert!(value.is_undefined()),
Err(value) => panic!("AudioContext.resume() promise was rejected: {:?}", value),
}
});
Ok(())
}
fn pause(&self) -> Result<(), PauseStreamError> {
let future = JsFuture::from(
self.audio_ctxt
.suspend()
.expect("Could not suspend the stream"),
);
spawn_local(async {
match future.await {
Ok(value) => assert!(value.is_undefined()),
Err(value) => panic!("AudioContext.suspend() promise was rejected: {:?}", value),
}
});
Ok(())
}
}
fn audio_callback_fn<D>(
mut data_callback: D,
) -> impl FnOnce(Stream, StreamConfig, SampleFormat, u32)
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
{
|stream, config, sample_format, buffer_size_frames| {
let sample_rate = config.sample_rate.0;
let buffer_size_samples = buffer_size_frames * config.channels as u32;
let audio_ctxt = &stream.audio_ctxt;
// TODO: We should be re-using a buffer.
let mut temporary_buffer = vec![0f32; buffer_size_samples as usize];
{
let len = temporary_buffer.len();
let data = temporary_buffer.as_mut_ptr() as *mut ();
let mut data = unsafe { Data::from_parts(data, len, sample_format) };
let now_secs: f64 = audio_ctxt.current_time();
let callback = crate::StreamInstant::from_secs_f64(now_secs);
// TODO: Use proper latency instead. Currently, unsupported on most browsers though, so
// we estimate based on buffer size instead. Probably should use this, but it's only
// supported by firefox (2020-04-28).
// let latency_secs: f64 = audio_ctxt.outputLatency.try_into().unwrap();
let buffer_duration = frames_to_duration(len, sample_rate as usize);
let playback = callback
.add(buffer_duration)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
data_callback(&mut data, &info);
}
let typed_array: Float32Array = temporary_buffer.as_slice().into();
debug_assert_eq!(temporary_buffer.len() % config.channels as usize, 0);
let src_buffer = Float32Array::new(typed_array.buffer().as_ref());
let context = audio_ctxt;
let buffer = context
.create_buffer(
config.channels as u32,
buffer_size_frames as u32,
sample_rate as f32,
)
.expect("Buffer could not be created");
for channel in 0..config.channels {
let mut buffer_content = buffer
.get_channel_data(channel as u32)
.expect("Should be impossible");
for (i, buffer_content_item) in buffer_content.iter_mut().enumerate() {
*buffer_content_item =
src_buffer.get_index(i as u32 * config.channels as u32 + channel as u32);
}
}
let node = context
.create_buffer_source()
.expect("The buffer source node could not be created");
node.set_buffer(Some(&buffer));
context
.destination()
.connect_with_audio_node(&node)
.expect("Could not connect the audio node to the destination");
node.start().expect("Could not start the audio node");
// TODO: handle latency better ; right now we just use setInterval with the amount of sound
// data that is in each buffer ; this is obviously bad, and also the schedule is too tight
// and there may be underflows
set_timeout(
1000 * buffer_size_frames as i32 / sample_rate as i32,
stream.clone().clone(),
data_callback,
&config,
sample_format,
buffer_size_frames as u32,
);
}
}
fn set_timeout<D>(
time: i32,
stream: Stream,
data_callback: D,
config: &StreamConfig,
sample_format: SampleFormat,
buffer_size_frames: u32,
) where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
{
let window = web_sys::window().expect("Not in a window somehow?");
window
.set_timeout_with_callback_and_timeout_and_arguments_4(
&Closure::once_into_js(audio_callback_fn(data_callback))
.dyn_ref::<js_sys::Function>()
.expect("The function was somehow not a function"),
time,
&stream.into(),
&((*config).clone()).into(),
&Closure::once_into_js(move || sample_format),
&buffer_size_frames.into(),
)
.expect("The timeout could not be set");
}
impl Default for Devices {
fn default() -> Devices {
// We produce an empty iterator if the WebAudio API isn't available.
Devices(is_webaudio_available())
}
}
impl Iterator for Devices {
type Item = Device;
#[inline]
fn next(&mut self) -> Option<Device> {
if self.0 {
self.0 = false;
Some(Device)
} else {
None
}
}
}
#[inline]
fn default_input_device() -> Option<Device> {
unimplemented!();
}
#[inline]
fn default_output_device() -> Option<Device> {
if is_webaudio_available() {
Some(Device)
} else {
None
}
}
// Detects whether the `AudioContext` global variable is available.
fn is_webaudio_available() -> bool {
AudioContext::new().is_ok()
}
// Whether or not the given stream configuration is valid for building a stream.
fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool {
conf.channels <= MAX_CHANNELS
&& conf.channels >= MIN_CHANNELS
&& conf.sample_rate <= MAX_SAMPLE_RATE
&& conf.sample_rate >= MIN_SAMPLE_RATE
&& sample_format == SUPPORTED_SAMPLE_FORMAT
}
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
fn frames_to_duration(frames: usize, rate: usize) -> std::time::Duration {
let secsf = frames as f64 / rate as f64;
let secs = secsf as u64;
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
std::time::Duration::new(secs, nanos)
}
@@ -0,0 +1,268 @@
use crate::traits::DeviceTrait;
use crate::{
BackendSpecificError, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError,
InputCallbackInfo, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, StreamError,
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
SupportedStreamConfigsError,
};
use std::hash::{Hash, Hasher};
use std::time::Duration;
use super::stream::Stream;
use super::JACK_SAMPLE_FORMAT;
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
const DEFAULT_NUM_CHANNELS: u16 = 2;
const DEFAULT_SUPPORTED_CHANNELS: [u16; 10] = [1, 2, 4, 6, 8, 16, 24, 32, 48, 64];
/// If a device is for input or output.
/// Until we have duplex stream support JACK clients and CPAL devices for JACK will be either input or output.
#[derive(Clone, Debug)]
pub enum DeviceType {
InputDevice,
OutputDevice,
}
#[derive(Clone, Debug)]
pub struct Device {
name: String,
sample_rate: SampleRate,
buffer_size: SupportedBufferSize,
device_type: DeviceType,
start_server_automatically: bool,
connect_ports_automatically: bool,
}
impl Device {
fn new_device(
name: String,
connect_ports_automatically: bool,
start_server_automatically: bool,
device_type: DeviceType,
) -> Result<Self, String> {
// ClientOptions are bit flags that you can set with the constants provided
let client_options = super::get_client_options(start_server_automatically);
// Create a dummy client to find out the sample rate of the server to be able to provide it as a possible config.
// This client will be dropped, and a new one will be created when making the stream.
// This is a hack due to the fact that the Client must be moved to create the AsyncClient.
match super::get_client(&name, client_options) {
Ok(client) => Ok(Device {
// The name given to the client by JACK, could potentially be different from the name supplied e.g.if there is a name collision
name: client.name().to_string(),
sample_rate: SampleRate(client.sample_rate() as u32),
buffer_size: SupportedBufferSize::Range {
min: client.buffer_size(),
max: client.buffer_size(),
},
device_type,
start_server_automatically,
connect_ports_automatically,
}),
Err(e) => Err(e),
}
}
pub fn default_output_device(
name: &str,
connect_ports_automatically: bool,
start_server_automatically: bool,
) -> Result<Self, String> {
let output_client_name = format!("{}_out", name);
Device::new_device(
output_client_name,
connect_ports_automatically,
start_server_automatically,
DeviceType::OutputDevice,
)
}
pub fn default_input_device(
name: &str,
connect_ports_automatically: bool,
start_server_automatically: bool,
) -> Result<Self, String> {
let input_client_name = format!("{}_in", name);
Device::new_device(
input_client_name,
connect_ports_automatically,
start_server_automatically,
DeviceType::InputDevice,
)
}
pub fn default_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let channels = DEFAULT_NUM_CHANNELS;
let sample_rate = self.sample_rate;
let buffer_size = self.buffer_size.clone();
// The sample format for JACK audio ports is always "32-bit float mono audio" in the current implementation.
// Custom formats are allowed within JACK, but this is of niche interest.
// The format can be found programmatically by calling jack::PortSpec::port_type() on a created port.
let sample_format = JACK_SAMPLE_FORMAT;
Ok(SupportedStreamConfig {
channels,
sample_rate,
buffer_size,
sample_format,
})
}
pub fn supported_configs(&self) -> Vec<SupportedStreamConfigRange> {
let f = match self.default_config() {
Err(_) => return vec![],
Ok(f) => f,
};
let mut supported_configs = vec![];
for &channels in DEFAULT_SUPPORTED_CHANNELS.iter() {
supported_configs.push(SupportedStreamConfigRange {
channels,
min_sample_rate: f.sample_rate,
max_sample_rate: f.sample_rate,
buffer_size: f.buffer_size.clone(),
sample_format: f.sample_format,
});
}
supported_configs
}
pub fn is_input(&self) -> bool {
matches!(self.device_type, DeviceType::InputDevice)
}
pub fn is_output(&self) -> bool {
matches!(self.device_type, DeviceType::OutputDevice)
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, DeviceNameError> {
Ok(self.name.clone())
}
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Ok(self.supported_configs().into_iter())
}
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Ok(self.supported_configs().into_iter())
}
/// Returns the default input config
/// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type.
/// The sample rate is set by the JACK server.
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
self.default_config()
}
/// Returns the default output config
/// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type.
/// The sample rate is set by the JACK server.
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
self.default_config()
}
fn build_input_stream_raw<D, E>(
&self,
conf: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
if let DeviceType::OutputDevice = &self.device_type {
// Trying to create an input stream from an output device
return Err(BuildStreamError::StreamConfigNotSupported);
}
if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT {
return Err(BuildStreamError::StreamConfigNotSupported);
}
// The settings should be fine, create a Client
let client_options = super::get_client_options(self.start_server_automatically);
let client;
match super::get_client(&self.name, client_options) {
Ok(c) => client = c,
Err(e) => {
return Err(BuildStreamError::BackendSpecific {
err: BackendSpecificError { description: e },
})
}
};
let mut stream = Stream::new_input(client, conf.channels, data_callback, error_callback);
if self.connect_ports_automatically {
stream.connect_to_system_inputs();
}
Ok(stream)
}
fn build_output_stream_raw<D, E>(
&self,
conf: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
if let DeviceType::InputDevice = &self.device_type {
// Trying to create an output stream from an input device
return Err(BuildStreamError::StreamConfigNotSupported);
}
if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT {
return Err(BuildStreamError::StreamConfigNotSupported);
}
// The settings should be fine, create a Client
let client_options = super::get_client_options(self.start_server_automatically);
let client;
match super::get_client(&self.name, client_options) {
Ok(c) => client = c,
Err(e) => {
return Err(BuildStreamError::BackendSpecific {
err: BackendSpecificError { description: e },
})
}
};
let mut stream = Stream::new_output(client, conf.channels, data_callback, error_callback);
if self.connect_ports_automatically {
stream.connect_to_system_outputs();
}
Ok(stream)
}
}
impl PartialEq for Device {
fn eq(&self, other: &Self) -> bool {
// Device::name() can never fail in this implementation
self.name().unwrap() == other.name().unwrap()
}
}
impl Eq for Device {}
impl Hash for Device {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name().unwrap().hash(state);
}
}
@@ -0,0 +1,180 @@
extern crate jack;
use crate::traits::HostTrait;
use crate::{DevicesError, SampleFormat, SupportedStreamConfigRange};
mod device;
pub use self::device::Device;
pub use self::stream::Stream;
mod stream;
const JACK_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
pub type Devices = std::vec::IntoIter<Device>;
/// The JACK Host type
#[derive(Debug)]
pub struct Host {
/// The name that the client will have in JACK.
/// Until we have duplex streams two clients will be created adding "out" or "in" to the name
/// since names have to be unique.
name: String,
/// If ports are to be connected to the system (soundcard) ports automatically (default is true).
connect_ports_automatically: bool,
/// If the JACK server should be started automatically if it isn't already when creating a Client (default is false).
start_server_automatically: bool,
/// A list of the devices that have been created from this Host.
devices_created: Vec<Device>,
}
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
let mut host = Host {
name: "cpal_client".to_owned(),
connect_ports_automatically: true,
start_server_automatically: false,
devices_created: vec![],
};
// Devices don't exist for JACK, they have to be created
host.initialize_default_devices();
Ok(host)
}
/// Set whether the ports should automatically be connected to system
/// (default is true)
pub fn set_connect_automatically(&mut self, do_connect: bool) {
self.connect_ports_automatically = do_connect;
}
/// Set whether a JACK server should be automatically started if it isn't already.
/// (default is false)
pub fn set_start_server_automatically(&mut self, do_start_server: bool) {
self.start_server_automatically = do_start_server;
}
pub fn input_device_with_name(&mut self, name: &str) -> Option<Device> {
self.name = name.to_owned();
self.default_input_device()
}
pub fn output_device_with_name(&mut self, name: &str) -> Option<Device> {
self.name = name.to_owned();
self.default_output_device()
}
fn initialize_default_devices(&mut self) {
let in_device_res = Device::default_input_device(
&self.name,
self.connect_ports_automatically,
self.start_server_automatically,
);
match in_device_res {
Ok(device) => self.devices_created.push(device),
Err(err) => {
println!("{}", err);
}
}
let out_device_res = Device::default_output_device(
&self.name,
self.connect_ports_automatically,
self.start_server_automatically,
);
match out_device_res {
Ok(device) => self.devices_created.push(device),
Err(err) => {
println!("{}", err);
}
}
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
/// JACK is available if
/// - the jack feature flag is set
/// - libjack is installed (wouldn't compile without it)
/// - the JACK server can be started
///
/// If the code compiles the necessary jack libraries are installed.
/// There is no way to know if the user has set up a correct JACK configuration e.g. with qjackctl.
/// Users can choose to automatically start the server if it isn't already started when creating a client
/// so checking if the server is running could give a false negative in some use cases.
/// For these reasons this function should always return true.
fn is_available() -> bool {
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Ok(self.devices_created.clone().into_iter())
}
fn default_input_device(&self) -> Option<Self::Device> {
for device in &self.devices_created {
if device.is_input() {
return Some(device.clone());
}
}
None
}
fn default_output_device(&self) -> Option<Self::Device> {
for device in &self.devices_created {
if device.is_output() {
return Some(device.clone());
}
}
None
}
}
fn get_client_options(start_server_automatically: bool) -> jack::ClientOptions {
let mut client_options = jack::ClientOptions::empty();
client_options.set(
jack::ClientOptions::NO_START_SERVER,
!start_server_automatically,
);
client_options
}
fn get_client(name: &str, client_options: jack::ClientOptions) -> Result<jack::Client, String> {
let c_res = jack::Client::new(name, client_options);
match c_res {
Ok((client, status)) => {
// The ClientStatus can tell us many things
if status.intersects(jack::ClientStatus::SERVER_ERROR) {
return Err(String::from(
"There was an error communicating with the JACK server!",
));
} else if status.intersects(jack::ClientStatus::SERVER_FAILED) {
return Err(String::from("Could not connect to the JACK server!"));
} else if status.intersects(jack::ClientStatus::VERSION_ERROR) {
return Err(String::from(
"Error connecting to JACK server: Client's protocol version does not match!",
));
} else if status.intersects(jack::ClientStatus::INIT_FAILURE) {
return Err(String::from(
"Error connecting to JACK server: Unable to initialize client!",
));
} else if status.intersects(jack::ClientStatus::SHM_FAILURE) {
return Err(String::from(
"Error connecting to JACK server: Unable to access shared memory!",
));
} else if status.intersects(jack::ClientStatus::NO_SUCH_CLIENT) {
return Err(String::from(
"Error connecting to JACK server: Requested client does not exist!",
));
} else if status.intersects(jack::ClientStatus::INVALID_OPTION) {
return Err(String::from("Error connecting to JACK server: The operation contained an invalid or unsupported option!"));
}
return Ok(client);
}
Err(e) => {
return Err(format!("Failed to open client because of error: {:?}", e));
}
}
}
@@ -0,0 +1,463 @@
use crate::traits::StreamTrait;
use crate::ChannelCount;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::{
BackendSpecificError, Data, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
PlayStreamError, SampleRate, StreamError,
};
use super::JACK_SAMPLE_FORMAT;
type ErrorCallbackPtr = Arc<Mutex<dyn FnMut(StreamError) + Send + 'static>>;
pub struct Stream {
// TODO: It might be faster to send a message when playing/pausing than to check this every iteration
playing: Arc<AtomicBool>,
async_client: jack::AsyncClient<JackNotificationHandler, LocalProcessHandler>,
// Port names are stored in order to connect them to other ports in jack automatically
input_port_names: Vec<String>,
output_port_names: Vec<String>,
}
impl Stream {
// TODO: Return error messages
pub fn new_input<D, E>(
client: jack::Client,
channels: ChannelCount,
data_callback: D,
mut error_callback: E,
) -> Stream
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let mut ports = vec![];
let mut port_names: Vec<String> = vec![];
// Create ports
for i in 0..channels {
let port_try = client.register_port(&format!("in_{}", i), jack::AudioIn::default());
match port_try {
Ok(port) => {
// Get the port name in order to later connect it automatically
if let Ok(port_name) = port.name() {
port_names.push(port_name);
}
// Store the port into a Vec to move to the ProcessHandler
ports.push(port);
}
Err(e) => {
// If port creation failed, send the error back via the error_callback
error_callback(
BackendSpecificError {
description: e.to_string(),
}
.into(),
);
}
}
}
let playing = Arc::new(AtomicBool::new(true));
let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr;
let input_process_handler = LocalProcessHandler::new(
vec![],
ports,
SampleRate(client.sample_rate() as u32),
client.buffer_size() as usize,
Some(Box::new(data_callback)),
None,
playing.clone(),
Arc::clone(&error_callback_ptr),
);
let notification_handler = JackNotificationHandler::new(error_callback_ptr);
let async_client = client
.activate_async(notification_handler, input_process_handler)
.unwrap();
Stream {
playing,
async_client,
input_port_names: port_names,
output_port_names: vec![],
}
}
pub fn new_output<D, E>(
client: jack::Client,
channels: ChannelCount,
data_callback: D,
mut error_callback: E,
) -> Stream
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let mut ports = vec![];
let mut port_names: Vec<String> = vec![];
// Create ports
for i in 0..channels {
let port_try = client.register_port(&format!("out_{}", i), jack::AudioOut::default());
match port_try {
Ok(port) => {
// Get the port name in order to later connect it automatically
if let Ok(port_name) = port.name() {
port_names.push(port_name);
}
// Store the port into a Vec to move to the ProcessHandler
ports.push(port);
}
Err(e) => {
// If port creation failed, send the error back via the error_callback
error_callback(
BackendSpecificError {
description: e.to_string(),
}
.into(),
);
}
}
}
let playing = Arc::new(AtomicBool::new(true));
let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr;
let output_process_handler = LocalProcessHandler::new(
ports,
vec![],
SampleRate(client.sample_rate() as u32),
client.buffer_size() as usize,
None,
Some(Box::new(data_callback)),
playing.clone(),
Arc::clone(&error_callback_ptr),
);
let notification_handler = JackNotificationHandler::new(error_callback_ptr);
let async_client = client
.activate_async(notification_handler, output_process_handler)
.unwrap();
Stream {
playing,
async_client,
input_port_names: vec![],
output_port_names: port_names,
}
}
/// Connect to the standard system outputs in jack, system:playback_1 and system:playback_2
/// This has to be done after the client is activated, doing it just after creating the ports doesn't work.
pub fn connect_to_system_outputs(&mut self) {
// Get the system ports
let system_ports = self.async_client.as_client().ports(
Some("system:playback_.*"),
None,
jack::PortFlags::empty(),
);
// Connect outputs from this client to the system playback inputs
for i in 0..self.output_port_names.len() {
if i >= system_ports.len() {
break;
}
match self
.async_client
.as_client()
.connect_ports_by_name(&self.output_port_names[i], &system_ports[i])
{
Ok(_) => (),
Err(e) => println!("Unable to connect to port with error {}", e),
}
}
}
/// Connect to the standard system outputs in jack, system:capture_1 and system:capture_2
/// This has to be done after the client is activated, doing it just after creating the ports doesn't work.
pub fn connect_to_system_inputs(&mut self) {
// Get the system ports
let system_ports = self.async_client.as_client().ports(
Some("system:capture_.*"),
None,
jack::PortFlags::empty(),
);
// Connect outputs from this client to the system playback inputs
for i in 0..self.input_port_names.len() {
if i >= system_ports.len() {
break;
}
match self
.async_client
.as_client()
.connect_ports_by_name(&system_ports[i], &self.input_port_names[i])
{
Ok(_) => (),
Err(e) => println!("Unable to connect to port with error {}", e),
}
}
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
self.playing.store(true, Ordering::SeqCst);
Ok(())
}
fn pause(&self) -> Result<(), PauseStreamError> {
self.playing.store(false, Ordering::SeqCst);
Ok(())
}
}
struct LocalProcessHandler {
/// No new ports are allowed to be created after the creation of the LocalProcessHandler as that would invalidate the buffer sizes
out_ports: Vec<jack::Port<jack::AudioOut>>,
in_ports: Vec<jack::Port<jack::AudioIn>>,
sample_rate: SampleRate,
buffer_size: usize,
input_data_callback: Option<Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>>,
output_data_callback: Option<Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>>,
// JACK audio samples are 32-bit float (unless you do some custom dark magic)
temp_input_buffer: Vec<f32>,
temp_output_buffer: Vec<f32>,
playing: Arc<AtomicBool>,
creation_timestamp: std::time::Instant,
/// This should not be called on `process`, only on `buffer_size` because it can block.
error_callback_ptr: ErrorCallbackPtr,
}
impl LocalProcessHandler {
fn new(
out_ports: Vec<jack::Port<jack::AudioOut>>,
in_ports: Vec<jack::Port<jack::AudioIn>>,
sample_rate: SampleRate,
buffer_size: usize,
input_data_callback: Option<Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>>,
output_data_callback: Option<
Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>,
>,
playing: Arc<AtomicBool>,
error_callback_ptr: ErrorCallbackPtr,
) -> Self {
// These may be reallocated in the `buffer_size` callback.
let temp_input_buffer = vec![0.0; in_ports.len() * buffer_size];
let temp_output_buffer = vec![0.0; out_ports.len() * buffer_size];
LocalProcessHandler {
out_ports,
in_ports,
sample_rate,
buffer_size,
input_data_callback,
output_data_callback,
temp_input_buffer,
temp_output_buffer,
playing,
creation_timestamp: std::time::Instant::now(),
error_callback_ptr,
}
}
}
fn temp_buffer_to_data(temp_input_buffer: &mut Vec<f32>, total_buffer_size: usize) -> Data {
let slice = &temp_input_buffer[0..total_buffer_size];
let data = slice.as_ptr() as *mut ();
let len = total_buffer_size;
let data = unsafe { Data::from_parts(data, len, JACK_SAMPLE_FORMAT) };
data
}
impl jack::ProcessHandler for LocalProcessHandler {
fn process(&mut self, _: &jack::Client, process_scope: &jack::ProcessScope) -> jack::Control {
if !self.playing.load(Ordering::SeqCst) {
return jack::Control::Continue;
}
// This should be equal to self.buffer_size, but the implementation will
// work even if it is less. Will panic in `temp_buffer_to_data` if greater.
let current_frame_count = process_scope.n_frames() as usize;
// Get timestamp data
let cycle_times = process_scope.cycle_times();
let current_start_usecs = match cycle_times {
Ok(times) => times.current_usecs,
Err(_) => {
// jack was unable to get the current time information
// Fall back to using Instants
let now = std::time::Instant::now();
let duration = now.duration_since(self.creation_timestamp);
duration.as_micros() as u64
}
};
let start_cycle_instant = micros_to_stream_instant(current_start_usecs);
let start_callback_instant = start_cycle_instant
.add(frames_to_duration(
process_scope.frames_since_cycle_start() as usize,
self.sample_rate,
))
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
if let Some(input_callback) = &mut self.input_data_callback {
// Let's get the data from the input ports and run the callback
let num_in_channels = self.in_ports.len();
// Read the data from the input ports into the temporary buffer
// Go through every channel and store its data in the temporary input buffer
for ch_ix in 0..num_in_channels {
let input_channel = &self.in_ports[ch_ix].as_slice(process_scope);
for i in 0..current_frame_count {
self.temp_input_buffer[ch_ix + i * num_in_channels] = input_channel[i];
}
}
// Create a slice of exactly current_frame_count frames
let data = temp_buffer_to_data(
&mut self.temp_input_buffer,
current_frame_count * num_in_channels,
);
// Create timestamp
let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize;
let duration_since_cycle_start =
frames_to_duration(frames_since_cycle_start, self.sample_rate);
let callback = start_callback_instant
.add(duration_since_cycle_start)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let capture = start_callback_instant;
let timestamp = crate::InputStreamTimestamp { callback, capture };
let info = crate::InputCallbackInfo { timestamp };
input_callback(&data, &info);
}
if let Some(output_callback) = &mut self.output_data_callback {
let num_out_channels = self.out_ports.len();
// Create a slice of exactly current_frame_count frames
let mut data = temp_buffer_to_data(
&mut self.temp_output_buffer,
current_frame_count * num_out_channels,
);
// Create timestamp
let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize;
let duration_since_cycle_start =
frames_to_duration(frames_since_cycle_start, self.sample_rate);
let callback = start_callback_instant
.add(duration_since_cycle_start)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let buffer_duration = frames_to_duration(current_frame_count, self.sample_rate);
let playback = start_cycle_instant
.add(buffer_duration)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = crate::OutputCallbackInfo { timestamp };
output_callback(&mut data, &info);
// Deinterlace
for ch_ix in 0..num_out_channels {
let output_channel = &mut self.out_ports[ch_ix].as_mut_slice(process_scope);
for i in 0..current_frame_count {
output_channel[i] = self.temp_output_buffer[ch_ix + i * num_out_channels];
}
}
}
// Continue as normal
jack::Control::Continue
}
fn buffer_size(&mut self, _: &jack::Client, size: jack::Frames) -> jack::Control {
// The `buffer_size` callback is actually called on the process thread, but
// it does not need to be suitable for real-time use. Thus we can simply allocate
// new buffers here. It is also fine to call the error callback.
// Details: https://github.com/RustAudio/rust-jack/issues/137
let new_size = size as usize;
if new_size != self.buffer_size {
self.buffer_size = new_size;
self.temp_input_buffer = vec![0.0; self.in_ports.len() * new_size];
self.temp_output_buffer = vec![0.0; self.out_ports.len() * new_size];
let description = format!("buffer size changed to: {}", new_size);
if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() {
let err = &mut *mutex_guard;
err(BackendSpecificError { description }.into());
}
}
jack::Control::Continue
}
}
fn micros_to_stream_instant(micros: u64) -> crate::StreamInstant {
let nanos = micros * 1000;
let secs = micros / 1_000_000;
let subsec_nanos = nanos - secs * 1_000_000_000;
crate::StreamInstant::new(secs as i64, subsec_nanos as u32)
}
// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration {
let secsf = frames as f64 / rate.0 as f64;
let secs = secsf as u64;
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
std::time::Duration::new(secs, nanos)
}
/// Receives notifications from the JACK server. It is unclear if this may be run concurrent with itself under JACK2 specs
/// so it needs to be Sync.
struct JackNotificationHandler {
error_callback_ptr: ErrorCallbackPtr,
init_sample_rate_flag: Arc<AtomicBool>,
}
impl JackNotificationHandler {
pub fn new(error_callback_ptr: ErrorCallbackPtr) -> Self {
JackNotificationHandler {
error_callback_ptr,
init_sample_rate_flag: Arc::new(AtomicBool::new(false)),
}
}
fn send_error(&mut self, description: String) {
// This thread isn't the audio thread, it's fine to block
if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() {
let err = &mut *mutex_guard;
err(BackendSpecificError { description }.into());
}
}
}
impl jack::NotificationHandler for JackNotificationHandler {
fn shutdown(&mut self, _status: jack::ClientStatus, reason: &str) {
self.send_error(format!("JACK was shut down for reason: {}", reason));
}
fn sample_rate(&mut self, _: &jack::Client, srate: jack::Frames) -> jack::Control {
match self.init_sample_rate_flag.load(Ordering::SeqCst) {
false => {
// One of these notifications is sent every time a client is started.
self.init_sample_rate_flag.store(true, Ordering::SeqCst);
jack::Control::Continue
}
true => {
self.send_error(format!("sample rate changed to: {}", srate));
// Since CPAL currently has no way of signaling a sample rate change in order to make
// all necessary changes that would bring we choose to quit.
jack::Control::Quit
}
}
}
fn xrun(&mut self, _: &jack::Client) -> jack::Control {
self.send_error(String::from("xrun (buffer over or under run)"));
jack::Control::Continue
}
}
@@ -0,0 +1,30 @@
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
))]
pub(crate) mod alsa;
#[cfg(all(windows, feature = "asio"))]
pub(crate) mod asio;
#[cfg(any(target_os = "macos", target_os = "ios"))]
pub(crate) mod coreaudio;
#[cfg(target_os = "emscripten")]
pub(crate) mod emscripten;
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
),
feature = "jack"
))]
pub(crate) mod jack;
pub(crate) mod null;
#[cfg(target_os = "android")]
pub(crate) mod oboe;
#[cfg(windows)]
pub(crate) mod wasapi;
#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))]
pub(crate) mod webaudio;
@@ -0,0 +1,160 @@
use std::time::Duration;
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat,
StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigRange,
SupportedStreamConfigsError,
};
#[derive(Default)]
pub struct Devices;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device;
pub struct Host;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Stream;
pub struct SupportedInputConfigs;
pub struct SupportedOutputConfigs;
impl Host {
#[allow(dead_code)]
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl Devices {
pub fn new() -> Result<Self, DevicesError> {
Ok(Devices)
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Ok("null".to_owned())
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
unimplemented!()
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
unimplemented!()
}
#[inline]
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
unimplemented!()
}
#[inline]
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
unimplemented!()
}
fn build_input_stream_raw<D, E>(
&self,
_config: &StreamConfig,
_sample_format: SampleFormat,
_data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
unimplemented!()
}
/// Create an output stream.
fn build_output_stream_raw<D, E>(
&self,
_config: &StreamConfig,
_sample_format: SampleFormat,
_data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
unimplemented!()
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
false
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Device> {
None
}
fn default_output_device(&self) -> Option<Device> {
None
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
unimplemented!()
}
fn pause(&self) -> Result<(), PauseStreamError> {
unimplemented!()
}
}
impl Iterator for Devices {
type Item = Device;
#[inline]
fn next(&mut self) -> Option<Device> {
None
}
}
impl Iterator for SupportedInputConfigs {
type Item = SupportedStreamConfigRange;
#[inline]
fn next(&mut self) -> Option<SupportedStreamConfigRange> {
None
}
}
impl Iterator for SupportedOutputConfigs {
type Item = SupportedStreamConfigRange;
#[inline]
fn next(&mut self) -> Option<SupportedStreamConfigRange> {
None
}
}
@@ -0,0 +1,62 @@
use std::sync::Arc;
extern crate jni;
use self::jni::Executor;
use self::jni::{errors::Result as JResult, JNIEnv, JavaVM};
// constants from android.media.AudioFormat
pub const ENCODING_PCM_16BIT: i32 = 2;
pub const ENCODING_PCM_FLOAT: i32 = 4;
pub const CHANNEL_OUT_MONO: i32 = 4;
pub const CHANNEL_OUT_STEREO: i32 = 12;
fn with_attached<F, R>(closure: F) -> JResult<R>
where
F: FnOnce(&mut JNIEnv) -> JResult<R>,
{
let android_context = ndk_context::android_context();
let vm = Arc::new(unsafe { JavaVM::from_raw(android_context.vm().cast())? });
Executor::new(vm).with_attached(|env| closure(env))
}
fn get_min_buffer_size(
class: &'static str,
sample_rate: i32,
channel_mask: i32,
format: i32,
) -> i32 {
// Unwrapping everything because these operations are not expected to fail
// or throw exceptions. Android returns negative values for invalid parameters,
// which is what we expect.
with_attached(|env| {
let class = env.find_class(class).unwrap();
env.call_static_method(
class,
"getMinBufferSize",
"(III)I",
&[sample_rate.into(), channel_mask.into(), format.into()],
)
.unwrap()
.i()
})
.unwrap()
}
pub fn get_audio_track_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 {
get_min_buffer_size(
"android/media/AudioTrack",
sample_rate,
channel_mask,
format,
)
}
pub fn get_audio_record_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 {
get_min_buffer_size(
"android/media/AudioRecord",
sample_rate,
channel_mask,
format,
)
}
@@ -0,0 +1,82 @@
use std::convert::TryInto;
use std::time::Duration;
extern crate oboe;
use crate::{
BackendSpecificError, BuildStreamError, PauseStreamError, PlayStreamError, StreamError,
StreamInstant,
};
pub fn to_stream_instant(duration: Duration) -> StreamInstant {
StreamInstant::new(
duration.as_secs().try_into().unwrap(),
duration.subsec_nanos(),
)
}
pub fn stream_instant<T: oboe::AudioStreamSafe + ?Sized>(stream: &mut T) -> StreamInstant {
const CLOCK_MONOTONIC: i32 = 1;
let ts = stream
.get_timestamp(CLOCK_MONOTONIC)
.unwrap_or(oboe::FrameTimestamp {
position: 0,
timestamp: 0,
});
to_stream_instant(Duration::from_nanos(ts.timestamp as u64))
}
impl From<oboe::Error> for StreamError {
fn from(error: oboe::Error) -> Self {
use self::oboe::Error::*;
match error {
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
e => (BackendSpecificError {
description: e.to_string(),
})
.into(),
}
}
}
impl From<oboe::Error> for PlayStreamError {
fn from(error: oboe::Error) -> Self {
use self::oboe::Error::*;
match error {
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
e => (BackendSpecificError {
description: e.to_string(),
})
.into(),
}
}
}
impl From<oboe::Error> for PauseStreamError {
fn from(error: oboe::Error) -> Self {
use self::oboe::Error::*;
match error {
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
e => (BackendSpecificError {
description: e.to_string(),
})
.into(),
}
}
}
impl From<oboe::Error> for BuildStreamError {
fn from(error: oboe::Error) -> Self {
use self::oboe::Error::*;
match error {
Disconnected | Unavailable | Closed => Self::DeviceNotAvailable,
NoFreeHandles => Self::StreamIdOverflow,
InvalidFormat | InvalidRate => Self::StreamConfigNotSupported,
IllegalArgument => Self::InvalidArgument,
e => (BackendSpecificError {
description: e.to_string(),
})
.into(),
}
}
}
@@ -0,0 +1,90 @@
use std::marker::PhantomData;
use std::time::Instant;
extern crate oboe;
use super::convert::{stream_instant, to_stream_instant};
use crate::{Data, InputCallbackInfo, InputStreamTimestamp, SizedSample, StreamError};
pub struct CpalInputCallback<I, C> {
data_cb: Box<dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static>,
error_cb: Box<dyn FnMut(StreamError) + Send + 'static>,
created: Instant,
phantom_channel: PhantomData<C>,
phantom_input: PhantomData<I>,
}
impl<I, C> CpalInputCallback<I, C> {
pub fn new<D, E>(data_cb: D, error_cb: E) -> Self
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Self {
data_cb: Box::new(data_cb),
error_cb: Box::new(error_cb),
created: Instant::now(),
phantom_channel: PhantomData,
phantom_input: PhantomData,
}
}
fn make_callback_info(
&self,
audio_stream: &mut dyn oboe::AudioInputStreamSafe,
) -> InputCallbackInfo {
InputCallbackInfo {
timestamp: InputStreamTimestamp {
callback: to_stream_instant(self.created.elapsed()),
capture: stream_instant(audio_stream),
},
}
}
}
impl<T: SizedSample, C: oboe::IsChannelCount> oboe::AudioInputCallback for CpalInputCallback<T, C>
where
(T, C): oboe::IsFrameType,
{
type FrameType = (T, C);
fn on_error_before_close(
&mut self,
_audio_stream: &mut dyn oboe::AudioInputStreamSafe,
error: oboe::Error,
) {
(self.error_cb)(StreamError::from(error))
}
fn on_error_after_close(
&mut self,
_audio_stream: &mut dyn oboe::AudioInputStreamSafe,
error: oboe::Error,
) {
(self.error_cb)(StreamError::from(error))
}
fn on_audio_ready(
&mut self,
audio_stream: &mut dyn oboe::AudioInputStreamSafe,
audio_data: &[<<Self as oboe::AudioInputCallback>::FrameType as oboe::IsFrameType>::Type],
) -> oboe::DataCallbackResult {
let cb_info = self.make_callback_info(audio_stream);
let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono {
1
} else {
2
};
(self.data_cb)(
&unsafe {
Data::from_parts(
audio_data.as_ptr() as *mut _,
audio_data.len() * channel_count,
T::FORMAT,
)
},
&cb_info,
);
oboe::DataCallbackResult::Continue
}
}
@@ -0,0 +1,499 @@
use std::cell::RefCell;
use std::cmp;
use std::convert::TryInto;
use std::time::Duration;
use std::vec::IntoIter as VecIntoIter;
extern crate oboe;
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
PlayStreamError, SampleFormat, SampleRate, SizedSample, StreamConfig, StreamError,
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
SupportedStreamConfigsError,
};
mod android_media;
mod convert;
mod input_callback;
mod output_callback;
use self::android_media::{get_audio_record_min_buffer_size, get_audio_track_min_buffer_size};
use self::input_callback::CpalInputCallback;
use self::oboe::{AudioInputStream, AudioOutputStream};
use self::output_callback::CpalOutputCallback;
// Android Java API supports up to 8 channels, but oboe API
// only exposes mono and stereo.
const CHANNEL_MASKS: [i32; 2] = [
android_media::CHANNEL_OUT_MONO,
android_media::CHANNEL_OUT_STEREO,
];
const SAMPLE_RATES: [i32; 13] = [
5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176_400, 192_000,
];
pub struct Host;
#[derive(Clone)]
pub struct Device(Option<oboe::AudioDeviceInfo>);
pub enum Stream {
Input(Box<RefCell<dyn AudioInputStream>>),
Output(Box<RefCell<dyn AudioOutputStream>>),
}
pub type SupportedInputConfigs = VecIntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = VecIntoIter<SupportedStreamConfigRange>;
pub type Devices = VecIntoIter<Device>;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
if let Ok(devices) = oboe::AudioDeviceInfo::request(oboe::AudioDeviceDirection::InputOutput)
{
Ok(devices
.into_iter()
.map(|d| Device(Some(d)))
.collect::<Vec<_>>()
.into_iter())
} else {
Ok(vec![Device(None)].into_iter())
}
}
fn default_input_device(&self) -> Option<Self::Device> {
Some(Device(None))
}
fn default_output_device(&self) -> Option<Self::Device> {
Some(Device(None))
}
}
fn buffer_size_range_for_params(
is_output: bool,
sample_rate: i32,
channel_mask: i32,
android_format: i32,
) -> SupportedBufferSize {
let min_buffer_size = if is_output {
get_audio_track_min_buffer_size(sample_rate, channel_mask, android_format)
} else {
get_audio_record_min_buffer_size(sample_rate, channel_mask, android_format)
};
if min_buffer_size > 0 {
SupportedBufferSize::Range {
min: min_buffer_size as u32,
max: i32::MAX as u32,
}
} else {
SupportedBufferSize::Unknown
}
}
fn default_supported_configs(is_output: bool) -> VecIntoIter<SupportedStreamConfigRange> {
// Have to "brute force" the parameter combinations with getMinBufferSize
const FORMATS: [SampleFormat; 2] = [SampleFormat::I16, SampleFormat::F32];
let mut output = Vec::with_capacity(SAMPLE_RATES.len() * CHANNEL_MASKS.len() * FORMATS.len());
for sample_format in &FORMATS {
let android_format = if *sample_format == SampleFormat::I16 {
android_media::ENCODING_PCM_16BIT
} else {
android_media::ENCODING_PCM_FLOAT
};
for (mask_idx, channel_mask) in CHANNEL_MASKS.iter().enumerate() {
let channel_count = mask_idx + 1;
for sample_rate in &SAMPLE_RATES {
if let SupportedBufferSize::Range { min, max } = buffer_size_range_for_params(
is_output,
*sample_rate,
*channel_mask,
android_format,
) {
output.push(SupportedStreamConfigRange {
channels: channel_count as u16,
min_sample_rate: SampleRate(*sample_rate as u32),
max_sample_rate: SampleRate(*sample_rate as u32),
buffer_size: SupportedBufferSize::Range { min, max },
sample_format: *sample_format,
});
}
}
}
}
output.into_iter()
}
fn device_supported_configs(
device: &oboe::AudioDeviceInfo,
is_output: bool,
) -> VecIntoIter<SupportedStreamConfigRange> {
let sample_rates = if !device.sample_rates.is_empty() {
device.sample_rates.as_slice()
} else {
&SAMPLE_RATES
};
const ALL_CHANNELS: [i32; 2] = [1, 2];
let channel_counts = if !device.channel_counts.is_empty() {
device.channel_counts.as_slice()
} else {
&ALL_CHANNELS
};
const ALL_FORMATS: [oboe::AudioFormat; 2] = [oboe::AudioFormat::I16, oboe::AudioFormat::F32];
let formats = if !device.formats.is_empty() {
device.formats.as_slice()
} else {
&ALL_FORMATS
};
let mut output = Vec::with_capacity(sample_rates.len() * channel_counts.len() * formats.len());
for sample_rate in sample_rates {
for channel_count in channel_counts {
assert!(*channel_count > 0);
if *channel_count > 2 {
// could be supported by the device, but oboe does not support more than 2 channels
continue;
}
let channel_mask = CHANNEL_MASKS[*channel_count as usize - 1];
for format in formats {
let (android_format, sample_format) = match format {
oboe::AudioFormat::I16 => {
(android_media::ENCODING_PCM_16BIT, SampleFormat::I16)
}
oboe::AudioFormat::F32 => {
(android_media::ENCODING_PCM_FLOAT, SampleFormat::F32)
}
_ => panic!("Unexpected format"),
};
let buffer_size = buffer_size_range_for_params(
is_output,
*sample_rate,
channel_mask,
android_format,
);
output.push(SupportedStreamConfigRange {
channels: cmp::min(*channel_count as u16, 2u16),
min_sample_rate: SampleRate(*sample_rate as u32),
max_sample_rate: SampleRate(*sample_rate as u32),
buffer_size,
sample_format,
});
}
}
}
output.into_iter()
}
fn configure_for_device<D, C, I>(
builder: oboe::AudioStreamBuilder<D, C, I>,
device: &Device,
config: &StreamConfig,
) -> oboe::AudioStreamBuilder<D, C, I> {
let mut builder = if let Some(info) = &device.0 {
builder.set_device_id(info.id)
} else {
builder
};
builder = builder.set_sample_rate(config.sample_rate.0.try_into().unwrap());
match &config.buffer_size {
BufferSize::Default => builder,
BufferSize::Fixed(size) => builder.set_buffer_capacity_in_frames(*size as i32),
}
}
fn build_input_stream<D, E, C, T>(
device: &Device,
config: &StreamConfig,
data_callback: D,
error_callback: E,
builder: oboe::AudioStreamBuilder<oboe::Input, C, T>,
) -> Result<Stream, BuildStreamError>
where
T: SizedSample + oboe::IsFormat + Send + 'static,
C: oboe::IsChannelCount + Send + 'static,
(T, C): oboe::IsFrameType,
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let builder = configure_for_device(builder, device, config);
let stream = builder
.set_callback(CpalInputCallback::<T, C>::new(
data_callback,
error_callback,
))
.open_stream()?;
Ok(Stream::Input(Box::new(RefCell::new(stream))))
}
fn build_output_stream<D, E, C, T>(
device: &Device,
config: &StreamConfig,
data_callback: D,
error_callback: E,
builder: oboe::AudioStreamBuilder<oboe::Output, C, T>,
) -> Result<Stream, BuildStreamError>
where
T: SizedSample + oboe::IsFormat + Send + 'static,
C: oboe::IsChannelCount + Send + 'static,
(T, C): oboe::IsFrameType,
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let builder = configure_for_device(builder, device, config);
let stream = builder
.set_callback(CpalOutputCallback::<T, C>::new(
data_callback,
error_callback,
))
.open_stream()?;
Ok(Stream::Output(Box::new(RefCell::new(stream))))
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, DeviceNameError> {
match &self.0 {
None => Ok("default".to_owned()),
Some(info) => Ok(info.product_name.clone()),
}
}
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
if let Some(info) = &self.0 {
Ok(device_supported_configs(info, false))
} else {
Ok(default_supported_configs(false))
}
}
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
if let Some(info) = &self.0 {
Ok(device_supported_configs(info, true))
} else {
Ok(default_supported_configs(true))
}
}
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let mut configs: Vec<_> = self.supported_input_configs().unwrap().collect();
configs.sort_by(|a, b| b.cmp_default_heuristics(a));
let config = configs
.into_iter()
.next()
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?
.with_max_sample_rate();
Ok(config)
}
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
let mut configs: Vec<_> = self.supported_output_configs().unwrap().collect();
configs.sort_by(|a, b| b.cmp_default_heuristics(a));
let config = configs
.into_iter()
.next()
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?
.with_max_sample_rate();
Ok(config)
}
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
match sample_format {
SampleFormat::I16 => {
let builder = oboe::AudioStreamBuilder::default()
.set_input()
.set_format::<i16>();
if config.channels == 1 {
build_input_stream(
self,
config,
data_callback,
error_callback,
builder.set_mono(),
)
} else if config.channels == 2 {
build_input_stream(
self,
config,
data_callback,
error_callback,
builder.set_stereo(),
)
} else {
Err(BackendSpecificError {
description: "More than 2 channels are not supported by Oboe.".to_owned(),
}
.into())
}
}
SampleFormat::F32 => {
let builder = oboe::AudioStreamBuilder::default()
.set_input()
.set_format::<f32>();
if config.channels == 1 {
build_input_stream(
self,
config,
data_callback,
error_callback,
builder.set_mono(),
)
} else if config.channels == 2 {
build_input_stream(
self,
config,
data_callback,
error_callback,
builder.set_stereo(),
)
} else {
Err(BackendSpecificError {
description: "More than 2 channels are not supported by Oboe.".to_owned(),
}
.into())
}
}
sample_format => Err(BackendSpecificError {
description: format!("{} format is not supported on Android.", sample_format),
}
.into()),
}
}
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
match sample_format {
SampleFormat::I16 => {
let builder = oboe::AudioStreamBuilder::default()
.set_output()
.set_format::<i16>();
if config.channels == 1 {
build_output_stream(
self,
config,
data_callback,
error_callback,
builder.set_mono(),
)
} else if config.channels == 2 {
build_output_stream(
self,
config,
data_callback,
error_callback,
builder.set_stereo(),
)
} else {
Err(BackendSpecificError {
description: "More than 2 channels are not supported by Oboe.".to_owned(),
}
.into())
}
}
SampleFormat::F32 => {
let builder = oboe::AudioStreamBuilder::default()
.set_output()
.set_format::<f32>();
if config.channels == 1 {
build_output_stream(
self,
config,
data_callback,
error_callback,
builder.set_mono(),
)
} else if config.channels == 2 {
build_output_stream(
self,
config,
data_callback,
error_callback,
builder.set_stereo(),
)
} else {
Err(BackendSpecificError {
description: "More than 2 channels are not supported by Oboe.".to_owned(),
}
.into())
}
}
sample_format => Err(BackendSpecificError {
description: format!("{} format is not supported on Android.", sample_format),
}
.into()),
}
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
match self {
Self::Input(stream) => stream
.borrow_mut()
.request_start()
.map_err(PlayStreamError::from),
Self::Output(stream) => stream
.borrow_mut()
.request_start()
.map_err(PlayStreamError::from),
}
}
fn pause(&self) -> Result<(), PauseStreamError> {
match self {
Self::Input(_) => Err(BackendSpecificError {
description: "Pause called on the input stream.".to_owned(),
}
.into()),
Self::Output(stream) => stream
.borrow_mut()
.request_pause()
.map_err(PauseStreamError::from),
}
}
}
@@ -0,0 +1,90 @@
use std::marker::PhantomData;
use std::time::Instant;
extern crate oboe;
use super::convert::{stream_instant, to_stream_instant};
use crate::{Data, OutputCallbackInfo, OutputStreamTimestamp, SizedSample, StreamError};
pub struct CpalOutputCallback<I, C> {
data_cb: Box<dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static>,
error_cb: Box<dyn FnMut(StreamError) + Send + 'static>,
created: Instant,
phantom_channel: PhantomData<C>,
phantom_input: PhantomData<I>,
}
impl<I, C> CpalOutputCallback<I, C> {
pub fn new<D, E>(data_cb: D, error_cb: E) -> Self
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
Self {
data_cb: Box::new(data_cb),
error_cb: Box::new(error_cb),
created: Instant::now(),
phantom_channel: PhantomData,
phantom_input: PhantomData,
}
}
fn make_callback_info(
&self,
audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
) -> OutputCallbackInfo {
OutputCallbackInfo {
timestamp: OutputStreamTimestamp {
callback: to_stream_instant(self.created.elapsed()),
playback: stream_instant(audio_stream),
},
}
}
}
impl<T: SizedSample, C: oboe::IsChannelCount> oboe::AudioOutputCallback for CpalOutputCallback<T, C>
where
(T, C): oboe::IsFrameType,
{
type FrameType = (T, C);
fn on_error_before_close(
&mut self,
_audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
error: oboe::Error,
) {
(self.error_cb)(StreamError::from(error))
}
fn on_error_after_close(
&mut self,
_audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
error: oboe::Error,
) {
(self.error_cb)(StreamError::from(error))
}
fn on_audio_ready(
&mut self,
audio_stream: &mut dyn oboe::AudioOutputStreamSafe,
audio_data: &mut [<<Self as oboe::AudioOutputCallback>::FrameType as oboe::IsFrameType>::Type],
) -> oboe::DataCallbackResult {
let cb_info = self.make_callback_info(audio_stream);
let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono {
1
} else {
2
};
(self.data_cb)(
&mut unsafe {
Data::from_parts(
audio_data.as_mut_ptr() as *mut _,
audio_data.len() * channel_count,
T::FORMAT,
)
},
&cb_info,
);
oboe::DataCallbackResult::Continue
}
}
@@ -0,0 +1,57 @@
//! Handles COM initialization and cleanup.
use super::IoError;
use std::marker::PhantomData;
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED};
thread_local!(static COM_INITIALIZED: ComInitialized = {
unsafe {
// Try to initialize COM with STA by default to avoid compatibility issues with the ASIO
// backend (where CoInitialize() is called by the ASIO SDK) or winit (where drag and drop
// requires STA).
// This call can fail with RPC_E_CHANGED_MODE if another library initialized COM with MTA.
// That's OK though since COM ensures thread-safety/compatibility through marshalling when
// necessary.
let result = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
if result.is_ok() || result == RPC_E_CHANGED_MODE {
ComInitialized {
result,
_ptr: PhantomData,
}
} else {
// COM initialization failed in another way, something is really wrong.
panic!(
"Failed to initialize COM: {}",
IoError::from_raw_os_error(result.0)
);
}
}
});
/// RAII object that guards the fact that COM is initialized.
///
// We store a raw pointer because it's the only way at the moment to remove `Send`/`Sync` from the
// object.
struct ComInitialized {
result: windows::core::HRESULT,
_ptr: PhantomData<*mut ()>,
}
impl Drop for ComInitialized {
#[inline]
fn drop(&mut self) {
// Need to avoid calling CoUninitialize() if CoInitializeEx failed since it may have
// returned RPC_E_MODE_CHANGED - which is OK, see above.
if self.result.is_ok() {
unsafe { CoUninitialize() };
}
}
}
/// Ensures that COM is initialized in this thread.
#[inline]
pub fn com_initialized() {
COM_INITIALIZED.with(|_| {});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
pub use self::device::{
default_input_device, default_output_device, Device, Devices, SupportedInputConfigs,
SupportedOutputConfigs,
};
pub use self::stream::Stream;
use crate::traits::HostTrait;
use crate::BackendSpecificError;
use crate::DevicesError;
use std::io::Error as IoError;
use windows::Win32::Media::Audio;
mod com;
mod device;
mod stream;
/// The WASAPI host, the default windows host type.
///
/// Note: If you use a WASAPI output device as an input device it will
/// transparently enable loopback mode (see
/// https://docs.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording).
#[derive(Debug)]
pub struct Host;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
// Assume WASAPI is always available on Windows.
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Self::Device> {
default_input_device()
}
fn default_output_device(&self) -> Option<Self::Device> {
default_output_device()
}
}
impl From<windows::core::Error> for BackendSpecificError {
fn from(error: windows::core::Error) -> Self {
BackendSpecificError {
description: format!("{}", IoError::from(error)),
}
}
}
trait ErrDeviceNotAvailable: From<BackendSpecificError> {
fn device_not_available() -> Self;
}
impl ErrDeviceNotAvailable for crate::BuildStreamError {
fn device_not_available() -> Self {
Self::DeviceNotAvailable
}
}
impl ErrDeviceNotAvailable for crate::SupportedStreamConfigsError {
fn device_not_available() -> Self {
Self::DeviceNotAvailable
}
}
impl ErrDeviceNotAvailable for crate::DefaultStreamConfigError {
fn device_not_available() -> Self {
Self::DeviceNotAvailable
}
}
impl ErrDeviceNotAvailable for crate::StreamError {
fn device_not_available() -> Self {
Self::DeviceNotAvailable
}
}
fn windows_err_to_cpal_err<E: ErrDeviceNotAvailable>(e: windows::core::Error) -> E {
windows_err_to_cpal_err_message::<E>(e, "")
}
fn windows_err_to_cpal_err_message<E: ErrDeviceNotAvailable>(
e: windows::core::Error,
message: &str,
) -> E {
match e.code() {
Audio::AUDCLNT_E_DEVICE_INVALIDATED => E::device_not_available(),
_ => {
let description = format!("{}{}", message, e);
let err = BackendSpecificError { description };
err.into()
}
}
}
@@ -0,0 +1,559 @@
use super::windows_err_to_cpal_err;
use crate::traits::StreamTrait;
use crate::{
BackendSpecificError, Data, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
PlayStreamError, SampleFormat, StreamError,
};
use std::mem;
use std::ptr;
use std::sync::mpsc::{channel, Receiver, SendError, Sender};
use std::thread::{self, JoinHandle};
use windows::Win32::Foundation;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Foundation::WAIT_OBJECT_0;
use windows::Win32::Media::Audio;
use windows::Win32::System::SystemServices;
use windows::Win32::System::Threading;
pub struct Stream {
/// The high-priority audio processing thread calling callbacks.
/// Option used for moving out in destructor.
///
/// TODO: Actually set the thread priority.
thread: Option<JoinHandle<()>>,
// Commands processed by the `run()` method that is currently running.
// `pending_scheduled_event` must be signalled whenever a command is added here, so that it
// will get picked up.
commands: Sender<Command>,
// This event is signalled after a new entry is added to `commands`, so that the `run()`
// method can be notified.
pending_scheduled_event: Foundation::HANDLE,
}
struct RunContext {
// Streams that have been created in this event loop.
stream: StreamInner,
// Handles corresponding to the `event` field of each element of `voices`. Must always be in
// sync with `voices`, except that the first element is always `pending_scheduled_event`.
handles: Vec<Foundation::HANDLE>,
commands: Receiver<Command>,
}
// Once we start running the eventloop, the RunContext will not be moved.
unsafe impl Send for RunContext {}
pub enum Command {
PlayStream,
PauseStream,
Terminate,
}
pub enum AudioClientFlow {
Render {
render_client: Audio::IAudioRenderClient,
},
Capture {
capture_client: Audio::IAudioCaptureClient,
},
}
pub struct StreamInner {
pub audio_client: Audio::IAudioClient,
pub audio_clock: Audio::IAudioClock,
pub client_flow: AudioClientFlow,
// Event that is signalled by WASAPI whenever audio data must be written.
pub event: Foundation::HANDLE,
// True if the stream is currently playing. False if paused.
pub playing: bool,
// Number of frames of audio data in the underlying buffer allocated by WASAPI.
pub max_frames_in_buffer: u32,
// Number of bytes that each frame occupies.
pub bytes_per_frame: u16,
// The configuration with which the stream was created.
pub config: crate::StreamConfig,
// The sample format with which the stream was created.
pub sample_format: SampleFormat,
}
impl Stream {
pub(crate) fn new_input<D, E>(
stream_inner: StreamInner,
mut data_callback: D,
mut error_callback: E,
) -> Stream
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let pending_scheduled_event = unsafe {
Threading::CreateEventA(None, false, false, windows::core::PCSTR(ptr::null()))
}
.expect("cpal: could not create input stream event");
let (tx, rx) = channel();
let run_context = RunContext {
handles: vec![pending_scheduled_event, stream_inner.event],
stream: stream_inner,
commands: rx,
};
let thread = thread::Builder::new()
.name("cpal_wasapi_in".to_owned())
.spawn(move || run_input(run_context, &mut data_callback, &mut error_callback))
.unwrap();
Stream {
thread: Some(thread),
commands: tx,
pending_scheduled_event,
}
}
pub(crate) fn new_output<D, E>(
stream_inner: StreamInner,
mut data_callback: D,
mut error_callback: E,
) -> Stream
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
let pending_scheduled_event = unsafe {
Threading::CreateEventA(None, false, false, windows::core::PCSTR(ptr::null()))
}
.expect("cpal: could not create output stream event");
let (tx, rx) = channel();
let run_context = RunContext {
handles: vec![pending_scheduled_event, stream_inner.event],
stream: stream_inner,
commands: rx,
};
let thread = thread::Builder::new()
.name("cpal_wasapi_out".to_owned())
.spawn(move || run_output(run_context, &mut data_callback, &mut error_callback))
.unwrap();
Stream {
thread: Some(thread),
commands: tx,
pending_scheduled_event,
}
}
#[inline]
fn push_command(&self, command: Command) -> Result<(), SendError<Command>> {
self.commands.send(command)?;
unsafe {
Threading::SetEvent(self.pending_scheduled_event).unwrap();
}
Ok(())
}
}
impl Drop for Stream {
#[inline]
fn drop(&mut self) {
if self.push_command(Command::Terminate).is_ok() {
self.thread.take().unwrap().join().unwrap();
unsafe {
let _ = Foundation::CloseHandle(self.pending_scheduled_event);
}
}
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
self.push_command(Command::PlayStream)
.map_err(|_| crate::error::PlayStreamError::DeviceNotAvailable)?;
Ok(())
}
fn pause(&self) -> Result<(), PauseStreamError> {
self.push_command(Command::PauseStream)
.map_err(|_| crate::error::PauseStreamError::DeviceNotAvailable)?;
Ok(())
}
}
impl Drop for StreamInner {
#[inline]
fn drop(&mut self) {
unsafe {
let _ = Foundation::CloseHandle(self.event);
}
}
}
// Process any pending commands that are queued within the `RunContext`.
// Returns `true` if the loop should continue running, `false` if it should terminate.
fn process_commands(run_context: &mut RunContext) -> Result<bool, StreamError> {
// Process the pending commands.
for command in run_context.commands.try_iter() {
match command {
Command::PlayStream => unsafe {
if !run_context.stream.playing {
run_context
.stream
.audio_client
.Start()
.map_err(windows_err_to_cpal_err::<StreamError>)?;
run_context.stream.playing = true;
}
},
Command::PauseStream => unsafe {
if run_context.stream.playing {
run_context
.stream
.audio_client
.Stop()
.map_err(windows_err_to_cpal_err::<StreamError>)?;
run_context.stream.playing = false;
}
},
Command::Terminate => {
return Ok(false);
}
}
}
Ok(true)
}
// Wait for any of the given handles to be signalled.
//
// Returns the index of the `handle` that was signalled, or an `Err` if
// `WaitForMultipleObjectsEx` fails.
//
// This is called when the `run` thread is ready to wait for the next event. The
// next event might be some command submitted by the user (the first handle) or
// might indicate that one of the streams is ready to deliver or receive audio.
fn wait_for_handle_signal(handles: &[Foundation::HANDLE]) -> Result<usize, BackendSpecificError> {
debug_assert!(handles.len() <= SystemServices::MAXIMUM_WAIT_OBJECTS as usize);
let result = unsafe {
Threading::WaitForMultipleObjectsEx(
handles,
false, // Don't wait for all, just wait for the first
Threading::INFINITE, // TODO: allow setting a timeout
false, // irrelevant parameter here
)
};
if result == Foundation::WAIT_FAILED {
let err = unsafe { Foundation::GetLastError() };
let description = format!("`WaitForMultipleObjectsEx failed: {:?}", err);
let err = BackendSpecificError { description };
return Err(err);
}
// Notifying the corresponding task handler.
let handle_idx = (result.0 - WAIT_OBJECT_0.0) as usize;
Ok(handle_idx)
}
// Get the number of available frames that are available for writing/reading.
fn get_available_frames(stream: &StreamInner) -> Result<u32, StreamError> {
unsafe {
let padding = stream
.audio_client
.GetCurrentPadding()
.map_err(windows_err_to_cpal_err::<StreamError>)?;
Ok(stream.max_frames_in_buffer - padding)
}
}
fn run_input(
mut run_ctxt: RunContext,
data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo),
error_callback: &mut dyn FnMut(StreamError),
) {
boost_current_thread_priority();
loop {
match process_commands_and_await_signal(&mut run_ctxt, error_callback) {
Some(ControlFlow::Break) => break,
Some(ControlFlow::Continue) => continue,
None => (),
}
let capture_client = match run_ctxt.stream.client_flow {
AudioClientFlow::Capture { ref capture_client } => capture_client.clone(),
_ => unreachable!(),
};
match process_input(
&run_ctxt.stream,
capture_client,
data_callback,
error_callback,
) {
ControlFlow::Break => break,
ControlFlow::Continue => continue,
}
}
}
fn run_output(
mut run_ctxt: RunContext,
data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo),
error_callback: &mut dyn FnMut(StreamError),
) {
boost_current_thread_priority();
loop {
match process_commands_and_await_signal(&mut run_ctxt, error_callback) {
Some(ControlFlow::Break) => break,
Some(ControlFlow::Continue) => continue,
None => (),
}
let render_client = match run_ctxt.stream.client_flow {
AudioClientFlow::Render { ref render_client } => render_client.clone(),
_ => unreachable!(),
};
match process_output(
&run_ctxt.stream,
render_client,
data_callback,
error_callback,
) {
ControlFlow::Break => break,
ControlFlow::Continue => continue,
}
}
}
fn boost_current_thread_priority() {
unsafe {
let thread_id = Threading::GetCurrentThreadId();
let _ = Threading::SetThreadPriority(
HANDLE(thread_id as isize),
Threading::THREAD_PRIORITY_TIME_CRITICAL,
);
}
}
enum ControlFlow {
Break,
Continue,
}
fn process_commands_and_await_signal(
run_context: &mut RunContext,
error_callback: &mut dyn FnMut(StreamError),
) -> Option<ControlFlow> {
// Process queued commands.
match process_commands(run_context) {
Ok(true) => (),
Ok(false) => return Some(ControlFlow::Break),
Err(err) => {
error_callback(err);
return Some(ControlFlow::Break);
}
};
// Wait for any of the handles to be signalled.
let handle_idx = match wait_for_handle_signal(&run_context.handles) {
Ok(idx) => idx,
Err(err) => {
error_callback(err.into());
return Some(ControlFlow::Break);
}
};
// If `handle_idx` is 0, then it's `pending_scheduled_event` that was signalled in
// order for us to pick up the pending commands. Otherwise, a stream needs data.
if handle_idx == 0 {
return Some(ControlFlow::Continue);
}
None
}
// The loop for processing pending input data.
fn process_input(
stream: &StreamInner,
capture_client: Audio::IAudioCaptureClient,
data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo),
error_callback: &mut dyn FnMut(StreamError),
) -> ControlFlow {
unsafe {
// Get the available data in the shared buffer.
let mut buffer: *mut u8 = ptr::null_mut();
let mut flags = mem::MaybeUninit::uninit();
loop {
let mut frames_available = match capture_client.GetNextPacketSize() {
Ok(0) => return ControlFlow::Continue,
Ok(f) => f,
Err(err) => {
error_callback(windows_err_to_cpal_err(err));
return ControlFlow::Break;
}
};
let mut qpc_position: u64 = 0;
let result = capture_client.GetBuffer(
&mut buffer,
&mut frames_available,
flags.as_mut_ptr(),
None,
Some(&mut qpc_position),
);
match result {
// TODO: Can this happen?
Err(e) if e.code() == Audio::AUDCLNT_S_BUFFER_EMPTY => continue,
Err(e) => {
error_callback(windows_err_to_cpal_err(e));
return ControlFlow::Break;
}
Ok(_) => (),
}
debug_assert!(!buffer.is_null());
let data = buffer as *mut ();
let len = frames_available as usize * stream.bytes_per_frame as usize
/ stream.sample_format.sample_size();
let data = Data::from_parts(data, len, stream.sample_format);
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
let timestamp = match input_timestamp(stream, qpc_position) {
Ok(ts) => ts,
Err(err) => {
error_callback(err);
return ControlFlow::Break;
}
};
let info = InputCallbackInfo { timestamp };
data_callback(&data, &info);
// Release the buffer.
let result = capture_client
.ReleaseBuffer(frames_available)
.map_err(windows_err_to_cpal_err);
if let Err(err) = result {
error_callback(err);
return ControlFlow::Break;
}
}
}
}
// The loop for writing output data.
fn process_output(
stream: &StreamInner,
render_client: Audio::IAudioRenderClient,
data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo),
error_callback: &mut dyn FnMut(StreamError),
) -> ControlFlow {
// The number of frames available for writing.
let frames_available = match get_available_frames(stream) {
Ok(0) => return ControlFlow::Continue, // TODO: Can this happen?
Ok(n) => n,
Err(err) => {
error_callback(err);
return ControlFlow::Break;
}
};
unsafe {
let buffer = match render_client.GetBuffer(frames_available) {
Ok(b) => b,
Err(e) => {
error_callback(windows_err_to_cpal_err(e));
return ControlFlow::Break;
}
};
debug_assert!(!buffer.is_null());
let data = buffer as *mut ();
let len = frames_available as usize * stream.bytes_per_frame as usize
/ stream.sample_format.sample_size();
let mut data = Data::from_parts(data, len, stream.sample_format);
let sample_rate = stream.config.sample_rate;
let timestamp = match output_timestamp(stream, frames_available, sample_rate) {
Ok(ts) => ts,
Err(err) => {
error_callback(err);
return ControlFlow::Break;
}
};
let info = OutputCallbackInfo { timestamp };
data_callback(&mut data, &info);
if let Err(err) = render_client.ReleaseBuffer(frames_available, 0) {
error_callback(windows_err_to_cpal_err(err));
return ControlFlow::Break;
}
}
ControlFlow::Continue
}
/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`.
fn frames_to_duration(frames: u32, rate: crate::SampleRate) -> std::time::Duration {
let secsf = frames as f64 / rate.0 as f64;
let secs = secsf as u64;
let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32;
std::time::Duration::new(secs, nanos)
}
/// Use the stream's `IAudioClock` to produce the current stream instant.
///
/// Uses the QPC position produced via the `GetPosition` method.
fn stream_instant(stream: &StreamInner) -> Result<crate::StreamInstant, StreamError> {
let mut position: u64 = 0;
let mut qpc_position: u64 = 0;
unsafe {
stream
.audio_clock
.GetPosition(&mut position, Some(&mut qpc_position))
.map_err(windows_err_to_cpal_err::<StreamError>)?;
};
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
let qpc_nanos = qpc_position as i128 * 100;
let instant = crate::StreamInstant::from_nanos_i128(qpc_nanos)
.expect("performance counter out of range of `StreamInstant` representation");
Ok(instant)
}
/// Produce the input stream timestamp.
///
/// `buffer_qpc_position` is the `qpc_position` returned via the `GetBuffer` call on the capture
/// client. It represents the instant at which the first sample of the retrieved buffer was
/// captured.
fn input_timestamp(
stream: &StreamInner,
buffer_qpc_position: u64,
) -> Result<crate::InputStreamTimestamp, StreamError> {
// The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds.
let qpc_nanos = buffer_qpc_position as i128 * 100;
let capture = crate::StreamInstant::from_nanos_i128(qpc_nanos)
.expect("performance counter out of range of `StreamInstant` representation");
let callback = stream_instant(stream)?;
Ok(crate::InputStreamTimestamp { capture, callback })
}
/// Produce the output stream timestamp.
///
/// `frames_available` is the number of frames available for writing as reported by subtracting the
/// result of `GetCurrentPadding` from the maximum buffer size.
///
/// `sample_rate` is the rate at which audio frames are processed by the device.
///
/// TODO: The returned `playback` is an estimate that assumes audio is delivered immediately after
/// `frames_available` are consumed. The reality is that there is likely a tiny amount of latency
/// after this, but not sure how to determine this.
fn output_timestamp(
stream: &StreamInner,
frames_available: u32,
sample_rate: crate::SampleRate,
) -> Result<crate::OutputStreamTimestamp, StreamError> {
let callback = stream_instant(stream)?;
let buffer_duration = frames_to_duration(frames_available, sample_rate);
let playback = callback
.add(buffer_duration)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
Ok(crate::OutputStreamTimestamp { callback, playback })
}
@@ -0,0 +1,528 @@
extern crate js_sys;
extern crate wasm_bindgen;
extern crate web_sys;
use self::js_sys::eval;
use self::wasm_bindgen::prelude::*;
use self::wasm_bindgen::JsCast;
use self::web_sys::{AudioContext, AudioContextOptions};
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
use crate::{
BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError,
DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError,
PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize,
SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError,
};
use std::ops::DerefMut;
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
/// Content is false if the iterator is empty.
pub struct Devices(bool);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device;
pub struct Host;
pub struct Stream {
ctx: Arc<AudioContext>,
on_ended_closures: Vec<Arc<RwLock<Option<Closure<dyn FnMut()>>>>>,
config: StreamConfig,
buffer_size_frames: usize,
}
pub type SupportedInputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
pub type SupportedOutputConfigs = ::std::vec::IntoIter<SupportedStreamConfigRange>;
const MIN_CHANNELS: u16 = 1;
const MAX_CHANNELS: u16 = 32;
const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000);
const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000);
const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100);
const MIN_BUFFER_SIZE: u32 = 1;
const MAX_BUFFER_SIZE: u32 = u32::MAX;
const DEFAULT_BUFFER_SIZE: usize = 2048;
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
impl Host {
pub fn new() -> Result<Self, crate::HostUnavailable> {
Ok(Host)
}
}
impl HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
// Assume this host is always available on webaudio.
true
}
fn devices(&self) -> Result<Self::Devices, DevicesError> {
Devices::new()
}
fn default_input_device(&self) -> Option<Self::Device> {
default_input_device()
}
fn default_output_device(&self) -> Option<Self::Device> {
default_output_device()
}
}
impl Devices {
fn new() -> Result<Self, DevicesError> {
Ok(Self::default())
}
}
impl Device {
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Ok("Default Device".to_owned())
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
// TODO
Ok(Vec::new().into_iter())
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
let buffer_size = SupportedBufferSize::Range {
min: MIN_BUFFER_SIZE,
max: MAX_BUFFER_SIZE,
};
let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS)
.map(|channels| SupportedStreamConfigRange {
channels,
min_sample_rate: MIN_SAMPLE_RATE,
max_sample_rate: MAX_SAMPLE_RATE,
buffer_size: buffer_size.clone(),
sample_format: SUPPORTED_SAMPLE_FORMAT,
})
.collect();
Ok(configs.into_iter())
}
#[inline]
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
// TODO
Err(DefaultStreamConfigError::StreamTypeNotSupported)
}
#[inline]
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
const EXPECT: &str = "expected at least one valid webaudio stream config";
let config = self
.supported_output_configs()
.expect(EXPECT)
.max_by(|a, b| a.cmp_default_heuristics(b))
.unwrap()
.with_sample_rate(DEFAULT_SAMPLE_RATE);
Ok(config)
}
}
impl DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
#[inline]
fn name(&self) -> Result<String, DeviceNameError> {
Device::name(self)
}
#[inline]
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError> {
Device::supported_input_configs(self)
}
#[inline]
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError> {
Device::supported_output_configs(self)
}
#[inline]
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_input_config(self)
}
#[inline]
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
Device::default_output_config(self)
}
fn build_input_stream_raw<D, E>(
&self,
_config: &StreamConfig,
_sample_format: SampleFormat,
_data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
// TODO
Err(BuildStreamError::StreamConfigNotSupported)
}
/// Create an output stream.
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
_error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
if !valid_config(config, sample_format) {
return Err(BuildStreamError::StreamConfigNotSupported);
}
let n_channels = config.channels as usize;
let buffer_size_frames = match config.buffer_size {
BufferSize::Fixed(v) => {
if v == 0 {
return Err(BuildStreamError::StreamConfigNotSupported);
} else {
v as usize
}
}
BufferSize::Default => DEFAULT_BUFFER_SIZE,
};
let buffer_size_samples = buffer_size_frames * n_channels;
let buffer_time_step_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate);
let data_callback = Arc::new(Mutex::new(Box::new(data_callback)));
// Create the WebAudio stream.
let mut stream_opts = AudioContextOptions::new();
stream_opts.sample_rate(config.sample_rate.0 as f32);
let ctx = AudioContext::new_with_context_options(&stream_opts).map_err(
|err| -> BuildStreamError {
let description = format!("{:?}", err);
let err = BackendSpecificError { description };
err.into()
},
)?;
let destination = ctx.destination();
// If possible, set the destination's channel_count to the given config.channel.
// If not, fallback on the default destination channel_count to keep previous behavior
// and do not return an error.
if config.channels as u32 <= destination.max_channel_count() {
destination.set_channel_count(config.channels as u32);
}
let ctx = Arc::new(ctx);
// A container for managing the lifecycle of the audio callbacks.
let mut on_ended_closures: Vec<Arc<RwLock<Option<Closure<dyn FnMut()>>>>> = Vec::new();
// A cursor keeping track of the current time at which new frames should be scheduled.
let time = Arc::new(RwLock::new(0f64));
// Create a set of closures / callbacks which will continuously fetch and schedule sample
// playback. Starting with two workers, e.g. a front and back buffer so that audio frames
// can be fetched in the background.
for _i in 0..2 {
let data_callback_handle = data_callback.clone();
let ctx_handle = ctx.clone();
let time_handle = time.clone();
// A set of temporary buffers to be used for intermediate sample transformation steps.
let mut temporary_buffer = vec![0f32; buffer_size_samples];
let mut temporary_channel_buffer = vec![0f32; buffer_size_frames];
#[cfg(target_feature = "atomics")]
let temporary_channel_array_view: js_sys::Float32Array;
#[cfg(target_feature = "atomics")]
{
let temporary_channel_array = js_sys::ArrayBuffer::new(
(std::mem::size_of::<f32>() * buffer_size_frames) as u32,
);
temporary_channel_array_view = js_sys::Float32Array::new(&temporary_channel_array);
}
// Create a webaudio buffer which will be reused to avoid allocations.
let ctx_buffer = ctx
.create_buffer(
config.channels as u32,
buffer_size_frames as u32,
config.sample_rate.0 as f32,
)
.map_err(|err| -> BuildStreamError {
let description = format!("{:?}", err);
let err = BackendSpecificError { description };
err.into()
})?;
// A self reference to this closure for passing to future audio event calls.
let on_ended_closure: Arc<RwLock<Option<Closure<dyn FnMut()>>>> =
Arc::new(RwLock::new(None));
let on_ended_closure_handle = on_ended_closure.clone();
on_ended_closure
.write()
.unwrap()
.replace(Closure::wrap(Box::new(move || {
let now = ctx_handle.current_time();
let time_at_start_of_buffer = {
let time_at_start_of_buffer = time_handle
.read()
.expect("Unable to get a read lock on the time cursor");
// Synchronise first buffer as necessary (eg. keep the time value
// referenced to the context clock).
if *time_at_start_of_buffer > 0.001 {
*time_at_start_of_buffer
} else {
// 25ms of time to fetch the first sample data, increase to avoid
// initial underruns.
now + 0.025
}
};
// Populate the sample data into an interleaved temporary buffer.
{
let len = temporary_buffer.len();
let data = temporary_buffer.as_mut_ptr() as *mut ();
let mut data = unsafe { Data::from_parts(data, len, sample_format) };
let mut data_callback = data_callback_handle.lock().unwrap();
let callback = crate::StreamInstant::from_secs_f64(now);
let playback = crate::StreamInstant::from_secs_f64(time_at_start_of_buffer);
let timestamp = crate::OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
(data_callback.deref_mut())(&mut data, &info);
}
// Deinterleave the sample data and copy into the audio context buffer.
// We do not reference the audio context buffer directly e.g. getChannelData.
// As wasm-bindgen only gives us a copy, not a direct reference.
for channel in 0..n_channels {
for i in 0..buffer_size_frames {
temporary_channel_buffer[i] =
temporary_buffer[n_channels * i + channel];
}
#[cfg(not(target_feature = "atomics"))]
{
ctx_buffer
.copy_to_channel(&mut temporary_channel_buffer, channel as i32)
.expect(
"Unable to write sample data into the audio context buffer",
);
}
// copyToChannel cannot be directly copied into from a SharedArrayBuffer,
// which WASM memory is backed by if the 'atomics' flag is enabled.
// This workaround copies the data into an intermediary buffer first.
// There's a chance browsers may eventually relax that requirement.
// See this issue: https://github.com/WebAudio/web-audio-api/issues/2565
#[cfg(target_feature = "atomics")]
{
temporary_channel_array_view.copy_from(&mut temporary_channel_buffer);
ctx_buffer
.unchecked_ref::<ExternalArrayAudioBuffer>()
.copy_to_channel(&temporary_channel_array_view, channel as i32)
.expect(
"Unable to write sample data into the audio context buffer",
);
}
}
// Create an AudioBufferSourceNode, schedule it to playback the reused buffer
// in the future.
let source = ctx_handle
.create_buffer_source()
.expect("Unable to create a webaudio buffer source");
source.set_buffer(Some(&ctx_buffer));
source
.connect_with_audio_node(&ctx_handle.destination())
.expect(
"Unable to connect the web audio buffer source to the context destination",
);
source.set_onended(Some(
on_ended_closure_handle
.read()
.unwrap()
.as_ref()
.unwrap()
.as_ref()
.unchecked_ref(),
));
source
.start_with_when(time_at_start_of_buffer)
.expect("Unable to start the webaudio buffer source");
// Keep track of when the next buffer worth of samples should be played.
*time_handle.write().unwrap() = time_at_start_of_buffer + buffer_time_step_secs;
}) as Box<dyn FnMut()>));
on_ended_closures.push(on_ended_closure);
}
Ok(Stream {
ctx,
on_ended_closures,
config: config.clone(),
buffer_size_frames,
})
}
}
impl Stream {
/// Return the [`AudioContext`](https://developer.mozilla.org/docs/Web/API/AudioContext) used
/// by this stream.
pub fn audio_context(&self) -> &AudioContext {
&*self.ctx
}
}
impl StreamTrait for Stream {
fn play(&self) -> Result<(), PlayStreamError> {
let window = web_sys::window().unwrap();
match self.ctx.resume() {
Ok(_) => {
// Begin webaudio playback, initially scheduling the closures to fire on a timeout
// event.
let mut offset_ms = 10;
let time_step_secs =
buffer_time_step_secs(self.buffer_size_frames, self.config.sample_rate);
let time_step_ms = (time_step_secs * 1_000.0) as i32;
for on_ended_closure in self.on_ended_closures.iter() {
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
on_ended_closure
.read()
.unwrap()
.as_ref()
.unwrap()
.as_ref()
.unchecked_ref(),
offset_ms,
)
.unwrap();
offset_ms += time_step_ms;
}
Ok(())
}
Err(err) => {
let description = format!("{:?}", err);
let err = BackendSpecificError { description };
Err(err.into())
}
}
}
fn pause(&self) -> Result<(), PauseStreamError> {
match self.ctx.suspend() {
Ok(_) => Ok(()),
Err(err) => {
let description = format!("{:?}", err);
let err = BackendSpecificError { description };
Err(err.into())
}
}
}
}
impl Drop for Stream {
fn drop(&mut self) {
let _ = self.ctx.close();
}
}
impl Default for Devices {
fn default() -> Devices {
// We produce an empty iterator if the WebAudio API isn't available.
Devices(is_webaudio_available())
}
}
impl Iterator for Devices {
type Item = Device;
#[inline]
fn next(&mut self) -> Option<Device> {
if self.0 {
self.0 = false;
Some(Device)
} else {
None
}
}
}
#[inline]
fn default_input_device() -> Option<Device> {
// TODO
None
}
#[inline]
fn default_output_device() -> Option<Device> {
if is_webaudio_available() {
Some(Device)
} else {
None
}
}
// Detects whether the `AudioContext` global variable is available.
fn is_webaudio_available() -> bool {
if let Ok(audio_context_is_defined) = eval("typeof AudioContext !== 'undefined'") {
audio_context_is_defined.as_bool().unwrap()
} else {
false
}
}
// Whether or not the given stream configuration is valid for building a stream.
fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool {
conf.channels <= MAX_CHANNELS
&& conf.channels >= MIN_CHANNELS
&& conf.sample_rate <= MAX_SAMPLE_RATE
&& conf.sample_rate >= MIN_SAMPLE_RATE
&& sample_format == SUPPORTED_SAMPLE_FORMAT
}
fn buffer_time_step_secs(buffer_size_frames: usize, sample_rate: SampleRate) -> f64 {
buffer_size_frames as f64 / sample_rate.0 as f64
}
#[cfg(target_feature = "atomics")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = AudioBuffer)]
type ExternalArrayAudioBuffer;
# [wasm_bindgen(catch, method, structural, js_class = "AudioBuffer", js_name = copyToChannel)]
pub fn copy_to_channel(
this: &ExternalArrayAudioBuffer,
source: &js_sys::Float32Array,
channel_number: i32,
) -> Result<(), JsValue>;
}
+871
View File
@@ -0,0 +1,871 @@
//! # How to use cpal
//!
//! Here are some concepts cpal exposes:
//!
//! - A [`Host`] provides access to the available audio devices on the system.
//! Some platforms have more than one host available, but every platform supported by CPAL has at
//! least one [default_host] that is guaranteed to be available.
//! - A [`Device`] is an audio device that may have any number of input and
//! output streams.
//! - A [`Stream`] is an open flow of audio data. Input streams allow you to
//! receive audio data, output streams allow you to play audio data. You must choose which
//! [Device] will run your stream before you can create one. Often, a default device can be
//! retrieved via the [Host].
//!
//! The first step is to initialise the [`Host`]:
//!
//! ```
//! use cpal::traits::HostTrait;
//! let host = cpal::default_host();
//! ```
//!
//! Then choose an available [`Device`]. The easiest way is to use the default input or output
//! `Device` via the [`default_input_device()`] or [`default_output_device()`] methods on `host`.
//!
//! Alternatively, you can enumerate all the available devices with the [`devices()`] method.
//! Beware that the `default_*_device()` functions return an `Option<Device>` in case no device
//! is available for that stream type on the system.
//!
//! ```no_run
//! # use cpal::traits::HostTrait;
//! # let host = cpal::default_host();
//! let device = host.default_output_device().expect("no output device available");
//! ```
//!
//! Before we can create a stream, we must decide what the configuration of the audio stream is
//! going to be.
//! You can query all the supported configurations with the
//! [`supported_input_configs()`] and [`supported_output_configs()`] methods.
//! These produce a list of [`SupportedStreamConfigRange`] structs which can later be turned into
//! actual [`SupportedStreamConfig`] structs.
//!
//! If you don't want to query the list of configs,
//! you can also build your own [`StreamConfig`] manually, but doing so could lead to an error when
//! building the stream if the config is not supported by the device.
//!
//! > **Note**: the `supported_input/output_configs()` methods
//! > could return an error for example if the device has been disconnected.
//!
//! ```no_run
//! use cpal::traits::{DeviceTrait, HostTrait};
//! # let host = cpal::default_host();
//! # let device = host.default_output_device().unwrap();
//! let mut supported_configs_range = device.supported_output_configs()
//! .expect("error while querying configs");
//! let supported_config = supported_configs_range.next()
//! .expect("no supported config?!")
//! .with_max_sample_rate();
//! ```
//!
//! Now that we have everything for the stream, we are ready to create it from our selected device:
//!
//! ```no_run
//! use cpal::Data;
//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
//! # let host = cpal::default_host();
//! # let device = host.default_output_device().unwrap();
//! # let config = device.default_output_config().unwrap().into();
//! let stream = device.build_output_stream(
//! &config,
//! move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
//! // react to stream events and read or write stream data here.
//! },
//! move |err| {
//! // react to errors here.
//! },
//! None // None=blocking, Some(Duration)=timeout
//! );
//! ```
//!
//! While the stream is running, the selected audio device will periodically call the data callback
//! that was passed to the function. The callback is passed an instance of either [`&Data` or
//! `&mut Data`](Data) depending on whether the stream is an input stream or output stream respectively.
//!
//! > **Note**: Creating and running a stream will *not* block the thread. On modern platforms, the
//! > given callback is called by a dedicated, high-priority thread responsible for delivering
//! > audio data to the system's audio device in a timely manner. On older platforms that only
//! > provide a blocking API (e.g. ALSA), CPAL will create a thread in order to consistently
//! > provide non-blocking behaviour (currently this is a thread per stream, but this may change to
//! > use a single thread for all streams). *If this is an issue for your platform or design,
//! > please share your issue and use-case with the CPAL team on the GitHub issue tracker for
//! > consideration.*
//!
//! In this example, we simply fill the given output buffer with silence.
//!
//! ```no_run
//! use cpal::{Data, Sample, SampleFormat, FromSample};
//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
//! # let host = cpal::default_host();
//! # let device = host.default_output_device().unwrap();
//! # let supported_config = device.default_output_config().unwrap();
//! let err_fn = |err| eprintln!("an error occurred on the output audio stream: {}", err);
//! let sample_format = supported_config.sample_format();
//! let config = supported_config.into();
//! let stream = match sample_format {
//! SampleFormat::F32 => device.build_output_stream(&config, write_silence::<f32>, err_fn, None),
//! SampleFormat::I16 => device.build_output_stream(&config, write_silence::<i16>, err_fn, None),
//! SampleFormat::U16 => device.build_output_stream(&config, write_silence::<u16>, err_fn, None),
//! sample_format => panic!("Unsupported sample format '{sample_format}'")
//! }.unwrap();
//!
//! fn write_silence<T: Sample>(data: &mut [T], _: &cpal::OutputCallbackInfo) {
//! for sample in data.iter_mut() {
//! *sample = Sample::EQUILIBRIUM;
//! }
//! }
//! ```
//!
//! Not all platforms automatically run the stream upon creation. To ensure the stream has started,
//! we can use [`Stream::play`](traits::StreamTrait::play).
//!
//! ```no_run
//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
//! # let host = cpal::default_host();
//! # let device = host.default_output_device().unwrap();
//! # let supported_config = device.default_output_config().unwrap();
//! # let sample_format = supported_config.sample_format();
//! # let config = supported_config.into();
//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {};
//! # let err_fn = move |_err| {};
//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap();
//! stream.play().unwrap();
//! ```
//!
//! Some devices support pausing the audio stream. This can be useful for saving energy in moments
//! of silence.
//!
//! ```no_run
//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
//! # let host = cpal::default_host();
//! # let device = host.default_output_device().unwrap();
//! # let supported_config = device.default_output_config().unwrap();
//! # let sample_format = supported_config.sample_format();
//! # let config = supported_config.into();
//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {};
//! # let err_fn = move |_err| {};
//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap();
//! stream.pause().unwrap();
//! ```
//!
//! [`default_input_device()`]: traits::HostTrait::default_input_device
//! [`default_output_device()`]: traits::HostTrait::default_output_device
//! [`devices()`]: traits::HostTrait::devices
//! [`supported_input_configs()`]: traits::DeviceTrait::supported_input_configs
//! [`supported_output_configs()`]: traits::DeviceTrait::supported_output_configs
#![recursion_limit = "2048"]
// Extern crate declarations with `#[macro_use]` must unfortunately be at crate root.
#[cfg(target_os = "emscripten")]
#[macro_use]
extern crate wasm_bindgen;
#[cfg(target_os = "emscripten")]
extern crate js_sys;
#[cfg(target_os = "emscripten")]
extern crate web_sys;
pub use error::*;
pub use platform::{
available_hosts, default_host, host_from_id, Device, Devices, Host, HostId, Stream,
SupportedInputConfigs, SupportedOutputConfigs, ALL_HOSTS,
};
pub use samples_formats::{FromSample, Sample, SampleFormat, SizedSample, I24, I48, U24, U48};
use std::convert::TryInto;
use std::ops::{Div, Mul};
use std::time::Duration;
#[cfg(target_os = "emscripten")]
use wasm_bindgen::prelude::*;
mod error;
mod host;
pub mod platform;
mod samples_formats;
pub mod traits;
/// A host's device iterator yielding only *input* devices.
pub type InputDevices<I> = std::iter::Filter<I, fn(&<I as Iterator>::Item) -> bool>;
/// A host's device iterator yielding only *output* devices.
pub type OutputDevices<I> = std::iter::Filter<I, fn(&<I as Iterator>::Item) -> bool>;
/// Number of channels.
pub type ChannelCount = u16;
/// The number of samples processed per second for a single channel of audio.
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SampleRate(pub u32);
impl<T> Mul<T> for SampleRate
where
u32: Mul<T, Output = u32>,
{
type Output = Self;
fn mul(self, rhs: T) -> Self {
SampleRate(self.0 * rhs)
}
}
impl<T> Div<T> for SampleRate
where
u32: Div<T, Output = u32>,
{
type Output = Self;
fn div(self, rhs: T) -> Self {
SampleRate(self.0 / rhs)
}
}
/// The desired number of frames for the hardware buffer.
pub type FrameCount = u32;
/// The buffer size used by the device.
///
/// [`Default`] is used when no specific buffer size is set and uses the default
/// behavior of the given host. Note, the default buffer size may be surprisingly
/// large, leading to latency issues. If low latency is desired, [`Fixed(FrameCount)`]
/// should be used in accordance with the [`SupportedBufferSize`] range produced by
/// the [`SupportedStreamConfig`] API.
///
/// [`Default`]: BufferSize::Default
/// [`Fixed(FrameCount)`]: BufferSize::Fixed
/// [`SupportedStreamConfig`]: SupportedStreamConfig::buffer_size
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BufferSize {
Default,
Fixed(FrameCount),
}
#[cfg(target_os = "emscripten")]
impl wasm_bindgen::describe::WasmDescribe for BufferSize {
fn describe() {}
}
#[cfg(target_os = "emscripten")]
impl wasm_bindgen::convert::IntoWasmAbi for BufferSize {
type Abi = Option<u32>;
fn into_abi(self) -> Self::Abi {
match self {
Self::Default => None,
Self::Fixed(fc) => Some(fc),
}
.into_abi()
}
}
/// The set of parameters used to describe how to open a stream.
///
/// The sample format is omitted in favour of using a sample type.
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StreamConfig {
pub channels: ChannelCount,
pub sample_rate: SampleRate,
pub buffer_size: BufferSize,
}
/// Describes the minimum and maximum supported buffer size for the device
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SupportedBufferSize {
Range {
min: FrameCount,
max: FrameCount,
},
/// In the case that the platform provides no way of getting the default
/// buffersize before starting a stream.
Unknown,
}
/// Describes a range of supported stream configurations, retrieved via the
/// [`Device::supported_input/output_configs`](traits::DeviceTrait#required-methods) method.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SupportedStreamConfigRange {
pub(crate) channels: ChannelCount,
/// Minimum value for the samples rate of the supported formats.
pub(crate) min_sample_rate: SampleRate,
/// Maximum value for the samples rate of the supported formats.
pub(crate) max_sample_rate: SampleRate,
/// Buffersize ranges supported by the device
pub(crate) buffer_size: SupportedBufferSize,
/// Type of data expected by the device.
pub(crate) sample_format: SampleFormat,
}
/// Describes a single supported stream configuration, retrieved via either a
/// [`SupportedStreamConfigRange`] instance or one of the
/// [`Device::default_input/output_config`](traits::DeviceTrait#required-methods) methods.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportedStreamConfig {
channels: ChannelCount,
sample_rate: SampleRate,
buffer_size: SupportedBufferSize,
sample_format: SampleFormat,
}
/// A buffer of dynamically typed audio data, passed to raw stream callbacks.
///
/// Raw input stream callbacks receive `&Data`, while raw output stream callbacks expect `&mut
/// Data`.
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
#[derive(Debug)]
pub struct Data {
data: *mut (),
len: usize,
sample_format: SampleFormat,
}
/// A monotonic time instance associated with a stream, retrieved from either:
///
/// 1. A timestamp provided to the stream's underlying audio data callback or
/// 2. The same time source used to generate timestamps for a stream's underlying audio data
/// callback.
///
/// `StreamInstant` represents a duration since some unspecified origin occurring either before
/// or equal to the moment the stream from which it was created begins.
///
/// ## Host `StreamInstant` Sources
///
/// | Host | Source |
/// | ---- | ------ |
/// | alsa | `snd_pcm_status_get_htstamp` |
/// | coreaudio | `mach_absolute_time` |
/// | wasapi | `QueryPerformanceCounter` |
/// | asio | `timeGetTime` |
/// | emscripten | `AudioContext.getOutputTimestamp` |
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub struct StreamInstant {
secs: i64,
nanos: u32,
}
/// A timestamp associated with a call to an input stream's data callback.
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
pub struct InputStreamTimestamp {
/// The instant the stream's data callback was invoked.
pub callback: StreamInstant,
/// The instant that data was captured from the device.
///
/// E.g. The instant data was read from an ADC.
pub capture: StreamInstant,
}
/// A timestamp associated with a call to an output stream's data callback.
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
pub struct OutputStreamTimestamp {
/// The instant the stream's data callback was invoked.
pub callback: StreamInstant,
/// The predicted instant that data written will be delivered to the device for playback.
///
/// E.g. The instant data will be played by a DAC.
pub playback: StreamInstant,
}
/// Information relevant to a single call to the user's input stream data callback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputCallbackInfo {
timestamp: InputStreamTimestamp,
}
/// Information relevant to a single call to the user's output stream data callback.
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputCallbackInfo {
timestamp: OutputStreamTimestamp,
}
impl SupportedStreamConfig {
pub fn new(
channels: ChannelCount,
sample_rate: SampleRate,
buffer_size: SupportedBufferSize,
sample_format: SampleFormat,
) -> Self {
Self {
channels,
sample_rate,
buffer_size,
sample_format,
}
}
pub fn channels(&self) -> ChannelCount {
self.channels
}
pub fn sample_rate(&self) -> SampleRate {
self.sample_rate
}
pub fn buffer_size(&self) -> &SupportedBufferSize {
&self.buffer_size
}
pub fn sample_format(&self) -> SampleFormat {
self.sample_format
}
pub fn config(&self) -> StreamConfig {
StreamConfig {
channels: self.channels,
sample_rate: self.sample_rate,
buffer_size: BufferSize::Default,
}
}
}
impl StreamInstant {
/// The amount of time elapsed from another instant to this one.
///
/// Returns `None` if `earlier` is later than self.
pub fn duration_since(&self, earlier: &Self) -> Option<Duration> {
if self < earlier {
None
} else {
(self.as_nanos() - earlier.as_nanos())
.try_into()
.ok()
.map(Duration::from_nanos)
}
}
/// Returns the instant in time after the given duration has passed.
///
/// Returns `None` if the resulting instant would exceed the bounds of the underlying data
/// structure.
pub fn add(&self, duration: Duration) -> Option<Self> {
self.as_nanos()
.checked_add(duration.as_nanos() as i128)
.and_then(Self::from_nanos_i128)
}
/// Returns the instant in time one `duration` ago.
///
/// Returns `None` if the resulting instant would underflow. As a result, it is important to
/// consider that on some platforms the [`StreamInstant`] may begin at `0` from the moment the
/// source stream is created.
pub fn sub(&self, duration: Duration) -> Option<Self> {
self.as_nanos()
.checked_sub(duration.as_nanos() as i128)
.and_then(Self::from_nanos_i128)
}
fn as_nanos(&self) -> i128 {
(self.secs as i128 * 1_000_000_000) + self.nanos as i128
}
#[allow(dead_code)]
fn from_nanos(nanos: i64) -> Self {
let secs = nanos / 1_000_000_000;
let subsec_nanos = nanos - secs * 1_000_000_000;
Self::new(secs, subsec_nanos as u32)
}
#[allow(dead_code)]
fn from_nanos_i128(nanos: i128) -> Option<Self> {
let secs = nanos / 1_000_000_000;
if secs > i64::MAX as i128 || secs < i64::MIN as i128 {
None
} else {
let subsec_nanos = nanos - secs * 1_000_000_000;
debug_assert!(subsec_nanos < u32::MAX as i128);
Some(Self::new(secs as i64, subsec_nanos as u32))
}
}
#[allow(dead_code)]
fn from_secs_f64(secs: f64) -> crate::StreamInstant {
let s = secs.floor() as i64;
let ns = ((secs - s as f64) * 1_000_000_000.0) as u32;
Self::new(s, ns)
}
fn new(secs: i64, nanos: u32) -> Self {
StreamInstant { secs, nanos }
}
}
impl InputCallbackInfo {
/// The timestamp associated with the call to an input stream's data callback.
pub fn timestamp(&self) -> InputStreamTimestamp {
self.timestamp
}
}
impl OutputCallbackInfo {
/// The timestamp associated with the call to an output stream's data callback.
pub fn timestamp(&self) -> OutputStreamTimestamp {
self.timestamp
}
}
#[allow(clippy::len_without_is_empty)]
impl Data {
// Internal constructor for host implementations to use.
//
// The following requirements must be met in order for the safety of `Data`'s public API.
//
// - The `data` pointer must point to the first sample in the slice containing all samples.
// - The `len` must describe the length of the buffer as a number of samples in the expected
// format specified via the `sample_format` argument.
// - The `sample_format` must correctly represent the underlying sample data delivered/expected
// by the stream.
pub(crate) unsafe fn from_parts(
data: *mut (),
len: usize,
sample_format: SampleFormat,
) -> Self {
Data {
data,
len,
sample_format,
}
}
/// The sample format of the internal audio data.
pub fn sample_format(&self) -> SampleFormat {
self.sample_format
}
/// The full length of the buffer in samples.
///
/// The returned length is the same length as the slice of type `T` that would be returned via
/// [`as_slice`](Self::as_slice) given a sample type that matches the inner sample format.
pub fn len(&self) -> usize {
self.len
}
/// The raw slice of memory representing the underlying audio data as a slice of bytes.
///
/// It is up to the user to interpret the slice of memory based on [`Data::sample_format`].
pub fn bytes(&self) -> &[u8] {
let len = self.len * self.sample_format.sample_size();
// The safety of this block relies on correct construction of the `Data` instance.
// See the unsafe `from_parts` constructor for these requirements.
unsafe { std::slice::from_raw_parts(self.data as *const u8, len) }
}
/// The raw slice of memory representing the underlying audio data as a slice of bytes.
///
/// It is up to the user to interpret the slice of memory based on [`Data::sample_format`].
pub fn bytes_mut(&mut self) -> &mut [u8] {
let len = self.len * self.sample_format.sample_size();
// The safety of this block relies on correct construction of the `Data` instance. See
// the unsafe `from_parts` constructor for these requirements.
unsafe { std::slice::from_raw_parts_mut(self.data as *mut u8, len) }
}
/// Access the data as a slice of sample type `T`.
///
/// Returns `None` if the sample type does not match the expected sample format.
pub fn as_slice<T>(&self) -> Option<&[T]>
where
T: SizedSample,
{
if T::FORMAT == self.sample_format {
// The safety of this block relies on correct construction of the `Data` instance. See
// the unsafe `from_parts` constructor for these requirements.
unsafe { Some(std::slice::from_raw_parts(self.data as *const T, self.len)) }
} else {
None
}
}
/// Access the data as a slice of sample type `T`.
///
/// Returns `None` if the sample type does not match the expected sample format.
pub fn as_slice_mut<T>(&mut self) -> Option<&mut [T]>
where
T: SizedSample,
{
if T::FORMAT == self.sample_format {
// The safety of this block relies on correct construction of the `Data` instance. See
// the unsafe `from_parts` constructor for these requirements.
unsafe {
Some(std::slice::from_raw_parts_mut(
self.data as *mut T,
self.len,
))
}
} else {
None
}
}
}
impl SupportedStreamConfigRange {
pub fn new(
channels: ChannelCount,
min_sample_rate: SampleRate,
max_sample_rate: SampleRate,
buffer_size: SupportedBufferSize,
sample_format: SampleFormat,
) -> Self {
Self {
channels,
min_sample_rate,
max_sample_rate,
buffer_size,
sample_format,
}
}
pub fn channels(&self) -> ChannelCount {
self.channels
}
pub fn min_sample_rate(&self) -> SampleRate {
self.min_sample_rate
}
pub fn max_sample_rate(&self) -> SampleRate {
self.max_sample_rate
}
pub fn buffer_size(&self) -> &SupportedBufferSize {
&self.buffer_size
}
pub fn sample_format(&self) -> SampleFormat {
self.sample_format
}
/// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size.
///
/// # Panics
///
/// Panics if the given `sample_rate` is outside the range specified within
/// this [`SupportedStreamConfigRange`] instance. For a non-panicking
/// variant, use [`try_with_sample_rate`](#method.try_with_sample_rate).
pub fn with_sample_rate(self, sample_rate: SampleRate) -> SupportedStreamConfig {
self.try_with_sample_rate(sample_rate)
.expect("sample rate out of range")
}
/// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size.
///
/// Returns `None` if the given sample rate is outside the range specified
/// within this [`SupportedStreamConfigRange`] instance.
pub fn try_with_sample_rate(self, sample_rate: SampleRate) -> Option<SupportedStreamConfig> {
if self.min_sample_rate <= sample_rate && sample_rate <= self.max_sample_rate {
Some(SupportedStreamConfig {
channels: self.channels,
sample_rate,
sample_format: self.sample_format,
buffer_size: self.buffer_size,
})
} else {
None
}
}
/// Turns this [`SupportedStreamConfigRange`] into a [`SupportedStreamConfig`] corresponding to the maximum samples rate.
#[inline]
pub fn with_max_sample_rate(self) -> SupportedStreamConfig {
SupportedStreamConfig {
channels: self.channels,
sample_rate: self.max_sample_rate,
sample_format: self.sample_format,
buffer_size: self.buffer_size,
}
}
/// A comparison function which compares two [`SupportedStreamConfigRange`]s in terms of their priority of
/// use as a default stream format.
///
/// Some backends do not provide a default stream format for their audio devices. In these
/// cases, CPAL attempts to decide on a reasonable default format for the user. To do this we
/// use the "greatest" of all supported stream formats when compared with this method.
///
/// SupportedStreamConfigs are prioritised by the following heuristics:
///
/// **Channels**:
///
/// - Stereo
/// - Mono
/// - Max available channels
///
/// **Sample format**:
/// - f32
/// - i16
/// - u16
///
/// **Sample rate**:
///
/// - 44100 (cd quality)
/// - Max sample rate
pub fn cmp_default_heuristics(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering::Equal;
use SampleFormat::{F32, I16, U16};
let cmp_stereo = (self.channels == 2).cmp(&(other.channels == 2));
if cmp_stereo != Equal {
return cmp_stereo;
}
let cmp_mono = (self.channels == 1).cmp(&(other.channels == 1));
if cmp_mono != Equal {
return cmp_mono;
}
let cmp_channels = self.channels.cmp(&other.channels);
if cmp_channels != Equal {
return cmp_channels;
}
let cmp_f32 = (self.sample_format == F32).cmp(&(other.sample_format == F32));
if cmp_f32 != Equal {
return cmp_f32;
}
let cmp_i16 = (self.sample_format == I16).cmp(&(other.sample_format == I16));
if cmp_i16 != Equal {
return cmp_i16;
}
let cmp_u16 = (self.sample_format == U16).cmp(&(other.sample_format == U16));
if cmp_u16 != Equal {
return cmp_u16;
}
const HZ_44100: SampleRate = SampleRate(44_100);
let r44100_in_self = self.min_sample_rate <= HZ_44100 && HZ_44100 <= self.max_sample_rate;
let r44100_in_other =
other.min_sample_rate <= HZ_44100 && HZ_44100 <= other.max_sample_rate;
let cmp_r44100 = r44100_in_self.cmp(&r44100_in_other);
if cmp_r44100 != Equal {
return cmp_r44100;
}
self.max_sample_rate.cmp(&other.max_sample_rate)
}
}
#[test]
fn test_cmp_default_heuristics() {
let mut formats = [
SupportedStreamConfigRange {
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
channels: 2,
min_sample_rate: SampleRate(1),
max_sample_rate: SampleRate(96000),
sample_format: SampleFormat::F32,
},
SupportedStreamConfigRange {
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
channels: 1,
min_sample_rate: SampleRate(1),
max_sample_rate: SampleRate(96000),
sample_format: SampleFormat::F32,
},
SupportedStreamConfigRange {
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
channels: 2,
min_sample_rate: SampleRate(1),
max_sample_rate: SampleRate(96000),
sample_format: SampleFormat::I16,
},
SupportedStreamConfigRange {
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
channels: 2,
min_sample_rate: SampleRate(1),
max_sample_rate: SampleRate(96000),
sample_format: SampleFormat::U16,
},
SupportedStreamConfigRange {
buffer_size: SupportedBufferSize::Range { min: 256, max: 512 },
channels: 2,
min_sample_rate: SampleRate(1),
max_sample_rate: SampleRate(22050),
sample_format: SampleFormat::F32,
},
];
formats.sort_by(|a, b| a.cmp_default_heuristics(b));
// lowest-priority first:
assert_eq!(formats[0].sample_format(), SampleFormat::F32);
assert_eq!(formats[0].min_sample_rate(), SampleRate(1));
assert_eq!(formats[0].max_sample_rate(), SampleRate(96000));
assert_eq!(formats[0].channels(), 1);
assert_eq!(formats[1].sample_format(), SampleFormat::U16);
assert_eq!(formats[1].min_sample_rate(), SampleRate(1));
assert_eq!(formats[1].max_sample_rate(), SampleRate(96000));
assert_eq!(formats[1].channels(), 2);
assert_eq!(formats[2].sample_format(), SampleFormat::I16);
assert_eq!(formats[2].min_sample_rate(), SampleRate(1));
assert_eq!(formats[2].max_sample_rate(), SampleRate(96000));
assert_eq!(formats[2].channels(), 2);
assert_eq!(formats[3].sample_format(), SampleFormat::F32);
assert_eq!(formats[3].min_sample_rate(), SampleRate(1));
assert_eq!(formats[3].max_sample_rate(), SampleRate(22050));
assert_eq!(formats[3].channels(), 2);
assert_eq!(formats[4].sample_format(), SampleFormat::F32);
assert_eq!(formats[4].min_sample_rate(), SampleRate(1));
assert_eq!(formats[4].max_sample_rate(), SampleRate(96000));
assert_eq!(formats[4].channels(), 2);
}
impl From<SupportedStreamConfig> for StreamConfig {
fn from(conf: SupportedStreamConfig) -> Self {
conf.config()
}
}
// If a backend does not provide an API for retrieving supported formats, we query it with a bunch
// of commonly used rates. This is always the case for wasapi and is sometimes the case for alsa.
//
// If a rate you desire is missing from this list, feel free to add it!
#[cfg(target_os = "windows")]
const COMMON_SAMPLE_RATES: &[SampleRate] = &[
SampleRate(5512),
SampleRate(8000),
SampleRate(11025),
SampleRate(16000),
SampleRate(22050),
SampleRate(32000),
SampleRate(44100),
SampleRate(48000),
SampleRate(64000),
SampleRate(88200),
SampleRate(96000),
SampleRate(176400),
SampleRate(192000),
];
#[test]
fn test_stream_instant() {
let a = StreamInstant::new(2, 0);
let b = StreamInstant::new(-2, 0);
let min = StreamInstant::new(i64::MIN, 0);
let max = StreamInstant::new(i64::MAX, 0);
assert_eq!(
a.sub(Duration::from_secs(1)),
Some(StreamInstant::new(1, 0))
);
assert_eq!(
a.sub(Duration::from_secs(2)),
Some(StreamInstant::new(0, 0))
);
assert_eq!(
a.sub(Duration::from_secs(3)),
Some(StreamInstant::new(-1, 0))
);
assert_eq!(min.sub(Duration::from_secs(1)), None);
assert_eq!(
b.add(Duration::from_secs(1)),
Some(StreamInstant::new(-1, 0))
);
assert_eq!(
b.add(Duration::from_secs(2)),
Some(StreamInstant::new(0, 0))
);
assert_eq!(
b.add(Duration::from_secs(3)),
Some(StreamInstant::new(1, 0))
);
assert_eq!(max.add(Duration::from_secs(1)), None);
}
@@ -0,0 +1,742 @@
//! Platform-specific items.
//!
//! This module also contains the implementation of the platform's dynamically dispatched [`Host`]
//! type and its associated [`Device`], [`Stream`] and other associated types. These
//! types are useful in the case that users require switching between audio host APIs at runtime.
#[doc(inline)]
pub use self::platform_impl::*;
/// A macro to assist with implementing a platform's dynamically dispatched [`Host`] type.
///
/// These dynamically dispatched types are necessary to allow for users to switch between hosts at
/// runtime.
///
/// For example the invocation `impl_platform_host(Wasapi wasapi "WASAPI", Asio asio "ASIO")`,
/// this macro should expand to:
///
// This sample code block is marked as text because it's not a valid test,
// it's just illustrative. (see rust issue #96573)
/// ```text
/// pub enum HostId {
/// Wasapi,
/// Asio,
/// }
///
/// pub enum Host {
/// Wasapi(crate::host::wasapi::Host),
/// Asio(crate::host::asio::Host),
/// }
/// ```
///
/// And so on for Device, Devices, Host, Stream, SupportedInputConfigs,
/// SupportedOutputConfigs and all their necessary trait implementations.
///
macro_rules! impl_platform_host {
($($(#[cfg($feat: meta)])? $HostVariant:ident $host_mod:ident $host_name:literal),*) => {
/// All hosts supported by CPAL on this platform.
pub const ALL_HOSTS: &'static [HostId] = &[
$(
$(#[cfg($feat)])?
HostId::$HostVariant,
)*
];
/// The platform's dynamically dispatched `Host` type.
///
/// An instance of this `Host` type may represent one of the `Host`s available
/// on the platform.
///
/// Use this type if you require switching between available hosts at runtime.
///
/// This type may be constructed via the [`host_from_id`] function. [`HostId`]s may
/// be acquired via the [`ALL_HOSTS`] const, and the [`available_hosts`] function.
pub struct Host(HostInner);
/// The `Device` implementation associated with the platform's dynamically dispatched
/// [`Host`] type.
#[derive(Clone)]
pub struct Device(DeviceInner);
/// The `Devices` iterator associated with the platform's dynamically dispatched [`Host`]
/// type.
pub struct Devices(DevicesInner);
/// The `Stream` implementation associated with the platform's dynamically dispatched
/// [`Host`] type.
// Streams cannot be `Send` or `Sync` if we plan to support Android's AAudio API. This is
// because the stream API is not thread-safe, and the API prohibits calling certain
// functions within the callback.
//
// TODO: Confirm this and add more specific detail and references.
#[must_use = "If the stream is not stored it will not play."]
pub struct Stream(StreamInner, crate::platform::NotSendSyncAcrossAllPlatforms);
/// The `SupportedInputConfigs` iterator associated with the platform's dynamically
/// dispatched [`Host`] type.
pub struct SupportedInputConfigs(SupportedInputConfigsInner);
/// The `SupportedOutputConfigs` iterator associated with the platform's dynamically
/// dispatched [`Host`] type.
pub struct SupportedOutputConfigs(SupportedOutputConfigsInner);
/// Unique identifier for available hosts on the platform.
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
pub enum HostId {
$(
$(#[cfg($feat)])?
$HostVariant,
)*
}
/// Contains a platform specific [`Device`] implementation.
#[derive(Clone)]
pub enum DeviceInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::Device),
)*
}
/// Contains a platform specific [`Devices`] implementation.
pub enum DevicesInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::Devices),
)*
}
/// Contains a platform specific [`Host`] implementation.
pub enum HostInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::Host),
)*
}
/// Contains a platform specific [`Stream`] implementation.
pub enum StreamInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::Stream),
)*
}
enum SupportedInputConfigsInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::SupportedInputConfigs),
)*
}
enum SupportedOutputConfigsInner {
$(
$(#[cfg($feat)])?
$HostVariant(crate::host::$host_mod::SupportedOutputConfigs),
)*
}
impl HostId {
pub fn name(&self) -> &'static str {
match self {
$(
$(#[cfg($feat)])?
HostId::$HostVariant => $host_name,
)*
}
}
}
impl Devices {
/// Returns a reference to the underlying platform specific implementation of this
/// `Devices`.
pub fn as_inner(&self) -> &DevicesInner {
&self.0
}
/// Returns a mutable reference to the underlying platform specific implementation of
/// this `Devices`.
pub fn as_inner_mut(&mut self) -> &mut DevicesInner {
&mut self.0
}
/// Returns the underlying platform specific implementation of this `Devices`.
pub fn into_inner(self) -> DevicesInner {
self.0
}
}
impl Device {
/// Returns a reference to the underlying platform specific implementation of this
/// `Device`.
pub fn as_inner(&self) -> &DeviceInner {
&self.0
}
/// Returns a mutable reference to the underlying platform specific implementation of
/// this `Device`.
pub fn as_inner_mut(&mut self) -> &mut DeviceInner {
&mut self.0
}
/// Returns the underlying platform specific implementation of this `Device`.
pub fn into_inner(self) -> DeviceInner {
self.0
}
}
impl Host {
/// The unique identifier associated with this `Host`.
pub fn id(&self) -> HostId {
match self.0 {
$(
$(#[cfg($feat)])?
HostInner::$HostVariant(_) => HostId::$HostVariant,
)*
}
}
/// Returns a reference to the underlying platform specific implementation of this
/// `Host`.
pub fn as_inner(&self) -> &HostInner {
&self.0
}
/// Returns a mutable reference to the underlying platform specific implementation of
/// this `Host`.
pub fn as_inner_mut(&mut self) -> &mut HostInner {
&mut self.0
}
/// Returns the underlying platform specific implementation of this `Host`.
pub fn into_inner(self) -> HostInner {
self.0
}
}
impl Stream {
/// Returns a reference to the underlying platform specific implementation of this
/// `Stream`.
pub fn as_inner(&self) -> &StreamInner {
&self.0
}
/// Returns a mutable reference to the underlying platform specific implementation of
/// this `Stream`.
pub fn as_inner_mut(&mut self) -> &mut StreamInner {
&mut self.0
}
/// Returns the underlying platform specific implementation of this `Stream`.
pub fn into_inner(self) -> StreamInner {
self.0
}
}
impl Iterator for Devices {
type Item = Device;
fn next(&mut self) -> Option<Self::Item> {
match self.0 {
$(
$(#[cfg($feat)])?
DevicesInner::$HostVariant(ref mut d) => {
d.next().map(DeviceInner::$HostVariant).map(Device::from)
}
)*
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
match self.0 {
$(
$(#[cfg($feat)])?
DevicesInner::$HostVariant(ref d) => d.size_hint(),
)*
}
}
}
impl Iterator for SupportedInputConfigs {
type Item = crate::SupportedStreamConfigRange;
fn next(&mut self) -> Option<Self::Item> {
match self.0 {
$(
$(#[cfg($feat)])?
SupportedInputConfigsInner::$HostVariant(ref mut s) => s.next(),
)*
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
match self.0 {
$(
$(#[cfg($feat)])?
SupportedInputConfigsInner::$HostVariant(ref d) => d.size_hint(),
)*
}
}
}
impl Iterator for SupportedOutputConfigs {
type Item = crate::SupportedStreamConfigRange;
fn next(&mut self) -> Option<Self::Item> {
match self.0 {
$(
$(#[cfg($feat)])?
SupportedOutputConfigsInner::$HostVariant(ref mut s) => s.next(),
)*
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
match self.0 {
$(
$(#[cfg($feat)])?
SupportedOutputConfigsInner::$HostVariant(ref d) => d.size_hint(),
)*
}
}
}
impl crate::traits::DeviceTrait for Device {
type SupportedInputConfigs = SupportedInputConfigs;
type SupportedOutputConfigs = SupportedOutputConfigs;
type Stream = Stream;
fn name(&self) -> Result<String, crate::DeviceNameError> {
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => d.name(),
)*
}
}
fn supported_input_configs(&self) -> Result<Self::SupportedInputConfigs, crate::SupportedStreamConfigsError> {
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => {
d.supported_input_configs()
.map(SupportedInputConfigsInner::$HostVariant)
.map(SupportedInputConfigs)
}
)*
}
}
fn supported_output_configs(&self) -> Result<Self::SupportedOutputConfigs, crate::SupportedStreamConfigsError> {
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => {
d.supported_output_configs()
.map(SupportedOutputConfigsInner::$HostVariant)
.map(SupportedOutputConfigs)
}
)*
}
}
fn default_input_config(&self) -> Result<crate::SupportedStreamConfig, crate::DefaultStreamConfigError> {
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => d.default_input_config(),
)*
}
}
fn default_output_config(&self) -> Result<crate::SupportedStreamConfig, crate::DefaultStreamConfigError> {
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => d.default_output_config(),
)*
}
}
fn build_input_stream_raw<D, E>(
&self,
config: &crate::StreamConfig,
sample_format: crate::SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<std::time::Duration>,
) -> Result<Self::Stream, crate::BuildStreamError>
where
D: FnMut(&crate::Data, &crate::InputCallbackInfo) + Send + 'static,
E: FnMut(crate::StreamError) + Send + 'static,
{
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => d
.build_input_stream_raw(
config,
sample_format,
data_callback,
error_callback,
timeout,
)
.map(StreamInner::$HostVariant)
.map(Stream::from),
)*
}
}
fn build_output_stream_raw<D, E>(
&self,
config: &crate::StreamConfig,
sample_format: crate::SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<std::time::Duration>,
) -> Result<Self::Stream, crate::BuildStreamError>
where
D: FnMut(&mut crate::Data, &crate::OutputCallbackInfo) + Send + 'static,
E: FnMut(crate::StreamError) + Send + 'static,
{
match self.0 {
$(
$(#[cfg($feat)])?
DeviceInner::$HostVariant(ref d) => d
.build_output_stream_raw(
config,
sample_format,
data_callback,
error_callback,
timeout,
)
.map(StreamInner::$HostVariant)
.map(Stream::from),
)*
}
}
}
impl crate::traits::HostTrait for Host {
type Devices = Devices;
type Device = Device;
fn is_available() -> bool {
$(
$(#[cfg($feat)])?
if crate::host::$host_mod::Host::is_available() { return true; }
)*
false
}
fn devices(&self) -> Result<Self::Devices, crate::DevicesError> {
match self.0 {
$(
$(#[cfg($feat)])?
HostInner::$HostVariant(ref h) => {
h.devices().map(DevicesInner::$HostVariant).map(Devices::from)
}
)*
}
}
fn default_input_device(&self) -> Option<Self::Device> {
match self.0 {
$(
$(#[cfg($feat)])?
HostInner::$HostVariant(ref h) => {
h.default_input_device().map(DeviceInner::$HostVariant).map(Device::from)
}
)*
}
}
fn default_output_device(&self) -> Option<Self::Device> {
match self.0 {
$(
$(#[cfg($feat)])?
HostInner::$HostVariant(ref h) => {
h.default_output_device().map(DeviceInner::$HostVariant).map(Device::from)
}
)*
}
}
}
impl crate::traits::StreamTrait for Stream {
fn play(&self) -> Result<(), crate::PlayStreamError> {
match self.0 {
$(
$(#[cfg($feat)])?
StreamInner::$HostVariant(ref s) => {
s.play()
}
)*
}
}
fn pause(&self) -> Result<(), crate::PauseStreamError> {
match self.0 {
$(
$(#[cfg($feat)])?
StreamInner::$HostVariant(ref s) => {
s.pause()
}
)*
}
}
}
impl From<DeviceInner> for Device {
fn from(d: DeviceInner) -> Self {
Device(d)
}
}
impl From<DevicesInner> for Devices {
fn from(d: DevicesInner) -> Self {
Devices(d)
}
}
impl From<HostInner> for Host {
fn from(h: HostInner) -> Self {
Host(h)
}
}
impl From<StreamInner> for Stream {
fn from(s: StreamInner) -> Self {
Stream(s, Default::default())
}
}
$(
$(#[cfg($feat)])?
impl From<crate::host::$host_mod::Device> for Device {
fn from(h: crate::host::$host_mod::Device) -> Self {
DeviceInner::$HostVariant(h).into()
}
}
$(#[cfg($feat)])?
impl From<crate::host::$host_mod::Devices> for Devices {
fn from(h: crate::host::$host_mod::Devices) -> Self {
DevicesInner::$HostVariant(h).into()
}
}
$(#[cfg($feat)])?
impl From<crate::host::$host_mod::Host> for Host {
fn from(h: crate::host::$host_mod::Host) -> Self {
HostInner::$HostVariant(h).into()
}
}
$(#[cfg($feat)])?
impl From<crate::host::$host_mod::Stream> for Stream {
fn from(h: crate::host::$host_mod::Stream) -> Self {
StreamInner::$HostVariant(h).into()
}
}
)*
/// Produces a list of hosts that are currently available on the system.
pub fn available_hosts() -> Vec<HostId> {
let mut host_ids = vec![];
$(
$(#[cfg($feat)])?
if <crate::host::$host_mod::Host as crate::traits::HostTrait>::is_available() {
host_ids.push(HostId::$HostVariant);
}
)*
host_ids
}
/// Given a unique host identifier, initialise and produce the host if it is available.
pub fn host_from_id(id: HostId) -> Result<Host, crate::HostUnavailable> {
match id {
$(
$(#[cfg($feat)])?
HostId::$HostVariant => {
crate::host::$host_mod::Host::new()
.map(HostInner::$HostVariant)
.map(Host::from)
}
)*
}
}
};
}
// TODO: Add pulseaudio and jack here eventually.
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd"
))]
mod platform_impl {
pub use crate::host::alsa::{
Device as AlsaDevice, Devices as AlsaDevices, Host as AlsaHost, Stream as AlsaStream,
SupportedInputConfigs as AlsaSupportedInputConfigs,
SupportedOutputConfigs as AlsaSupportedOutputConfigs,
};
#[cfg(feature = "jack")]
pub use crate::host::jack::{
Device as JackDevice, Devices as JackDevices, Host as JackHost, Stream as JackStream,
SupportedInputConfigs as JackSupportedInputConfigs,
SupportedOutputConfigs as JackSupportedOutputConfigs,
};
impl_platform_host!(#[cfg(feature = "jack")] Jack jack "JACK", Alsa alsa "ALSA");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
AlsaHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod platform_impl {
pub use crate::host::coreaudio::{
Device as CoreAudioDevice, Devices as CoreAudioDevices, Host as CoreAudioHost,
Stream as CoreAudioStream, SupportedInputConfigs as CoreAudioSupportedInputConfigs,
SupportedOutputConfigs as CoreAudioSupportedOutputConfigs,
};
impl_platform_host!(CoreAudio coreaudio "CoreAudio");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
CoreAudioHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(target_os = "emscripten")]
mod platform_impl {
pub use crate::host::emscripten::{
Device as EmscriptenDevice, Devices as EmscriptenDevices, Host as EmscriptenHost,
Stream as EmscriptenStream, SupportedInputConfigs as EmscriptenSupportedInputConfigs,
SupportedOutputConfigs as EmscriptenSupportedOutputConfigs,
};
impl_platform_host!(Emscripten emscripten "Emscripten");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
EmscriptenHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))]
mod platform_impl {
pub use crate::host::webaudio::{
Device as WebAudioDevice, Devices as WebAudioDevices, Host as WebAudioHost,
Stream as WebAudioStream, SupportedInputConfigs as WebAudioSupportedInputConfigs,
SupportedOutputConfigs as WebAudioSupportedOutputConfigs,
};
impl_platform_host!(WebAudio webaudio "WebAudio");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
WebAudioHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(windows)]
mod platform_impl {
#[cfg(feature = "asio")]
pub use crate::host::asio::{
Device as AsioDevice, Devices as AsioDevices, Host as AsioHost, Stream as AsioStream,
SupportedInputConfigs as AsioSupportedInputConfigs,
SupportedOutputConfigs as AsioSupportedOutputConfigs,
};
pub use crate::host::wasapi::{
Device as WasapiDevice, Devices as WasapiDevices, Host as WasapiHost,
Stream as WasapiStream, SupportedInputConfigs as WasapiSupportedInputConfigs,
SupportedOutputConfigs as WasapiSupportedOutputConfigs,
};
impl_platform_host!(#[cfg(feature = "asio")] Asio asio "ASIO", Wasapi wasapi "WASAPI");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
WasapiHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(target_os = "android")]
mod platform_impl {
pub use crate::host::oboe::{
Device as OboeDevice, Devices as OboeDevices, Host as OboeHost, Stream as OboeStream,
SupportedInputConfigs as OboeSupportedInputConfigs,
SupportedOutputConfigs as OboeSupportedOutputConfigs,
};
impl_platform_host!(Oboe oboe "Oboe");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
OboeHost::new()
.expect("the default host should always be available")
.into()
}
}
#[cfg(not(any(
windows,
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "macos",
target_os = "ios",
target_os = "emscripten",
target_os = "android",
all(target_arch = "wasm32", feature = "wasm-bindgen"),
)))]
mod platform_impl {
pub use crate::host::null::{
Device as NullDevice, Devices as NullDevices, Host as NullHost,
SupportedInputConfigs as NullSupportedInputConfigs,
SupportedOutputConfigs as NullSupportedOutputConfigs,
};
impl_platform_host!(Null null "Null");
/// The default host for the current compilation target platform.
pub fn default_host() -> Host {
NullHost::new()
.expect("the default host should always be available")
.into()
}
}
// The following zero-sized types are for applying Send/Sync restrictions to ensure
// consistent behaviour across different platforms. These verbosely named types are used
// (rather than using the markers directly) in the hope of making the compile errors
// slightly more helpful.
//
// TODO: Remove these in favour of using negative trait bounds if they stabilise.
// A marker used to remove the `Send` and `Sync` traits.
struct NotSendSyncAcrossAllPlatforms(std::marker::PhantomData<*mut ()>);
impl Default for NotSendSyncAcrossAllPlatforms {
fn default() -> Self {
NotSendSyncAcrossAllPlatforms(std::marker::PhantomData)
}
}
@@ -0,0 +1,167 @@
use std::{fmt::Display, mem};
#[cfg(target_os = "emscripten")]
use wasm_bindgen::prelude::*;
pub use dasp_sample::{FromSample, Sample, I24, I48, U24, U48};
/// Format that each sample has.
#[cfg_attr(target_os = "emscripten", wasm_bindgen)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SampleFormat {
/// `i8` with a valid range of 'u8::MIN..=u8::MAX' with `0` being the origin
I8,
/// `i16` with a valid range of 'u16::MIN..=u16::MAX' with `0` being the origin
I16,
// /// `I24` with a valid range of '-(1 << 23)..(1 << 23)' with `0` being the origin
// I24,
/// `i32` with a valid range of 'u32::MIN..=u32::MAX' with `0` being the origin
I32,
// /// `I24` with a valid range of '-(1 << 47)..(1 << 47)' with `0` being the origin
// I48,
/// `i64` with a valid range of 'u64::MIN..=u64::MAX' with `0` being the origin
I64,
/// `u8` with a valid range of 'u8::MIN..=u8::MAX' with `1 << 7 == 128` being the origin
U8,
/// `u16` with a valid range of 'u16::MIN..=u16::MAX' with `1 << 15 == 32768` being the origin
U16,
// /// `U24` with a valid range of '0..16777216' with `1 << 23 == 8388608` being the origin
// U24,
/// `u32` with a valid range of 'u32::MIN..=u32::MAX' with `1 << 31` being the origin
U32,
// /// `U48` with a valid range of '0..(1 << 48)' with `1 << 47` being the origin
// U48,
/// `u64` with a valid range of 'u64::MIN..=u64::MAX' with `1 << 63` being the origin
U64,
/// `f32` with a valid range of `-1.0..1.0` with `0.0` being the origin
F32,
/// `f64` with a valid range of -1.0..1.0 with 0.0 being the origin
F64,
}
impl SampleFormat {
/// Returns the size in bytes of a sample of this format.
#[inline]
#[must_use]
pub fn sample_size(&self) -> usize {
match *self {
SampleFormat::I8 | SampleFormat::U8 => mem::size_of::<i8>(),
SampleFormat::I16 | SampleFormat::U16 => mem::size_of::<i16>(),
// SampleFormat::I24 | SampleFormat::U24 => 3,
SampleFormat::I32 | SampleFormat::U32 => mem::size_of::<i32>(),
// SampleFormat::I48 | SampleFormat::U48 => 6,
SampleFormat::I64 | SampleFormat::U64 => mem::size_of::<i64>(),
SampleFormat::F32 => mem::size_of::<f32>(),
SampleFormat::F64 => mem::size_of::<f64>(),
}
}
#[inline]
#[must_use]
pub fn is_int(&self) -> bool {
//matches!(*self, SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I24 | SampleFormat::I32 | SampleFormat::I48 | SampleFormat::I64)
matches!(
*self,
SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I32 | SampleFormat::I64
)
}
#[inline]
#[must_use]
pub fn is_uint(&self) -> bool {
//matches!(*self, SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U24 | SampleFormat::U32 | SampleFormat::U48 | SampleFormat::U64)
matches!(
*self,
SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U32 | SampleFormat::U64
)
}
#[inline]
#[must_use]
pub fn is_float(&self) -> bool {
matches!(*self, SampleFormat::F32 | SampleFormat::F64)
}
}
impl Display for SampleFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
SampleFormat::I8 => "i8",
SampleFormat::I16 => "i16",
// SampleFormat::I24 => "i24",
SampleFormat::I32 => "i32",
// SampleFormat::I48 => "i48",
SampleFormat::I64 => "i64",
SampleFormat::U8 => "u8",
SampleFormat::U16 => "u16",
// SampleFormat::U24 => "u24",
SampleFormat::U32 => "u32",
// SampleFormat::U48 => "u48",
SampleFormat::U64 => "u64",
SampleFormat::F32 => "f32",
SampleFormat::F64 => "f64",
}
.fmt(f)
}
}
pub trait SizedSample: Sample {
const FORMAT: SampleFormat;
}
impl SizedSample for i8 {
const FORMAT: SampleFormat = SampleFormat::I8;
}
impl SizedSample for i16 {
const FORMAT: SampleFormat = SampleFormat::I16;
}
// impl SizedSample for I24 { const FORMAT: SampleFormat = SampleFormat::I24; }
impl SizedSample for i32 {
const FORMAT: SampleFormat = SampleFormat::I32;
}
// impl SizedSample for I48 { const FORMAT: SampleFormat = SampleFormat::I48; }
impl SizedSample for i64 {
const FORMAT: SampleFormat = SampleFormat::I64;
}
impl SizedSample for u8 {
const FORMAT: SampleFormat = SampleFormat::U8;
}
impl SizedSample for u16 {
const FORMAT: SampleFormat = SampleFormat::U16;
}
// impl SizedSample for U24 { const FORMAT: SampleFormat = SampleFormat::U24; }
impl SizedSample for u32 {
const FORMAT: SampleFormat = SampleFormat::U32;
}
// impl SizedSample for U48 { const FORMAT: SampleFormat = SampleFormat::U48; }
impl SizedSample for u64 {
const FORMAT: SampleFormat = SampleFormat::U64;
}
impl SizedSample for f32 {
const FORMAT: SampleFormat = SampleFormat::F32;
}
impl SizedSample for f64 {
const FORMAT: SampleFormat = SampleFormat::F64;
}
+221
View File
@@ -0,0 +1,221 @@
//! The suite of traits allowing CPAL to abstract over hosts, devices, event loops and stream IDs.
use std::time::Duration;
use crate::{
BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError,
InputCallbackInfo, InputDevices, OutputCallbackInfo, OutputDevices, PauseStreamError,
PlayStreamError, SampleFormat, SizedSample, StreamConfig, StreamError, SupportedStreamConfig,
SupportedStreamConfigRange, SupportedStreamConfigsError,
};
/// A [`Host`] provides access to the available audio devices on the system.
///
/// Each platform may have a number of available hosts depending on the system, each with their own
/// pros and cons.
///
/// For example, WASAPI is the standard audio host API that ships with the Windows operating
/// system. However, due to historical limitations with respect to performance and flexibility,
/// Steinberg created the ASIO API providing better audio device support for pro audio and
/// low-latency applications. As a result, it is common for some devices and device capabilities to
/// only be available via ASIO, while others are only available via WASAPI.
///
/// Another great example is the Linux platform. While the ALSA host API is the lowest-level API
/// available to almost all distributions of Linux, its flexibility is limited as it requires that
/// each process have exclusive access to the devices with which they establish streams. PulseAudio
/// is another popular host API that aims to solve this issue by providing user-space mixing,
/// however it has its own limitations w.r.t. low-latency and high-performance audio applications.
/// JACK is yet another host API that is more suitable to pro-audio applications, however it is
/// less readily available by default in many Linux distributions and is known to be tricky to
/// set up.
///
/// [`Host`]: crate::Host
pub trait HostTrait {
/// The type used for enumerating available devices by the host.
type Devices: Iterator<Item = Self::Device>;
/// The `Device` type yielded by the host.
type Device: DeviceTrait;
/// Whether or not the host is available on the system.
fn is_available() -> bool;
/// An iterator yielding all [`Device`](DeviceTrait)s currently available to the host on the system.
///
/// Can be empty if the system does not support audio in general.
fn devices(&self) -> Result<Self::Devices, DevicesError>;
/// The default input audio device on the system.
///
/// Returns `None` if no input device is available.
fn default_input_device(&self) -> Option<Self::Device>;
/// The default output audio device on the system.
///
/// Returns `None` if no output device is available.
fn default_output_device(&self) -> Option<Self::Device>;
/// An iterator yielding all `Device`s currently available to the system that support one or more
/// input stream formats.
///
/// Can be empty if the system does not support audio input.
fn input_devices(&self) -> Result<InputDevices<Self::Devices>, DevicesError> {
fn supports_input<D: DeviceTrait>(device: &D) -> bool {
device
.supported_input_configs()
.map(|mut iter| iter.next().is_some())
.unwrap_or(false)
}
Ok(self.devices()?.filter(supports_input::<Self::Device>))
}
/// An iterator yielding all `Device`s currently available to the system that support one or more
/// output stream formats.
///
/// Can be empty if the system does not support audio output.
fn output_devices(&self) -> Result<OutputDevices<Self::Devices>, DevicesError> {
fn supports_output<D: DeviceTrait>(device: &D) -> bool {
device
.supported_output_configs()
.map(|mut iter| iter.next().is_some())
.unwrap_or(false)
}
Ok(self.devices()?.filter(supports_output::<Self::Device>))
}
}
/// A device that is capable of audio input and/or output.
///
/// Please note that `Device`s may become invalid if they get disconnected. Therefore, all the
/// methods that involve a device return a `Result` allowing the user to handle this case.
pub trait DeviceTrait {
/// The iterator type yielding supported input stream formats.
type SupportedInputConfigs: Iterator<Item = SupportedStreamConfigRange>;
/// The iterator type yielding supported output stream formats.
type SupportedOutputConfigs: Iterator<Item = SupportedStreamConfigRange>;
/// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`].
///
/// [`build_input_stream_raw`]: Self::build_input_stream_raw
/// [`build_output_stream_raw`]: Self::build_output_stream_raw
type Stream: StreamTrait;
/// The human-readable name of the device.
fn name(&self) -> Result<String, DeviceNameError>;
/// An iterator yielding formats that are supported by the backend.
///
/// Can return an error if the device is no longer valid (e.g. it has been disconnected).
fn supported_input_configs(
&self,
) -> Result<Self::SupportedInputConfigs, SupportedStreamConfigsError>;
/// An iterator yielding output stream formats that are supported by the device.
///
/// Can return an error if the device is no longer valid (e.g. it has been disconnected).
fn supported_output_configs(
&self,
) -> Result<Self::SupportedOutputConfigs, SupportedStreamConfigsError>;
/// The default input stream format for the device.
fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError>;
/// The default output stream format for the device.
fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError>;
/// Create an input stream.
fn build_input_stream<T, D, E>(
&self,
config: &StreamConfig,
mut data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
T: SizedSample,
D: FnMut(&[T], &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
self.build_input_stream_raw(
config,
T::FORMAT,
move |data, info| {
data_callback(
data.as_slice()
.expect("host supplied incorrect sample type"),
info,
)
},
error_callback,
timeout,
)
}
/// Create an output stream.
fn build_output_stream<T, D, E>(
&self,
config: &StreamConfig,
mut data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
T: SizedSample,
D: FnMut(&mut [T], &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static,
{
self.build_output_stream_raw(
config,
T::FORMAT,
move |data, info| {
data_callback(
data.as_slice_mut()
.expect("host supplied incorrect sample type"),
info,
)
},
error_callback,
timeout,
)
}
/// Create a dynamically typed input stream.
fn build_input_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static;
/// Create a dynamically typed output stream.
fn build_output_stream_raw<D, E>(
&self,
config: &StreamConfig,
sample_format: SampleFormat,
data_callback: D,
error_callback: E,
timeout: Option<Duration>,
) -> Result<Self::Stream, BuildStreamError>
where
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(StreamError) + Send + 'static;
}
/// A stream created from [`Device`](DeviceTrait), with methods to control playback.
pub trait StreamTrait {
/// Run the stream.
///
/// Note: Not all platforms automatically run the stream upon creation, so it is important to
/// call `play` after creation if it is expected that the stream should run immediately.
fn play(&self) -> Result<(), PlayStreamError>;
/// Some devices support pausing the audio stream. This can be useful for saving energy in
/// moments of silence.
///
/// Note: Not all devices support suspending the stream at the hardware level. This method may
/// fail in these cases.
fn pause(&self) -> Result<(), PauseStreamError>;
}
@@ -0,0 +1 @@
{"v":1}
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
},
"path_in_vcs": "symphonia-format-isomp4"
}
+94
View File
@@ -0,0 +1,94 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytemuck"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
@@ -0,0 +1,59 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
rust-version = "1.53"
name = "symphonia-format-isomp4"
version = "0.5.5"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
readme = "README.md"
keywords = [
"audio",
"media",
"demuxer",
"mp4",
"iso",
]
categories = [
"multimedia",
"multimedia::audio",
"multimedia::encoding",
]
license = "MPL-2.0"
repository = "https://github.com/pdeljanov/Symphonia"
[lib]
name = "symphonia_format_isomp4"
path = "src/lib.rs"
[dependencies.encoding_rs]
version = "0.8.17"
[dependencies.log]
version = "0.4"
[dependencies.symphonia-core]
version = "0.5.5"
[dependencies.symphonia-metadata]
version = "0.5.5"
[dependencies.symphonia-utils-xiph]
version = "0.5.5"
@@ -0,0 +1,20 @@
[package]
name = "symphonia-format-isomp4"
version = "0.5.5"
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
repository = "https://github.com/pdeljanov/Symphonia"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
license = "MPL-2.0"
readme = "README.md"
categories = ["multimedia", "multimedia::audio", "multimedia::encoding"]
keywords = ["audio", "media", "demuxer", "mp4", "iso"]
edition = "2018"
rust-version = "1.53"
[dependencies]
encoding_rs = "0.8.17"
log = "0.4"
symphonia-core = { version = "0.5.5", path = "../symphonia-core" }
symphonia-metadata = { version = "0.5.5", path = "../symphonia-metadata" }
symphonia-utils-xiph = { version = "0.5.5", path = "../symphonia-utils-xiph" }
@@ -0,0 +1,15 @@
# Symphonia ISO/MP4 Demuxer
[![Docs](https://docs.rs/symphonia-format-isomp4/badge.svg)](https://docs.rs/symphonia-format-isomp4)
ISO/MP4 demuxer for Project Symphonia.
**Note:** This crate is part of Project Symphonia. Please use the [`symphonia`](https://crates.io/crates/symphonia) crate instead of this one directly.
## License
Symphonia is provided under the MPL v2.0 license. Please refer to the LICENSE file for more details.
## Contributing
Symphonia is a free and open-source project that welcomes contributions! To get started, please read our [Contribution Guidelines](https://github.com/pdeljanov/Symphonia/tree/master/CONTRIBUTING.md).
@@ -0,0 +1,60 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_ALAC};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct AlacAtom {
/// Atom header.
header: AtomHeader,
/// ALAC extra data (magic cookie).
extra_data: Box<[u8]>,
}
impl Atom for AlacAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
if version != 0 {
return unsupported_error("isomp4 (alac): unsupported alac version");
}
if flags != 0 {
return decode_error("isomp4 (alac): flags not zero");
}
if header.data_len <= AtomHeader::EXTRA_DATA_SIZE {
return decode_error("isomp4 (alac): invalid alac atom length");
}
// The ALAC magic cookie (aka extra data) is either 24 or 48 bytes long.
let magic_len = match header.data_len - AtomHeader::EXTRA_DATA_SIZE {
len @ 24 | len @ 48 => len as usize,
_ => return decode_error("isomp4 (alac): invalid magic cookie length"),
};
// Read the magic cookie.
let extra_data = reader.read_boxed_slice_exact(magic_len)?;
Ok(AlacAtom { header, extra_data })
}
}
impl AlacAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(CODEC_TYPE_ALAC).with_extra_data(self.extra_data.clone());
}
}
@@ -0,0 +1,41 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Chunk offset atom (64-bit version).
#[allow(dead_code)]
#[derive(Debug)]
pub struct Co64Atom {
/// Atom header.
header: AtomHeader,
pub chunk_offsets: Vec<u64>,
}
impl Atom for Co64Atom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
chunk_offsets.push(reader.read_be_u64()?);
}
Ok(Co64Atom { header, chunk_offsets })
}
}
@@ -0,0 +1,29 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Composition time atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct CttsAtom {
/// Atom header.
header: AtomHeader,
}
impl Atom for CttsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
todo!()
}
}
@@ -0,0 +1,43 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, ElstAtom};
/// Edits atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct EdtsAtom {
header: AtomHeader,
pub elst: Option<ElstAtom>,
}
impl Atom for EdtsAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut elst = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::EditList => {
elst = Some(iter.read_atom::<ElstAtom>()?);
}
_ => (),
}
}
Ok(EdtsAtom { header, elst })
}
}

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