From 114505c0a9f3b8fd84203298559134728b6a4b49 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:18:48 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(player):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B7=B7=E9=9F=B3=E4=B8=8E=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E9=A6=96=E9=A2=84=E5=8A=A0=E8=BD=BD=EF=BC=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=8F=8C=E6=A7=BD=20crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将旧版 SPlayer 的 Automix 能力完整迁移到 SPlayer-Next,同时首次支持歌曲预加载。新实现基于 Rust audio-engine 双槽架构(当前槽 + bounded preload 槽),不影响现有普通播放路径。 变更内容: - native/audio-engine 双槽播放:新增 preload/cancelPreload/crossfadeTo NAPI,preload 保留一个 bounded prepared slot,crossfadeTo 优先复用匹配预载槽。过渡期间旧槽淡出、新槽淡入,完成后释放旧槽资源。 - 迁移旧版音频分析(analysis.rs):BPM/调性/Smart Cut/transition proposal 到 native,通过主进程 worker 暴露 analyzeAudioFile、analyzeAudioFileHead、suggestTransition、suggestLongMix,避免阻塞主线程。分析结果作有界缓存,切歌/队列变化立即失效。 - 渲染层预载编排(preload.ts):根据队列、播放模式、音质解析下一首 URL,优先命中本地缓存;未命中时立即 preload 真实 URL,后台触发缓存下载。 - 渲染层 Automix 调度(automix.ts):复用旧版计划计算,输出 trigger time、crossfade duration、next start seek、initial rate、mix type,position 事件驱动,不新增高频 IPC。 - 中点 UI 提交(transitionCommit):主进程在 crossfade 中点统一提交 UI/歌词/封面/媒体状态/历史记录/预载。过渡期间忽略 native 新槽提前的 position/status,旧曲 UI 持续到 commit 点再切换。 - bassSwap 滤波:native 二阶 biquad 高通包络,旧曲前半段拉到 400Hz、新曲在 crossfade 中点释放回直通,贴合旧版 DJ 互换听感。 - 设置项:preloadNext(默认 true)、automixEnabled(默认 false)、automixMaxAnalyzeTimeSec(默认 60,范围 10-300),补充中英文 i18n 与播放设置项。 --- Cargo.lock | 205 ++++ electron/main/ipc/player.ts | 295 ++++- electron/preload/index.ts | 21 +- native/audio-engine/Cargo.toml | 1 + native/audio-engine/index.d.ts | 82 ++ native/audio-engine/src/analysis.rs | 1312 +++++++++++++++++++++++ native/audio-engine/src/audio_output.rs | 4 +- native/audio-engine/src/decoder.rs | 28 +- native/audio-engine/src/lib.rs | 206 +++- native/audio-engine/src/player.rs | 282 ++++- native/audio-engine/src/shared.rs | 77 ++ native/audio-engine/src/source.rs | 50 + shared/defaults/settings.ts | 3 + shared/types/player.ts | 113 ++ shared/types/settings.ts | 6 + src/core/player/automix.ts | 342 ++++++ src/core/player/events.ts | 19 +- src/core/player/index.ts | 159 ++- src/core/player/preload.ts | 123 +++ src/i18n/locales/en-US.json | 12 + src/i18n/locales/zh-CN.json | 12 + src/services/audioSource.ts | 7 +- src/settings/categories/player.ts | 25 + 23 files changed, 3349 insertions(+), 35 deletions(-) create mode 100644 native/audio-engine/src/analysis.rs create mode 100644 src/core/player/automix.ts create mode 100644 src/core/player/preload.ts diff --git a/Cargo.lock b/Cargo.lock index 5dd37c42..1110df88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ascii-canvas" version = "3.0.0" @@ -292,6 +298,7 @@ dependencies = [ "rodio", "rustfft", "signalsmith-stretch", + "symphonia", "thiserror 2.0.18", "time", "tokio", @@ -838,6 +845,15 @@ dependencies = [ "log", ] +[[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 = "endi" version = "1.1.1" @@ -908,6 +924,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -2735,6 +2757,189 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[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", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 1b3c1280..cfbc7253 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; -import { extname } from "node:path"; +import { extname, join } from "node:path"; +import { Worker } from "node:worker_threads"; import { app, ipcMain, powerMonitor } from "electron"; import { sendToMain } from "@main/utils/broadcast"; import { wsBroadcast } from "@main/server/broadcast"; @@ -27,11 +28,78 @@ import * as songCache from "@main/services/songCache"; import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata"; import { playerLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; -import type { LoadOptions, RepeatMode, ShuffleMode, PlayerState } from "@shared/types/player"; +import type { + CrossfadeOptions, + LoadOptions, + RepeatMode, + ShuffleMode, + PlayerState, +} from "@shared/types/player"; import type { MediaEvent } from "@main/services/media"; import { JsPlayerEvent } from "@splayer/audio-engine"; type AudioEngineModule = typeof import("@splayer/audio-engine"); +type NativeMetadata = Awaited["load"]>>; +type ExtendedNativePlayer = InstanceType & { + preload: (source: string) => Promise; + cancelPreload: () => void; + crossfadeTo: ( + source: string, + options?: { + durationMs?: number; + startSeekMs?: number; + initialRate?: number; + mixType?: "default" | "bassSwap"; + uiSwitchDelayMs?: number; + }, + ) => Promise; +}; + +const getExtendedPlayer = (): ExtendedNativePlayer => getPlayer() as ExtendedNativePlayer; + +type AnalysisWorkerMethod = + | "analyzeAudioFile" + | "analyzeAudioFileHead" + | "suggestTransition" + | "suggestLongMix"; + +const ANALYSIS_WORKER_SOURCE = ` +const { parentPort, workerData } = require("node:worker_threads"); +try { + const engine = require(workerData.modulePath); + const data = engine[workerData.method](...workerData.args); + parentPort.postMessage({ ok: true, data }); +} catch (error) { + parentPort.postMessage({ + ok: false, + message: error && error.message ? error.message : String(error), + }); +} +`; + +const getAudioEngineNativePath = (): string => + app.isPackaged + ? join(process.resourcesPath, "native", "audio-engine.node") + : join(process.cwd(), "native", "audio-engine", "audio-engine.node"); + +const runAnalysisWorker = (method: AnalysisWorkerMethod, args: unknown[]): Promise => + new Promise((resolve, reject) => { + const worker = new Worker(ANALYSIS_WORKER_SOURCE, { + eval: true, + workerData: { modulePath: getAudioEngineNativePath(), method, args }, + }); + worker.once("message", (message: { ok: boolean; data?: unknown; message?: string }) => { + if (message.ok) { + resolve(message.data); + } else { + reject(new Error(message.message || "audio analysis worker failed")); + } + }); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) reject(new Error(`audio analysis worker exited: ${code}`)); + }); + }); /** 返回失败响应,附带日志 */ const fail = (code: ErrorCode, error?: unknown) => { @@ -142,6 +210,33 @@ const registerNativeEvents = (inst: InstanceType { + const quality = { + sampleRate: meta.originalSampleRate, + channels: meta.channels, + bitsPerSample: meta.bitsPerSample, + bitRate: meta.bitRate, + codec: meta.codec, + }; + return { + detail: { + quality, + embeddedLyric: meta.embeddedLyric, + externalLyrics: meta.externalLyrics, + }, + mediaInfo: { + duration: toMs(meta.duration), + cover: isRemote ? undefined : toCacheUrl(meta.cover), + quality, + }, + }; +}; + /** 播放器相关 IPC */ export const registerPlayerIpc = (): void => { // 注册实例创建/重建时的回调 @@ -240,25 +335,7 @@ export const registerPlayerIpc = (): void => { setTaskbarThumbnailCover(buf); }); } - const quality = { - sampleRate: meta.originalSampleRate, - channels: meta.channels, - bitsPerSample: meta.bitsPerSample, - bitRate: meta.bitRate, - codec: meta.codec, - }; - const data = { - detail: { - quality, - embeddedLyric: meta.embeddedLyric, - externalLyrics: meta.externalLyrics, - }, - mediaInfo: { - duration: durationMs, - cover: isRemote ? undefined : toCacheUrl(meta.cover), - quality, - }, - }; + const data = toLoadResult(meta, isRemote); playerLog.debug(`加载成功: ${displayTitle}`); return { success: true, data }; } catch (error) { @@ -283,6 +360,181 @@ export const registerPlayerIpc = (): void => { } }); + // 静默预载下一首音频 + ipcMain.handle("player:preload", async (_event, source: string) => { + try { + const meta = await getExtendedPlayer().preload(source); + return { success: true, data: toLoadResult(meta, source.startsWith("http")) }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("已被更新的 load 取代")) { + return fail(ErrorCode.LOAD_SUPERSEDED); + } + return fail(ErrorCode.UNKNOWN, error); + } + }); + + // 交叉混音到目标音频 + ipcMain.handle( + "player:crossfadeTo", + async (_event, source: string, options: CrossfadeOptions = {}) => { + const authoritative = options.meta ?? null; + const isRemote = authoritative != null && authoritative.source !== "local"; + const seq = ++loadSeq; + try { + const inst = getExtendedPlayer(); + const meta = await inst.crossfadeTo(source, { + durationMs: options.durationMs ?? 8000, + startSeekMs: options.startSeekMs ?? 0, + initialRate: options.initialRate ?? 1, + mixType: options.mixType ?? "default", + }); + const durationMs = toMs(meta.duration); + const commitDelayMs = Math.max( + 0, + Math.floor(options.uiSwitchDelayMs ?? (options.durationMs ?? 8000) / 2), + ); + if (commitDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, commitDelayMs)); + } + if (seq !== loadSeq) { + return fail(ErrorCode.LOAD_SUPERSEDED); + } + const loadResult = toLoadResult(meta, isRemote); + const commitEvent = { + type: "transitionCommit", + data: { source, duration: durationMs, result: loadResult }, + }; + sendToMain("player:event", commitEvent); + wsBroadcast(commitEvent); + const fallbackTitle = meta.title || source.split(/[/\\]/).pop() || source; + const displayTitle = authoritative?.title ?? fallbackTitle; + const displayArtist = authoritative + ? formatArtists(authoritative.artists ?? []) + : formatArtists(parseArtists(meta.artist ?? "")); + const displayAlbum = authoritative?.album?.name ?? parseAlbum(meta.album ?? "")?.name ?? ""; + const remoteCover = + authoritative && authoritative.source !== "local" + ? (authoritative.coverOriginal ?? authoritative.cover) + : undefined; + const coverUrl = remoteCover && /^https?:\/\//i.test(remoteCover) ? remoteCover : undefined; + const localCover = isRemote ? null : (inst.getCoverRaw() ?? null); + const header = displayArtist ? `${displayTitle} - ${displayArtist}` : displayTitle; + mediaService.setMetadata({ + title: displayTitle, + artist: displayArtist, + album: displayAlbum, + coverData: localCover ?? undefined, + coverUrl, + durationMs, + }); + mediaService.setPlayState({ status: "Playing" }); + getMainWindow()?.setTitle(header || appName); + setTraySongName(header || appName); + setTrayPlayState("playing"); + if (!isRemote) setTaskbarThumbnailCover(meta.cover); + const primaryArtist = + authoritative?.artists?.[0]?.name ?? + parseArtists(meta.artist ?? "")[0]?.name ?? + displayArtist; + lastfm.onTrackLoaded({ + title: displayTitle, + artist: primaryArtist, + album: displayAlbum, + durationMs, + autoPlay: true, + }); + neteaseScrobble.onTrackLoaded(authoritative, durationMs, true); + if (coverUrl) { + void fetchBytes(coverUrl).then((buf) => { + if (!buf) return; + if (seq !== loadSeq) return; + mediaService.setMetadata({ + title: displayTitle, + artist: displayArtist, + album: displayAlbum, + coverData: buf, + coverUrl, + durationMs, + }); + setTaskbarThumbnailCover(buf); + }); + } + return { success: true, data: loadResult }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("已被更新的 load 取代")) { + return fail(ErrorCode.LOAD_SUPERSEDED); + } + return fail( + source.startsWith("http") ? ErrorCode.NETWORK_ERROR : ErrorCode.FILE_DECODE_ERROR, + error, + ); + } + }, + ); + + // 取消下一首预载 + ipcMain.handle("player:cancelPreload", () => { + try { + getExtendedPlayer().cancelPreload(); + return { success: true }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }); + + // 音频分析能力 + ipcMain.handle( + "player:analyzeAudioFile", + async (_event, path: string, maxAnalyzeTimeSec?: number) => { + try { + return { + success: true, + data: await runAnalysisWorker("analyzeAudioFile", [path, maxAnalyzeTimeSec]), + }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }, + ); + ipcMain.handle( + "player:analyzeAudioFileHead", + async (_event, path: string, maxAnalyzeTimeSec?: number) => { + try { + return { + success: true, + data: await runAnalysisWorker("analyzeAudioFileHead", [path, maxAnalyzeTimeSec]), + }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }, + ); + ipcMain.handle( + "player:suggestTransition", + async (_event, currentPath: string, nextPath: string) => { + try { + return { + success: true, + data: await runAnalysisWorker("suggestTransition", [currentPath, nextPath]), + }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }, + ); + ipcMain.handle("player:suggestLongMix", async (_event, currentPath: string, nextPath: string) => { + try { + return { + success: true, + data: await runAnalysisWorker("suggestLongMix", [currentPath, nextPath]), + }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }); + // 恢复播放 ipcMain.handle("player:play", async () => { try { @@ -306,6 +558,7 @@ export const registerPlayerIpc = (): void => { // 停止播放并释放资源 ipcMain.handle("player:stop", () => { try { + loadSeq++; getPlayer().stop(); return { success: true }; } catch (error) { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4450b846..62aed61a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -9,7 +9,7 @@ import type { PluginMatchCoverArgs, } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; -import type { LoadOptions, TrackSource } from "@shared/types/player"; +import type { CrossfadeOptions, LoadOptions, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; import type { TagEditRequest } from "@shared/types/tagEditor"; @@ -43,6 +43,25 @@ const api = { // 加载音频(本地路径或网络地址) load: (source: string, options?: LoadOptions) => ipcRenderer.invoke("player:load", source, options ?? {}), + // 静默预载下一首音频 + preload: (source: string) => ipcRenderer.invoke("player:preload", source), + // 交叉混音到目标音频 + crossfadeTo: (source: string, options?: CrossfadeOptions) => + ipcRenderer.invoke("player:crossfadeTo", source, options ?? {}), + // 分析完整音频特征 + analyzeAudioFile: (path: string, maxAnalyzeTimeSec?: number) => + ipcRenderer.invoke("player:analyzeAudioFile", path, maxAnalyzeTimeSec), + // 仅分析音频头部特征 + analyzeAudioFileHead: (path: string, maxAnalyzeTimeSec?: number) => + ipcRenderer.invoke("player:analyzeAudioFileHead", path, maxAnalyzeTimeSec), + // 计算两首歌的过渡建议 + suggestTransition: (currentPath: string, nextPath: string) => + ipcRenderer.invoke("player:suggestTransition", currentPath, nextPath), + // 计算两首歌的长段混音建议 + suggestLongMix: (currentPath: string, nextPath: string) => + ipcRenderer.invoke("player:suggestLongMix", currentPath, nextPath), + // 取消下一首预载 + cancelPreload: () => ipcRenderer.invoke("player:cancelPreload"), // 恢复播放 play: () => ipcRenderer.invoke("player:play"), // 暂停播放 diff --git a/native/audio-engine/Cargo.toml b/native/audio-engine/Cargo.toml index e428cd8c..65fe0963 100644 --- a/native/audio-engine/Cargo.toml +++ b/native/audio-engine/Cargo.toml @@ -26,6 +26,7 @@ image = { version = "0.25", default-features = false, features = [ "webp", ] } rustfft = "6" +symphonia = { version = "0.5", features = ["mp3", "flac", "aac", "isomp4", "alac", "vorbis", "wav"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = [ "env-filter", diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index 0604dfe5..a5a9546a 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -21,6 +21,12 @@ export declare class AudioPlayer { * 持锁阶段都是纯内存操作,主线程其它同步 NAPI 调用最多等几微秒,不会被 IO 卡住 */ load(source: string, autoPlay?: boolean): Promise + /** 预载下一首音频源。只保留一个预载槽,新预载会替换旧预载 */ + preload(source: string): Promise + /** 取消下一首预载 */ + cancelPreload(): void + /** 交叉混音到目标音频源,优先复用同源预载槽 */ + crossfadeTo(source: string, options?: JsCrossfadeOptions | undefined | null): Promise /** 恢复播放。如果已停止或播放结束,自动从头重新加载 */ play(): Promise /** 暂停播放 */ @@ -100,6 +106,55 @@ export declare class AudioPlayer { getPitchSync(): boolean } +export interface AdvancedTransition { + startTimeCurrent: number + startTimeNext: number + duration: number + pitch_shift_semitones: number + playback_rate: number + automation_current: Array + automation_next: Array + strategy: string +} + +export declare function analyzeAudioFile(path: string, maxAnalyzeTime?: number | undefined | null): AudioAnalysis | null + +export declare function analyzeAudioFileHead(path: string, maxAnalyzeTime?: number | undefined | null): AudioAnalysis | null + +export interface AudioAnalysis { + duration: number + bpm?: number + bpm_confidence?: number + fade_in_pos: number + fade_out_pos: number + first_beat_pos?: number + loudness?: number + drop_pos?: number + version: number + analyze_window: number + cut_in_pos?: number + cut_out_pos?: number + mix_center_pos: number + mix_start_pos: number + mix_end_pos: number + energy_profile: Array + vocal_in_pos?: number + vocal_out_pos?: number + vocal_last_in_pos?: number + outro_energy_level?: number + key_root?: number + key_mode?: number + key_confidence?: number + camelot_key?: string +} + +export interface AutomationPoint { + timeOffset: number + volume: number + lowCut: number + highCut: number +} + /** 取消正在进行的扫描任务 */ export declare function cancelScan(): void @@ -120,6 +175,18 @@ export interface JsAudioDevice { isDefault: boolean } +/** 交叉混音参数 */ +export interface JsCrossfadeOptions { + /** 过渡时长(毫秒) */ + durationMs?: number + /** 下一首起播位置(毫秒) */ + startSeekMs?: number + /** 下一首初始播放速度 */ + initialRate?: number + /** 混音策略类型 */ + mixType?: string +} + /** 一条外部歌词,返回给 JS 侧(仅格式和路径,内容按需加载) */ export interface JsExternalLyric { /** 格式(如 "lrc", "ttml", "yrc", "qrc") */ @@ -287,6 +354,21 @@ export declare function readTrackTags(path: string): Promise */ export declare function scanDirs(dirs: Array, callback: (event: JsScanEvent) => void, coverCacheDir?: string | undefined | null, incrementalData?: Array | undefined | null): void +export declare function suggestLongMix(currentPath: string, nextPath: string): AdvancedTransition | null + +export declare function suggestTransition(currentPath: string, nextPath: string): TransitionProposal | null + +export interface TransitionProposal { + duration: number + current_track_mix_out: number + next_track_mix_in: number + mix_type: string + filter_strategy: string + compatibility_score: number + key_compatible: boolean + bpm_compatible: boolean +} + /** * 批量写入标签(异步),逐项返回结果,单项失败不中断整批。 * 替换封面时会作废旧缩略图缓存,写后按扫描同等规则重新生成 diff --git a/native/audio-engine/src/analysis.rs b/native/audio-engine/src/analysis.rs new file mode 100644 index 00000000..352613bc --- /dev/null +++ b/native/audio-engine/src/analysis.rs @@ -0,0 +1,1312 @@ +#![allow( + clippy::unreadable_literal, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::too_many_arguments, + clippy::similar_names, + clippy::inline_always, + clippy::needless_continue +)] + +use napi_derive::napi; +use rustfft::num_complex::Complex32; +use rustfft::FftPlanner; +use std::fs::File; +use std::path::PathBuf; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{Decoder, DecoderOptions}; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::core::sample::i24; +use symphonia::core::units::Time; + +// --- Data Structures (API) --- + +#[napi(object)] +pub struct AudioAnalysis { + pub duration: f64, + pub bpm: Option, + #[napi(js_name = "bpm_confidence")] + pub bpm_confidence: Option, + #[napi(js_name = "fade_in_pos")] + pub fade_in_pos: f64, + #[napi(js_name = "fade_out_pos")] + pub fade_out_pos: f64, + #[napi(js_name = "first_beat_pos")] + pub first_beat_pos: Option, + pub loudness: Option, // LUFS + #[napi(js_name = "drop_pos")] + pub drop_pos: Option, // Chorus/Drop start + pub version: i32, + #[napi(js_name = "analyze_window")] + pub analyze_window: f64, + #[napi(js_name = "cut_in_pos")] + pub cut_in_pos: Option, + #[napi(js_name = "cut_out_pos")] + pub cut_out_pos: Option, + #[napi(js_name = "mix_center_pos")] + pub mix_center_pos: f64, + #[napi(js_name = "mix_start_pos")] + pub mix_start_pos: f64, + #[napi(js_name = "mix_end_pos")] + pub mix_end_pos: f64, + #[napi(js_name = "energy_profile")] + pub energy_profile: Vec, + #[napi(js_name = "vocal_in_pos")] + pub vocal_in_pos: Option, + #[napi(js_name = "vocal_out_pos")] + pub vocal_out_pos: Option, + #[napi(js_name = "vocal_last_in_pos")] + pub vocal_last_in_pos: Option, + #[napi(js_name = "outro_energy_level")] + pub outro_energy_level: Option, + #[napi(js_name = "key_root")] + pub key_root: Option, + #[napi(js_name = "key_mode")] + pub key_mode: Option, + #[napi(js_name = "key_confidence")] + pub key_confidence: Option, + #[napi(js_name = "camelot_key")] + pub camelot_key: Option, +} + +#[napi(object)] +pub struct TransitionProposal { + pub duration: f64, + #[napi(js_name = "current_track_mix_out")] + pub current_track_mix_out: f64, + #[napi(js_name = "next_track_mix_in")] + pub next_track_mix_in: f64, + #[napi(js_name = "mix_type")] + pub mix_type: String, + #[napi(js_name = "filter_strategy")] + pub filter_strategy: String, + #[napi(js_name = "compatibility_score")] + pub compatibility_score: f64, + #[napi(js_name = "key_compatible")] + pub key_compatible: bool, + #[napi(js_name = "bpm_compatible")] + pub bpm_compatible: bool, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AutomationPoint { + pub time_offset: f64, + pub volume: f64, + pub low_cut: f64, + pub high_cut: f64, +} + +#[napi(object)] +pub struct AdvancedTransition { + pub start_time_current: f64, + pub start_time_next: f64, + pub duration: f64, + #[napi(js_name = "pitch_shift_semitones")] + pub pitch_shift_semitones: i32, + #[napi(js_name = "playback_rate")] + pub playback_rate: f64, + #[napi(js_name = "automation_current")] + pub automation_current: Vec, + #[napi(js_name = "automation_next")] + pub automation_next: Vec, + pub strategy: String, +} + +// --- Constants --- + +const ENV_RATE: f64 = 50.0; +const WINDOW_SIZE_MS: usize = 20; +const ANALYSIS_VERSION: i32 = 13; +const DEFAULT_SAMPLE_RATE: u32 = 44100; + +// Key Detection Constants +const FFT_FRAME_SIZE: usize = 4096; +const FFT_STEP: usize = 1024; +const MIN_FREQ: f32 = 80.0; +const MAX_FREQ: f32 = 5000.0; +const HAMMING_ALPHA: f32 = 0.54; +const HAMMING_BETA: f32 = 0.46; + +// BPM Detection Constants +const BPM_MIN_LAG: usize = 15; // ~180 BPM at 50Hz +const BPM_MAX_LAG: usize = 55; // ~55 BPM at 50Hz +const SILENCE_THRESH_DB: f32 = -48.0; +const VOCAL_LPF_FREQ: f32 = 3000.0; +const VOCAL_HPF_FREQ: f32 = 200.0; +const LOW_LPF_FREQ: f32 = 150.0; + +// Filter Coefficients (ITU-R BS.1770-4) +const LOUDNESS_PRE_FILTER: [f64; 5] = [ + 1.53512485958697, + -2.69169618940638, + 1.19839281085285, + -1.69065929318241, + 0.73248077421585, +]; +const LOUDNESS_RLB_FILTER: [f64; 5] = [1.0, -2.0, 1.0, -1.99004745483398, 0.99007225036621]; + +// --- DSP Filters --- + +struct BiquadFilter { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + z1: f64, + z2: f64, +} + +impl BiquadFilter { + const fn new(coeffs: [f64; 5]) -> Self { + Self { + b0: coeffs[0], + b1: coeffs[1], + b2: coeffs[2], + a1: coeffs[3], + a2: coeffs[4], + z1: 0.0, + z2: 0.0, + } + } + + fn process(&mut self, input: f64) -> f64 { + let output = input.mul_add(self.b0, self.z1); + self.z1 = self.a1.mul_add(-output, input.mul_add(self.b1, self.z2)); + self.z2 = input.mul_add(self.b2, -(self.a2 * output)); + output + } +} + +// Generic First Order Filter (LPF/HPF) +struct FirstOrderFilter { + prev_x: f32, + prev_y: f32, + alpha: f32, + is_high_pass: bool, +} + +impl FirstOrderFilter { + fn new(sample_rate: u32, cutoff: f32, is_high_pass: bool) -> Self { + let dt = 1.0 / sample_rate as f32; + let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff); + let alpha = if is_high_pass { + rc / (rc + dt) + } else { + dt / (rc + dt) + }; + Self { + prev_x: 0.0, + prev_y: 0.0, + alpha, + is_high_pass, + } + } + + fn process(&mut self, x: f32) -> f32 { + let y = if self.is_high_pass { + self.alpha * (self.prev_y + x - self.prev_x) + } else { + self.alpha.mul_add(x - self.prev_y, self.prev_y) + }; + self.prev_x = x; + self.prev_y = y; + y + } +} + +struct VocalFilter { + lpf: FirstOrderFilter, + hpf: FirstOrderFilter, +} + +impl VocalFilter { + fn new(sample_rate: u32) -> Self { + Self { + lpf: FirstOrderFilter::new(sample_rate, VOCAL_LPF_FREQ, false), + hpf: FirstOrderFilter::new(sample_rate, VOCAL_HPF_FREQ, true), + } + } + fn process(&mut self, x: f32) -> f32 { + self.lpf.process(self.hpf.process(x)) + } +} + +struct LoudnessMeter { + pre_filter: Vec, + rlb_filter: Vec, + sum_sq: f64, + count: u64, +} + +impl LoudnessMeter { + fn new(channels: usize) -> Self { + let mut pre_filter = Vec::with_capacity(channels); + let mut rlb_filter = Vec::with_capacity(channels); + for _ in 0..channels { + pre_filter.push(BiquadFilter::new(LOUDNESS_PRE_FILTER)); + rlb_filter.push(BiquadFilter::new(LOUDNESS_RLB_FILTER)); + } + Self { + pre_filter, + rlb_filter, + sum_sq: 0.0, + count: 0, + } + } + + fn process(&mut self, channels: &[f32]) { + for (i, &sample) in channels.iter().enumerate() { + if i >= self.pre_filter.len() { + break; + } + let s = self.rlb_filter[i].process(self.pre_filter[i].process(f64::from(sample))); + self.sum_sq += s * s; + } + self.count += 1; + } + + fn get_lufs(&self) -> f64 { + if self.count == 0 { + return -70.0; + } + let mean_sq = self.sum_sq / self.count as f64; + if mean_sq <= 0.0 { + -70.0 + } else { + 10.0f64.mul_add(mean_sq.log10(), -0.691) + } + } +} + +// --- Analysis Core --- + +struct EnvelopeAccumulator { + sum_sq: f32, + count: usize, + window_size: usize, +} + +impl EnvelopeAccumulator { + const fn new(window_size: usize) -> Self { + Self { + sum_sq: 0.0, + count: 0, + window_size, + } + } + + fn process(&mut self, sample: f32) -> Option { + self.sum_sq += sample * sample; + self.count += 1; + if self.count >= self.window_size { + let rms = (self.sum_sq / self.window_size as f32).sqrt(); + self.sum_sq = 0.0; + self.count = 0; + Some(rms) + } else { + None + } + } +} + +#[derive(Default)] +struct AnalysisSegment { + envelope: Vec, + low_envelope: Vec, + vocal_ratio: Vec, +} + +struct TrackAnalyzer { + path: PathBuf, + max_analyze_time: f64, + include_tail: bool, + + head: AnalysisSegment, + tail: AnalysisSegment, + head_pcm: Vec, // For key detection + + duration: f64, + sample_rate: u32, + + // Internal state + loudness_meter: LoudnessMeter, +} + +impl TrackAnalyzer { + fn new(path: String, max_time: Option, include_tail: bool) -> Self { + Self { + path: PathBuf::from(path), + max_analyze_time: max_time.unwrap_or(60.0).clamp(5.0, 300.0), + include_tail, + head: AnalysisSegment::default(), + tail: AnalysisSegment::default(), + head_pcm: Vec::new(), + duration: 0.0, + sample_rate: DEFAULT_SAMPLE_RATE, + loudness_meter: LoudnessMeter::new(2), // Re-init on analyze + } + } + + fn analyze(&mut self) -> Option { + let src = File::open(&self.path).ok()?; + let mss = MediaSourceStream::new(Box::new(src), MediaSourceStreamOptions::default()); + let mut hint = Hint::new(); + if let Some(ext) = self.path.extension().and_then(|s| s.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .ok()?; + let mut format = probed.format; + let track = format.default_track()?; + let track_id = track.id; + let params = &track.codec_params; + + self.sample_rate = params.sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE); + self.loudness_meter = LoudnessMeter::new(2); // Assume stereo max for now + + // Duration estimation + let time_base = params.time_base; + let n_frames = params.n_frames; + let estimated_duration = match (n_frames, time_base) { + (Some(n), Some(tb)) => { + let t = tb.calc_time(n); + Some(t.seconds as f64 + t.frac) + } + _ => None, + }; + + let mut decoder = symphonia::default::get_codecs() + .make(params, &DecoderOptions::default()) + .ok()?; + + // Phase 1: Head + self.process_segment(&mut format, &mut decoder, track_id, true, time_base)?; + + // Phase 2: Tail + if self.include_tail { + if let Some(tot) = estimated_duration { + // If total duration is significantly longer than what we analyzed + max_analyze_time + // We only jump if there is unanalyzed gap. + // Actually, logic is: analyze tail if song is long enough. + if tot > self.max_analyze_time * 2.0 { + let seek_target = tot - self.max_analyze_time; + let seek_time = Time::from(seek_target); + if format + .seek( + SeekMode::Coarse, + SeekTo::Time { + time: seek_time, + track_id: Some(track_id), + }, + ) + .is_ok() + { + // Reset duration to correct time after seek? + // Actually duration tracking inside process_segment handles packets. + // But we need to ensure we don't overwrite self.duration with wrong values if packets are weird. + // Wait, process_segment updates self.duration from packet timestamp. + // That is correct. + self.process_segment( + &mut format, + &mut decoder, + track_id, + false, + time_base, + )?; + } + } + } + } + + Some(self.finalize_analysis()) + } + + fn process_segment( + &mut self, + format: &mut Box, + decoder: &mut Box, + track_id: u32, + is_head: bool, + time_base: Option, + ) -> Option<()> { + let window_size = (self.sample_rate as usize * WINDOW_SIZE_MS) / 1000; + if window_size == 0 { + return None; + } + + let mut acc_env = EnvelopeAccumulator::new(window_size); + let mut acc_low = EnvelopeAccumulator::new(window_size); + let mut acc_vocal = EnvelopeAccumulator::new(window_size); + + let mut vocal_filter = VocalFilter::new(self.sample_rate); + let mut lpf = FirstOrderFilter::new(self.sample_rate, LOW_LPF_FREQ, false); + + let key_max_samples = + (f64::from(self.sample_rate) * self.max_analyze_time.min(30.0)) as usize; + let mut processed_duration_local = 0.0; // For fallback if time_base missing + + let segment = if is_head { + &mut self.head + } else { + &mut self.tail + }; + let loudness_meter = &mut self.loudness_meter; + let head_pcm = &mut self.head_pcm; + + // Use a small buffer to hold channel data to avoid allocation per sample + // Max 8 channels supported + let mut frame_buf = [0.0f32; 8]; + + while let Ok(packet) = format.next_packet() { + if packet.track_id() != track_id { + continue; + } + + // Timestamp handling + let packet_time = if let Some(tb) = time_base { + let t = tb.calc_time(packet.ts()); + t.seconds as f64 + t.frac + } else { + // If we sought, this local duration is wrong for absolute time, + // but if time_base is missing, seeking is likely impossible/unreliable anyway. + if !is_head { + // If no time_base, we can't really do tail analysis properly via seek. + // We just continue. + } + self.duration + processed_duration_local + }; + + self.duration = packet_time; + + // Stop condition + if is_head && packet_time > self.max_analyze_time { + break; + } + + // Decode + let Ok(decoded) = decoder.decode(&packet) else { + continue; + }; + + let spec = *decoded.spec(); + let frames = decoded.frames(); + let channels = spec.channels.count().min(8); + + if time_base.is_none() { + processed_duration_local += frames as f64 / f64::from(self.sample_rate); + } + + let capture_pcm = is_head && head_pcm.len() < key_max_samples; + + // Process Frames + macro_rules! process_buffer { + ($buf:expr, $convert:expr) => { + for i in 0..frames { + let mut sum = 0.0; + for c in 0..channels { + let s = $convert($buf.chan(c)[i]); + frame_buf[c] = s; + sum += s; + } + Self::process_sample_static( + loudness_meter, + head_pcm, + sum, + channels, + &frame_buf[..channels], + &mut acc_env, + &mut acc_low, + &mut acc_vocal, + &mut vocal_filter, + &mut lpf, + segment, + capture_pcm, + ); + } + }; + } + + match decoded { + AudioBufferRef::F32(buf) => process_buffer!(buf, |s: f32| s), + AudioBufferRef::U8(buf) => { + process_buffer!(buf, |s: u8| (f32::from(s) - 128.0) / 128.0) + } + AudioBufferRef::S16(buf) => process_buffer!(buf, |s: i16| f32::from(s) / 32768.0), + AudioBufferRef::S24(buf) => process_buffer!(buf, |s: i24| s.0 as f32 / 8388608.0), + AudioBufferRef::S32(buf) => process_buffer!(buf, |s: i32| s as f32 / 2147483648.0), + _ => continue, + } + } + Some(()) + } + + // Static helper to avoid double borrow of self + #[inline(always)] + fn process_sample_static( + loudness_meter: &mut LoudnessMeter, + head_pcm: &mut Vec, + sum: f32, + channels: usize, + frame_buf: &[f32], + acc_env: &mut EnvelopeAccumulator, + acc_low: &mut EnvelopeAccumulator, + acc_vocal: &mut EnvelopeAccumulator, + vocal_filter: &mut VocalFilter, + lpf: &mut FirstOrderFilter, + segment: &mut AnalysisSegment, + capture_pcm: bool, + ) { + loudness_meter.process(frame_buf); + + let val = sum / channels as f32; + let vocal = vocal_filter.process(val); + let low = lpf.process(val); + + if capture_pcm { + head_pcm.push(val); + } + + if let Some(rms) = acc_env.process(val) { + segment.envelope.push(rms); + } + if let Some(rms_low) = acc_low.process(low) { + segment.low_envelope.push(rms_low); + } + if let Some(rms_vocal) = acc_vocal.process(vocal) { + let base = *segment.envelope.last().unwrap_or(&1.0); + segment + .vocal_ratio + .push(if base > 0.0001 { rms_vocal / base } else { 0.0 }); + } + } + + fn finalize_analysis(&self) -> AudioAnalysis { + let (fade_in, fade_out) = detect_silence( + &self.head.envelope, + &self.tail.envelope, + self.duration, + ENV_RATE, + SILENCE_THRESH_DB, + ); + let (bpm, bpm_conf, first_beat) = + detect_bpm(&self.head.envelope, &self.head.low_envelope, ENV_RATE); + let (key_root, key_mode, key_conf) = detect_key(&self.head_pcm, self.sample_rate); + let drop_pos = detect_drop(&self.head.envelope, ENV_RATE); + + let (vocal_in, vocal_out, vocal_last_in) = detect_vocals( + &self.head.envelope, + &self.head.vocal_ratio, + &self.tail.envelope, + &self.tail.vocal_ratio, + self.duration, + ENV_RATE, + fade_in, + fade_out, + ); + + let smart_cut_out = calculate_smart_cut_out( + bpm, + first_beat, + bpm_conf, + vocal_out, + fade_in, + fade_out, + self.duration, + ); + + let smart_cut_in = + calculate_smart_cut_in(bpm, first_beat, bpm_conf, vocal_in.or(drop_pos), fade_in); + + let mix_center = smart_cut_out.min(self.duration); + let mix_duration = bpm.map_or(20.0, |b| (240.0 / b * 8.0).clamp(15.0, 30.0)); + let mix_start = (mix_center - mix_duration / 2.0).max(0.0); + let mix_end = (mix_center + mix_duration / 2.0).min(self.duration); + + // Energy Profile + let profile_rate = 10.0; + let profile_len = ((self.duration * profile_rate).ceil() as usize).max(1); + let mut energy_profile = vec![0.0; profile_len]; + fill_energy_profile( + &mut energy_profile, + &self.head.envelope, + 0.0, + ENV_RATE, + profile_rate, + ); + if !self.tail.envelope.is_empty() { + let tail_start = (self.duration - self.tail.envelope.len() as f64 / ENV_RATE).max(0.0); + fill_energy_profile( + &mut energy_profile, + &self.tail.envelope, + tail_start, + ENV_RATE, + profile_rate, + ); + } + + AudioAnalysis { + duration: self.duration, + bpm, + bpm_confidence: bpm_conf, + fade_in_pos: fade_in, + fade_out_pos: if self.include_tail { + fade_out + } else { + self.duration + }, + first_beat_pos: first_beat, + loudness: Some(self.loudness_meter.get_lufs()), + drop_pos, + version: ANALYSIS_VERSION, + analyze_window: self.max_analyze_time, + cut_in_pos: Some(smart_cut_in), + cut_out_pos: if self.include_tail { + Some(smart_cut_out) + } else { + None + }, + mix_center_pos: mix_center, + mix_start_pos: mix_start, + mix_end_pos: mix_end, + energy_profile, + vocal_in_pos: vocal_in, + vocal_out_pos: if self.include_tail { vocal_out } else { None }, + vocal_last_in_pos: if self.include_tail { + vocal_last_in + } else { + None + }, + outro_energy_level: calculate_outro_energy(&self.tail.envelope, ENV_RATE), + key_root, + key_mode, + key_confidence: key_conf, + camelot_key: if let (Some(r), Some(m)) = (key_root, key_mode) { + get_camelot_key(r, m) + } else { + None + }, + } + } +} + +// --- Helpers --- + +fn fill_energy_profile( + profile: &mut [f64], + envelope: &[f32], + start_time: f64, + env_rate: f64, + profile_rate: f64, +) { + for (i, &val) in envelope.iter().enumerate() { + let time = start_time + i as f64 / env_rate; + let idx = (time * profile_rate) as usize; + if idx < profile.len() { + profile[idx] = profile[idx].max(f64::from(val)); + } + } +} + +fn detect_silence( + head: &[f32], + tail: &[f32], + duration: f64, + rate: f64, + db_thresh: f32, +) -> (f64, f64) { + let thresh = 10.0f32.powf(db_thresh / 20.0); + let fade_in = head + .iter() + .position(|&x| x > thresh) + .map_or(0.0, |i| i as f64 / rate); + + let fade_out = if tail.is_empty() { + head.iter() + .rposition(|&x| x > thresh) + .map_or(duration, |i| i as f64 / rate) + } else { + let tail_dur = tail.len() as f64 / rate; + let tail_start = (duration - tail_dur).max(0.0); + tail.iter() + .rposition(|&x| x > thresh) + .map_or(duration, |i| tail_start + (i + 1) as f64 / rate) + }; + (fade_in, fade_out) +} + +fn detect_drop(envelope: &[f32], rate: f64) -> Option { + let window_len = (2.0 * rate) as usize; + if envelope.len() < window_len * 2 { + return None; + } + + let mut max_ratio = 0.0; + let mut best_idx = 0; + let prev_len = (rate * 4.0) as usize; + + for i in prev_len..(envelope.len() - window_len) { + let prev_sum: f32 = envelope[i - prev_len..i].iter().sum(); + let next_sum: f32 = envelope[i..i + window_len].iter().sum(); + let prev_avg = prev_sum / prev_len as f32; + let next_avg = next_sum / window_len as f32; + + if prev_avg > 0.001 { + let ratio = next_avg / prev_avg; + if ratio > max_ratio { + max_ratio = ratio; + best_idx = i; + } + } + } + + if max_ratio > 1.5 { + Some(best_idx as f64 / rate) + } else { + None + } +} + +fn calculate_outro_energy(tail: &[f32], rate: f64) -> Option { + if tail.is_empty() { + return None; + } + let (_, local_out) = + detect_silence(tail, &[], tail.len() as f64 / rate, rate, SILENCE_THRESH_DB); + let end_idx = (local_out * rate) as usize; + let start_idx = end_idx.saturating_sub(500); // Last 10s active + if end_idx <= start_idx { + return None; + } + + let slice = &tail[start_idx..end_idx]; + let mean_sq: f32 = slice.iter().map(|&x| x * x).sum::() / slice.len() as f32; + if mean_sq > 0.0 { + Some(f64::from(20.0 * mean_sq.sqrt().log10())) + } else { + Some(-70.0) + } +} + +fn detect_vocals( + head_env: &[f32], + head_ratio: &[f32], + tail_env: &[f32], + tail_ratio: &[f32], + duration: f64, + rate: f64, + fade_in: f64, + fade_out: f64, +) -> (Option, Option, Option) { + let is_vocal = |ratio: f32, env: f32| ratio > 0.4 && env > 0.02; + + let vocal_in = head_ratio + .iter() + .zip(head_env.iter()) + .enumerate() + .skip((fade_in * rate) as usize) + .find(|(_, (&r, &e))| is_vocal(r, e)) + .map(|(i, _)| i as f64 / rate); + + let (scan_env, scan_ratio, base_time) = if tail_env.is_empty() { + (head_env, head_ratio, 0.0) + } else { + ( + tail_env, + tail_ratio, + (duration - tail_env.len() as f64 / rate).max(0.0), + ) + }; + + let end_limit_idx = ((fade_out - base_time) * rate).round() as usize; + let limit = end_limit_idx.min(scan_env.len()); + + let vocal_out = scan_ratio + .iter() + .zip(scan_env.iter()) + .take(limit) + .enumerate() + .rfind(|(_, (&r, &e))| is_vocal(r, e)) + .map(|(i, _)| base_time + i as f64 / rate); + + let vocal_last_in = vocal_out.map(|vo| (vo - 5.0).max(fade_in)); + + (vocal_in, vocal_out, vocal_last_in) +} + +fn snap_time(time: f64, bpm: f64, first_beat: f64, grid: f64) -> f64 { + if bpm <= 0.0 { + return time; + } + let sec_per_beat = 60.0 / bpm; + let grid_sec = sec_per_beat * grid; + if grid_sec <= 0.0 { + return time; + } + + let units = (time - first_beat) / grid_sec; + let snapped = units.round().mul_add(grid_sec, first_beat); + if snapped < 0.0 { + first_beat + } else { + snapped + } +} + +fn calculate_smart_cut_out( + bpm: Option, + first_beat: Option, + conf: Option, + vocal_out: Option, + _fade_in: f64, + fade_out: f64, + duration: f64, +) -> f64 { + let search_end = vocal_out.map_or(fade_out, |vo| (vo + 40.0).min(fade_out)); + + if let (Some(b), Some(fb)) = (bpm, first_beat) { + if conf.unwrap_or(0.0) > 0.4 { + let snapped = snap_time(search_end, b, fb, 4.0); + if let Some(vo) = vocal_out { + if snapped < vo + 2.0 { + return snap_time(vo + 4.0, b, fb, 4.0).min(duration); + } + } + return snapped.min(duration); + } + } + search_end +} + +fn calculate_smart_cut_in( + bpm: Option, + first_beat: Option, + conf: Option, + anchor: Option, + fade_in: f64, +) -> f64 { + let anchor = anchor.unwrap_or(fade_in); + if let (Some(b), Some(fb)) = (bpm, first_beat) { + if conf.unwrap_or(0.0) > 0.4 { + let sec_bar = 240.0 / b; + for bars in [32.0_f64, 16.0, 8.0] { + let t = bars.mul_add(-sec_bar, anchor); + if t > fade_in { + return snap_time(t, b, fb, 4.0); + } + } + } + } + fade_in +} + +// --- BPM & Key Detection Wrappers --- + +fn detect_bpm(env: &[f32], _low_env: &[f32], rate: f64) -> (Option, Option, Option) { + if env.len() < 100 { + return (None, None, None); + } + + // Simple Flux + let flux: Vec = env.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect(); + if flux.len() < 110 { + return (None, None, None); + } + + // Autocorrelation (60-180 BPM -> 0.33-1.0s -> 16-50 samples) + let mut best_corr = 0.0; + let mut best_lag = 0; + + for lag in BPM_MIN_LAG..BPM_MAX_LAG { + let mut sum = 0.0; + for i in 0..(flux.len() - lag) { + sum += flux[i] * flux[i + lag]; + } + if sum > best_corr { + best_corr = sum; + best_lag = lag; + } + } + + if best_corr <= 0.001 { + return (None, None, None); + } + + let bpm = 60.0 / (best_lag as f64 / rate); + // Refine phase + let first_beat = (0..best_lag) + .max_by_key(|&phase| { + let mut e = 0.0; + let mut idx = phase; + while idx < flux.len() { + e += flux[idx]; + idx += best_lag; + } + (e * 1000.0) as i32 + }) + .map(|p| p as f64 / rate); + + (Some(bpm), Some(0.8), first_beat) +} + +fn detect_key(pcm: &[f32], sr: u32) -> (Option, Option, Option) { + if pcm.len() < FFT_FRAME_SIZE { + return (None, None, None); + } + + let frame_size = FFT_FRAME_SIZE; + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(frame_size); + let mut buffer = vec![Complex32::new(0.0, 0.0); frame_size]; + let mut chroma = [0.0f32; 12]; + + // Simple Hamming window + let window: Vec = (0..frame_size) + .map(|i| { + HAMMING_BETA.mul_add( + -(2.0 * std::f32::consts::PI * i as f32 / (frame_size - 1) as f32).cos(), + HAMMING_ALPHA, + ) + }) + .collect(); + + // Processing chunks + for chunk in pcm.chunks(frame_size).step_by(FFT_STEP) { + if chunk.len() < frame_size { + break; + } + for i in 0..frame_size { + buffer[i] = Complex32::new(chunk[i] * window[i], 0.0); + } + fft.process(&mut buffer); + + // Map bins to chroma + for (i, item) in buffer.iter().enumerate().take(frame_size / 2).skip(1) { + let hz = i as f32 * sr as f32 / frame_size as f32; + if !(MIN_FREQ..=MAX_FREQ).contains(&hz) { + continue; + } + let midi = 69.0 + 12.0 * (hz / 440.0).ln() / std::f32::consts::LN_2; + let pc = (midi.round() as usize) % 12; + let mag = item.norm_sqr(); + chroma[pc] += mag; + } + } + + // Correlation with Major/Minor profiles (Krumhansl-Schmuckler) + let major = [ + 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, + ]; + let minor = [ + 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, + ]; + + let sum_sq: f32 = chroma.iter().map(|x| x * x).sum(); + if sum_sq == 0.0 { + return (None, None, None); + } + let norm = sum_sq.sqrt(); + for x in &mut chroma { + *x /= norm; + } + + let mut best_score = -1.0; + let mut best_root = 0; + let mut best_mode = 0; // 0=Maj, 1=Min + + for root in 0..12 { + let mut s_maj = 0.0; + let mut s_min = 0.0; + for (i, &c) in chroma.iter().enumerate() { + let idx = (i + 12 - root) % 12; + s_maj += c * major[idx] as f32; + s_min += c * minor[idx] as f32; + } + if s_maj > best_score { + best_score = s_maj; + best_root = root; + best_mode = 0; + } + if s_min > best_score { + best_score = s_min; + best_root = root; + best_mode = 1; + } + } + + if best_score > 0.0 { + (Some(best_root as i32), Some(best_mode), Some(0.8)) + } else { + (None, None, None) + } +} + +// --- Transition Logic --- + +#[derive(Debug)] +struct MixStrategy { + name: &'static str, + filter: &'static str, + bars: f64, + req_key: bool, + req_bpm: bool, +} + +impl MixStrategy { + const fn new( + name: &'static str, + filter: &'static str, + bars: f64, + req_bpm: bool, + req_key: bool, + ) -> Self { + Self { + name, + filter, + bars, + req_key, + req_bpm, + } + } +} + +const STRATEGIES: &[MixStrategy] = &[ + MixStrategy::new( + "Harmonic Deep Blend", + "Eq Swap (Bass/Mid)", + 32.0, + true, + true, + ), + MixStrategy::new("Long Filter Blend", "Bass Swap / LPF", 32.0, true, false), + MixStrategy::new("Standard Blend", "Eq Mixing", 16.0, true, true), + MixStrategy::new("Filter Blend", "Bass Cut Out", 16.0, true, false), + MixStrategy::new("Short Blend", "Wash Out / Echo", 8.0, false, false), + MixStrategy::new("Quick Blend", "Quick Fade", 4.0, true, false), +]; + +#[napi] +pub fn suggest_transition(current_path: String, next_path: String) -> Option { + let cur = TrackAnalyzer::new(current_path, None, true).analyze()?; + let next = TrackAnalyzer::new(next_path, Some(120.0), false).analyze()?; + + let bpm_a = cur.bpm.unwrap_or(128.0); + let bpm_b = next.bpm.unwrap_or(128.0); + let bpm_compatible = (bpm_a - bpm_b).abs() / bpm_a < 0.06; + let key_compatible = + is_camelot_compatible(cur.camelot_key.as_deref(), next.camelot_key.as_deref()); + + let cur_out = cur.cut_out_pos.unwrap_or(cur.fade_out_pos); + let next_in = next.first_beat_pos.unwrap_or(0.0); + let next_intro_len = next.vocal_in_pos.unwrap_or(30.0) - next_in; + + let sec_per_bar = 240.0 / bpm_a; + + // 1. Try Standard Strategies + for s in STRATEGIES { + if s.req_bpm && !bpm_compatible { + continue; + } + if s.req_key && !key_compatible { + continue; + } + + let dur = s.bars * sec_per_bar; + if next_intro_len < dur { + continue; + } // Next track intro too short + + // Check if Current track has space + let start = cur_out - dur; // Simple backward calculation + if start < cur.mix_center_pos - 30.0 { + continue; + } // Too far back? + + // Success + return Some(TransitionProposal { + duration: dur, + current_track_mix_out: start, + next_track_mix_in: next_in, + mix_type: format!("{} ({} Bars)", s.name, s.bars), + filter_strategy: s.filter.to_string(), + compatibility_score: 0.9, + key_compatible, + bpm_compatible, + }); + } + + // 2. Fallback: Aggressive Bass Swap + if bpm_compatible { + let dur = 16.0 * sec_per_bar; + if cur.duration - cur_out > dur { + return Some(TransitionProposal { + duration: dur, + current_track_mix_out: cur_out - dur, + next_track_mix_in: next_in, + mix_type: "Aggressive Bass Swap".to_string(), + filter_strategy: "Bass Swap".to_string(), + compatibility_score: 0.7, + key_compatible, + bpm_compatible, + }); + } + } + + // 3. Fallback: Echo Out + Some(TransitionProposal { + duration: sec_per_bar * 4.0, + current_track_mix_out: cur_out, + next_track_mix_in: next_in, + mix_type: "Echo Out".to_string(), + filter_strategy: "Echo Freeze".to_string(), + compatibility_score: 0.5, + key_compatible, + bpm_compatible, + }) +} + +#[napi] +pub fn suggest_long_mix(current_path: String, next_path: String) -> Option { + let cur = TrackAnalyzer::new(current_path, None, true).analyze()?; + let next = TrackAnalyzer::new(next_path, Some(180.0), false).analyze()?; + + let bpm_a = cur.bpm.unwrap_or(128.0); + let bpm_b = next.bpm.unwrap_or(128.0); + let playback_rate = bpm_a / bpm_b; + + let target_bars = 32.0; + let sec_per_bar = 240.0 / bpm_a; + let duration = target_bars * sec_per_bar; + + // Anchor: End of Current Bass -> Start of Next Bass (Drop) + let cur_end = cur.duration - 5.0; // Near end + let next_start = next + .drop_pos + .or(next.vocal_in_pos) + .unwrap_or(32.0 * 240.0 / bpm_b); + + // Automation + let (auto_a, auto_b) = generate_bass_swap_automation(duration); + + Some(AdvancedTransition { + start_time_current: (cur_end - duration).max(0.0), + start_time_next: (next_start - duration / playback_rate).max(0.0), + duration, + pitch_shift_semitones: 0, // Simplified for now + playback_rate, + automation_current: auto_a, + automation_next: auto_b, + strategy: "Long Bass Swap".to_string(), + }) +} + +// --- Utils --- + +fn get_camelot_key(root: i32, mode: i32) -> Option { + let map = if mode == 0 { + // Major + [12, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5] + } else { + // Minor + [9, 4, 11, 6, 1, 8, 3, 10, 5, 12, 7, 2] + }; + let num = map.get(root as usize)?; + let letter = if mode == 0 { "B" } else { "A" }; + Some(format!("{num}{letter}")) +} + +fn is_camelot_compatible(key_a: Option<&str>, key_b: Option<&str>) -> bool { + let (Some(a), Some(b)) = (key_a, key_b) else { + return false; + }; + if a == b { + return true; + } + // Parse + let parse = |k: &str| -> Option<(i32, char)> { + let mode = k.chars().last()?; + let num = k[..k.len() - 1].parse().ok()?; + Some((num, mode)) + }; + let (Some((na, ma)), Some((nb, mb))) = (parse(a), parse(b)) else { + return false; + }; + + let diff = (na - nb).abs(); + (diff == 1 || diff == 11) && ma == mb +} + +fn generate_bass_swap_automation(dur: f64) -> (Vec, Vec) { + let mid = dur / 2.0; + let mut a = Vec::new(); + let mut b = Vec::new(); + + // A: 1.0 -> 1.0 (BassCut) -> 0.0 + // B: 0.0 -> 1.0 (BassCut) -> 1.0 + + a.push(AutomationPoint { + time_offset: 0.0, + volume: 1.0, + low_cut: 0.0, + high_cut: 0.0, + }); + a.push(AutomationPoint { + time_offset: mid, + volume: 0.9, + low_cut: 0.8, + high_cut: 0.0, + }); + a.push(AutomationPoint { + time_offset: dur, + volume: 0.0, + low_cut: 1.0, + high_cut: 0.0, + }); + + b.push(AutomationPoint { + time_offset: 0.0, + volume: 0.0, + low_cut: 1.0, + high_cut: 0.0, + }); + b.push(AutomationPoint { + time_offset: mid, + volume: 0.9, + low_cut: 0.8, + high_cut: 0.0, + }); + b.push(AutomationPoint { + time_offset: dur, + volume: 1.0, + low_cut: 0.0, + high_cut: 0.0, + }); + + (a, b) +} + +// --- Exports --- + +#[napi] +pub fn analyze_audio_file(path: String, max_analyze_time: Option) -> Option { + TrackAnalyzer::new(path, max_analyze_time, true).analyze() +} + +#[napi] +pub fn analyze_audio_file_head( + path: String, + max_analyze_time: Option, +) -> Option { + TrackAnalyzer::new(path, max_analyze_time, false).analyze() +} diff --git a/native/audio-engine/src/audio_output.rs b/native/audio-engine/src/audio_output.rs index dce822f7..d96e03ff 100644 --- a/native/audio-engine/src/audio_output.rs +++ b/native/audio-engine/src/audio_output.rs @@ -143,8 +143,8 @@ fn build_output_stream( .find(|d| d.name().map(|got| got == name).unwrap_or(false)) .with_context(|| format!("Output device '{}' not found", name))?; let sample_rate = device_sample_rate(&device); - let (stream, handle) = - OutputStream::try_from_device(&device).context("Failed to open named output device")?; + let (stream, handle) = OutputStream::try_from_device(&device) + .context("Failed to open named output device")?; Ok((stream, handle, sample_rate)) } None => open_default_stream(&host), diff --git a/native/audio-engine/src/decoder.rs b/native/audio-engine/src/decoder.rs index b5d440f9..00d16f25 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -78,7 +78,8 @@ pub fn start_decode( ) -> Result<(AudioMetadata, JoinHandle)> { // 播放重采样目标 = 输出设备原生采样率 let target_rate = shared.sample_rate(); - let (reader, player_resampler, fft_resampler, interrupt_flag) = open_source(source, target_rate)?; + let (reader, player_resampler, fft_resampler, interrupt_flag) = + open_source(source, target_rate)?; if let Some(ref flag) = interrupt_flag { shared.bind_interrupt(Arc::clone(flag)); } @@ -140,6 +141,31 @@ pub fn start_decode( Ok((metadata, handle)) } +/// 启动解码线程并从指定位置开始输出 +pub fn start_decode_at( + source: &str, + shared: Arc, + cover_cache_dir: Option<&str>, + position_secs: f64, +) -> Result<(AudioMetadata, JoinHandle)> { + let (metadata, handle) = start_decode(source, Arc::clone(&shared), cover_cache_dir)?; + if position_secs <= 0.0 { + return Ok((metadata, handle)); + } + shared.stop(); + shared.drain_buffer(); + let mut data = handle + .join() + .map_err(|_| anyhow::anyhow!("解码线程回收失败"))?; + data.reset_interrupt(); + if !data.seek(position_secs) { + anyhow::bail!("音频 seek 失败"); + } + shared.clear_stop(); + let handle = resume_decode(data, shared); + Ok((metadata, handle)) +} + /// 用已有的 DecoderData 继续解码(seek 后复用) pub fn resume_decode(data: DecoderData, shared: Arc) -> JoinHandle { if let Some(flag) = data.interrupt_handle() { diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 748b9397..d2010d2f 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -1,6 +1,7 @@ //! FFmpeg 音频解码 + rodio 播放 + FFT 频谱分析。 //! 通过 NAPI-RS 暴露给 Node.js,作为 Electron 主进程的原生模块。 +mod analysis; mod audio_output; mod decoder; mod equalizer; @@ -27,6 +28,7 @@ use napi_derive::napi; use parking_lot::Mutex; use tracing::{info, warn}; +pub use analysis::*; use player::{InnerPlayer, PlayerEvent, PlayerState, SeekTake}; /// async seek 阶段 2 的输出 @@ -114,6 +116,23 @@ pub struct JsMusicMetadata { pub cover: Option, } +/// 交叉混音参数 +#[napi(object)] +pub struct JsCrossfadeOptions { + /// 过渡时长(毫秒) + #[napi(js_name = "durationMs")] + pub duration_ms: Option, + /// 下一首起播位置(毫秒) + #[napi(js_name = "startSeekMs")] + pub start_seek_ms: Option, + /// 下一首初始播放速度 + #[napi(js_name = "initialRate")] + pub initial_rate: Option, + /// 混音策略类型 + #[napi(js_name = "mixType")] + pub mix_type: Option, +} + /// 音频输出设备信息 #[napi(object)] pub struct JsAudioDevice { @@ -303,6 +322,184 @@ impl AudioPlayer { } } + /// 预载下一首音频源。只保留一个预载槽,新预载会替换旧预载 + #[napi] + pub async fn preload(&self, source: String) -> Result { + use crate::shared::Shared; + + info!(source = %source, "预载音频源"); + let (old_prepared, token, cover_dir, normalization_enabled, output_sample_rate) = { + let mut player = self.inner.lock(); + let (old_prepared, token) = player.take_for_async_preload(); + player.ensure_output_pub().into_napi()?; + ( + old_prepared, + token, + player.cover_cache_dir().map(String::from), + player.is_normalization_enabled(), + player.output_sample_rate(), + ) + }; + + let shared = Shared::new(output_sample_rate, decoder::TARGET_CHANNELS); + shared.set_normalization_enabled(normalization_enabled); + let shared_for_decoder = Arc::clone(&shared); + let source_for_decoder = source.clone(); + + let (metadata, decode_handle) = tokio::task::spawn_blocking(move || { + if let Some(prepared) = old_prepared { + prepared.shared.stop(); + prepared.shared.drain_buffer(); + let _ = prepared.decoder_thread.join(); + } + decoder::start_decode( + &source_for_decoder, + shared_for_decoder, + cover_dir.as_deref(), + ) + }) + .await + .map_err(|e| Error::from_reason(format!("preload task join error: {e}")))? + .into_napi()?; + + let returned_meta = { + let mut player = self.inner.lock(); + player.commit_preloaded(token, source, metadata, decode_handle, shared) + }; + + match returned_meta { + Some(meta) => Ok(Self::meta_to_js(meta)), + None => Err(Error::from_reason(LOAD_SUPERSEDED_REASON)), + } + } + + /// 取消下一首预载 + #[napi] + pub fn cancel_preload(&self) { + self.inner.lock().cancel_preload(); + } + + /// 交叉混音到目标音频源,优先复用同源预载槽 + #[napi] + pub async fn crossfade_to( + &self, + source: String, + options: Option, + ) -> Result { + use crate::shared::Shared; + + let duration_ms = options + .as_ref() + .and_then(|o| o.duration_ms) + .filter(|v| v.is_finite() && *v >= 0.0) + .unwrap_or(8000.0) as u64; + let start_seek_secs = options + .as_ref() + .and_then(|o| o.start_seek_ms) + .filter(|v| v.is_finite() && *v > 0.0) + .map(|v| v / 1000.0) + .unwrap_or(0.0); + let initial_rate = options + .as_ref() + .and_then(|o| o.initial_rate) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(1.0) as f32; + let mix_type = options + .as_ref() + .and_then(|o| o.mix_type.as_deref()) + .unwrap_or("default"); + info!(source = %source, duration_ms, start_seek_secs, initial_rate, mix_type, "交叉混音到音频源"); + + let take = { + let mut player = self.inner.lock(); + player.take_for_async_crossfade(&source).into_napi()? + }; + + let (metadata, decode_handle, shared) = if let Some(prepared) = take.prepared { + if start_seek_secs > 0.0 { + let output_sample_rate = take.output_sample_rate; + let normalization_enabled = take.normalization_enabled; + let cover_dir = take.cover_dir.clone(); + let source_for_seek = source.clone(); + tokio::task::spawn_blocking(move || { + prepared.shared.stop(); + prepared.shared.drain_buffer(); + let mut data = prepared + .decoder_thread + .join() + .map_err(|_| Error::from_reason("preload decoder thread join error"))?; + data.reset_interrupt(); + if data.seek(start_seek_secs) { + prepared.shared.clear_stop(); + let handle = decoder::resume_decode(data, Arc::clone(&prepared.shared)); + Ok::<_, Error>((prepared.metadata, handle, prepared.shared)) + } else { + let shared = Shared::new(output_sample_rate, decoder::TARGET_CHANNELS); + shared.set_normalization_enabled(normalization_enabled); + let (metadata, handle) = decoder::start_decode_at( + &source_for_seek, + Arc::clone(&shared), + cover_dir.as_deref(), + start_seek_secs, + ) + .into_napi()?; + Ok::<_, Error>((metadata, handle, shared)) + } + }) + .await + .map_err(|e| Error::from_reason(format!("crossfade seek task join error: {e}")))?? + } else { + (prepared.metadata, prepared.decoder_thread, prepared.shared) + } + } else { + let shared = Shared::new(take.output_sample_rate, decoder::TARGET_CHANNELS); + shared.set_normalization_enabled(take.normalization_enabled); + let shared_for_decoder = Arc::clone(&shared); + let source_for_decoder = source.clone(); + let cover_dir = take.cover_dir.clone(); + let discarded_prepared = take.discarded_prepared; + let (metadata, decode_handle) = tokio::task::spawn_blocking(move || { + if let Some(prepared) = discarded_prepared { + prepared.shared.stop(); + prepared.shared.drain_buffer(); + let _ = prepared.decoder_thread.join(); + } + decoder::start_decode_at( + &source_for_decoder, + shared_for_decoder, + cover_dir.as_deref(), + start_seek_secs, + ) + }) + .await + .map_err(|e| Error::from_reason(format!("crossfade task join error: {e}")))? + .into_napi()?; + (metadata, decode_handle, shared) + }; + + let returned_meta = { + let mut player = self.inner.lock(); + player + .commit_crossfade( + take.token, + &source, + metadata, + decode_handle, + shared, + duration_ms, + start_seek_secs, + initial_rate, + mix_type, + ) + .into_napi()? + }; + + match returned_meta { + Some(meta) => Ok(Self::meta_to_js(meta)), + None => Err(Error::from_reason(LOAD_SUPERSEDED_REASON)), + } + } + /// 内部:将 AudioMetadata 转为 JS 结构 fn meta_to_js(meta: crate::shared::AudioMetadata) -> JsMusicMetadata { JsMusicMetadata { @@ -866,10 +1063,11 @@ pub struct JsTagWriteResult { #[napi] pub async fn make_image_thumbnail(data: Buffer, max_size: u32) -> Result { let bytes = data.to_vec(); - let thumb = tokio::task::spawn_blocking(move || metadata::make_thumbnail_jpeg(&bytes, max_size)) - .await - .map_err(|e| Error::from_reason(format!("缩略图任务失败: {e}")))? - .into_napi()?; + let thumb = + tokio::task::spawn_blocking(move || metadata::make_thumbnail_jpeg(&bytes, max_size)) + .await + .map_err(|e| Error::from_reason(format!("缩略图任务失败: {e}")))? + .into_napi()?; Ok(thumb.into()) } diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index 96180924..e0eaf8c1 100644 --- a/native/audio-engine/src/player.rs +++ b/native/audio-engine/src/player.rs @@ -12,7 +12,7 @@ use crate::audio_output::AudioOutput; use crate::decoder; use crate::equalizer::{Equalizer, EQ_BAND_COUNT}; use crate::fft::FftAnalyzer; -use crate::shared::{AudioMetadata, Shared}; +use crate::shared::{AudioMetadata, HighPassRole, Shared}; use crate::source::DecoderSource; use crate::tempo::StretchProcessor; @@ -58,6 +58,53 @@ fn fade_volume(sink: &Sink, from: f32, to: f32, duration_ms: u64, cancel: &Atomi } } +/// 同步淡出旧 sink、淡入新 sink;完成后释放旧解码资源 +fn crossfade_volume( + old: CrossfadeOldTrack, + new_sink: Arc, + target_volume: f32, + duration_ms: u64, + cancel: &AtomicBool, +) { + for h in [old.position_timer, old.fft_timer, old.fade_handle] + .into_iter() + .flatten() + { + let _ = h.join(); + } + + if duration_ms == 0 { + new_sink.set_volume(target_volume); + } else { + let step_duration = Duration::from_millis(duration_ms / u64::from(FADE_STEPS)); + for step in 1..=FADE_STEPS { + if cancel.load(Ordering::Relaxed) { + break; + } + let progress = step as f32 / FADE_STEPS as f32; + new_sink.set_volume(target_volume * progress); + if let Some(ref sink) = old.sink { + sink.set_volume(target_volume * (1.0 - progress)); + } + sleep_unless_stopped(cancel, step_duration); + } + } + + if let Some(sink) = old.sink { + sink.stop(); + } + if let Some(shared) = old.shared { + shared.stop(); + shared.drain_buffer(); + } + if let Some(handle) = old.decoder_thread { + let _ = handle.join(); + } + if !cancel.load(Ordering::Relaxed) { + new_sink.set_volume(target_volume); + } +} + /// 播放状态 #[derive(Clone, Copy, Debug, PartialEq)] pub enum PlayerState { @@ -75,6 +122,8 @@ pub struct InnerPlayer { /// 使用 Arc 包装,允许 fade 线程在 Mutex 外操作音量 sink: Option>, shared: Option>, + /// 下一首预载槽:只保留一个候选,切歌/停止/新预载会释放旧槽 + prepared: Option, /// 解码线程句柄,join 后可回收 DecoderData 复用于 seek decoder_thread: Option>, fft: Arc, @@ -123,6 +172,16 @@ pub struct InnerPlayer { /// commit_loaded 比对 token 与最新值,不一致则该次加载已被新加载取代,需丢弃 /// 用于防止快速切歌时旧 IO 完成后覆盖新音频的竞态 load_token: Arc, + /// preload 单调递增 token:取消预载时让在途 commit 失效 + preload_token: Arc, +} + +/// 已解码预热但尚未接入输出的下一曲 +pub struct PreparedTrack { + pub source: String, + pub shared: Arc, + pub decoder_thread: JoinHandle, + pub metadata: AudioMetadata, } /// 切换/seek 时要 join 的旧线程集合,全部挪到 spawn_blocking 工作线程 join, @@ -132,12 +191,18 @@ pub struct OldThreads { pub position_timer: Option>, pub fft_timer: Option>, pub fade_handle: Option>, + pub prepared: Option, } impl OldThreads { /// 在工作线程上 join 所有旧 timer/fade,返回旧解码线程 handle 供调用方继续使用 /// 忽略 join 错误:辅助线程 panic 不阻止新加载,主播放路径不依赖它们 pub fn join_aux(self) -> Option> { + if let Some(prepared) = self.prepared { + prepared.shared.stop(); + prepared.shared.drain_buffer(); + let _ = prepared.decoder_thread.join(); + } for h in [self.position_timer, self.fft_timer, self.fade_handle] .into_iter() .flatten() @@ -148,6 +213,26 @@ impl OldThreads { } } +/// crossfade 接管当前播放槽时取出的旧播放资源 +pub struct CrossfadeOldTrack { + pub sink: Option>, + pub shared: Option>, + pub decoder_thread: Option>, + pub position_timer: Option>, + pub fft_timer: Option>, + pub fade_handle: Option>, +} + +/// crossfade 前置阶段返回给 lib.rs 的参数 +pub struct CrossfadeTake { + pub token: u64, + pub prepared: Option, + pub discarded_prepared: Option, + pub cover_dir: Option, + pub normalization_enabled: bool, + pub output_sample_rate: u32, +} + /// async seek 阶段 1 的输出:带到工作线程做 join + ffmpeg seek + 重启解码 pub struct SeekTake { /// 所有旧线程 handle(工作线程 join) @@ -230,6 +315,7 @@ impl InnerPlayer { output, sink: None, shared: None, + prepared: None, decoder_thread: None, fft: Arc::new(FftAnalyzer::new(decoder::TARGET_SAMPLE_RATE)), audio_sample_rate: 0, @@ -258,6 +344,7 @@ impl InnerPlayer { initial_rate, ))), load_token: Arc::new(AtomicU64::new(0)), + preload_token: Arc::new(AtomicU64::new(0)), }) } @@ -303,6 +390,50 @@ impl InnerPlayer { } } + fn stop_prepared(prepared: PreparedTrack) { + prepared.shared.stop(); + prepared.shared.drain_buffer(); + let _ = prepared.decoder_thread.join(); + } + + /// 清理预载槽并使在途 preload 失效 + pub fn cancel_preload(&mut self) { + self.preload_token.fetch_add(1, Ordering::AcqRel); + if let Some(prepared) = self.prepared.take() { + Self::stop_prepared(prepared); + } + } + + /// 开始一次异步预载,返回旧预载槽供工作线程释放 + pub fn take_for_async_preload(&mut self) -> (Option, u64) { + let token = self.preload_token.fetch_add(1, Ordering::AcqRel) + 1; + (self.prepared.take(), token) + } + + /// 提交异步预载结果 + pub fn commit_preloaded( + &mut self, + token: u64, + source: String, + metadata: AudioMetadata, + decoder_thread: JoinHandle, + shared: Arc, + ) -> Option { + if token != self.preload_token.load(Ordering::Acquire) { + shared.stop(); + drop(decoder_thread); + return None; + } + let returned = metadata.clone(); + self.prepared = Some(PreparedTrack { + source, + shared, + decoder_thread, + metadata, + }); + Some(returned) + } + /// 启动非阻塞渐变(独立线程执行,不阻塞调用方)。 /// 完成回调仅在未被取消时执行 fn start_fade(&mut self, from: f32, to: f32, on_complete: Option>) { @@ -526,6 +657,7 @@ impl InnerPlayer { pub fn take_for_async_load(&mut self) -> (OldThreads, u64) { // 自增 token:本次 load 的标识;任何并发的更早 commit_loaded 比较时会发现不匹配 let token = self.load_token.fetch_add(1, Ordering::AcqRel) + 1; + self.preload_token.fetch_add(1, Ordering::AcqRel); // 发停止信号(原子写,纳秒级) if let Some(flag) = self.fade_cancel.take() { @@ -559,6 +691,7 @@ impl InnerPlayer { position_timer: self.position_timer_handle.take(), fft_timer: self.fft_timer_handle.take(), fade_handle: self.fade_handle.take(), + prepared: self.prepared.take(), }; (old_threads, token) } @@ -568,6 +701,148 @@ impl InnerPlayer { token == self.load_token.load(Ordering::Acquire) } + /// crossfade 前置:不停止当前播放,只取出匹配的预载槽或丢弃旧预载槽 + pub fn take_for_async_crossfade(&mut self, source: &str) -> Result { + let token = self.load_token.fetch_add(1, Ordering::AcqRel) + 1; + self.preload_token.fetch_add(1, Ordering::AcqRel); + self.ensure_output()?; + let prepared = match self.prepared.take() { + Some(prepared) if prepared.source == source => Some(prepared), + other => { + return Ok(CrossfadeTake { + token, + prepared: None, + discarded_prepared: other, + cover_dir: self.cover_cache_dir().map(String::from), + normalization_enabled: self.normalization_enabled, + output_sample_rate: self.output_sample_rate(), + }); + } + }; + Ok(CrossfadeTake { + token, + prepared, + discarded_prepared: None, + cover_dir: self.cover_cache_dir().map(String::from), + normalization_enabled: self.normalization_enabled, + output_sample_rate: self.output_sample_rate(), + }) + } + + fn take_old_for_crossfade(&mut self) -> CrossfadeOldTrack { + if let Some(flag) = self.fade_cancel.take() { + flag.store(true, Ordering::Relaxed); + } + if let Some(flag) = self.position_timer_stop.take() { + flag.store(true, Ordering::Relaxed); + } + if let Some(flag) = self.fft_timer_stop.take() { + flag.store(true, Ordering::Relaxed); + } + CrossfadeOldTrack { + sink: self.sink.take(), + shared: self.shared.take(), + decoder_thread: self.decoder_thread.take(), + position_timer: self.position_timer_handle.take(), + fft_timer: self.fft_timer_handle.take(), + fade_handle: self.fade_handle.take(), + } + } + + fn cloned_equalizer(&self, sample_rate: u32) -> Arc> { + let current = self.equalizer.lock(); + let mut eq = Equalizer::new(sample_rate); + eq.set_enabled(current.enabled()); + eq.set_band_gains(¤t.band_gains_db()); + eq.set_preamp_db(current.preamp_db()); + Arc::new(Mutex::new(eq)) + } + + fn cloned_tempo(&self, sample_rate: u32) -> Arc> { + let current = self.tempo.lock(); + let mut tempo = StretchProcessor::new(decoder::TARGET_CHANNELS, sample_rate); + tempo.set_speed(current.speed()); + tempo.set_pitch(current.pitch()); + tempo.set_pitch_sync(current.pitch_sync()); + Arc::new(Mutex::new(tempo)) + } + + /// 提交 crossfade:新槽接管当前播放,旧槽在后台淡出后释放 + pub fn commit_crossfade( + &mut self, + token: u64, + source: &str, + mut metadata: AudioMetadata, + decode_handle: JoinHandle, + shared: Arc, + duration_ms: u64, + start_seek_secs: f64, + initial_rate: f32, + mix_type: &str, + ) -> Result> { + if token != self.load_token.load(Ordering::Acquire) { + shared.stop(); + drop(decode_handle); + return Ok(None); + } + + self.configure_dsp_sample_rate(); + let sink = { + let output = self.ensure_output()?; + Arc::new(Sink::try_new(output.handle()).context("Failed to create audio sink")?) + }; + let old = self.take_old_for_crossfade(); + if mix_type == "bassSwap" { + if let Some(ref shared) = old.shared { + shared.set_high_pass_automation(HighPassRole::FadeOut, duration_ms); + } + shared.set_high_pass_automation(HighPassRole::FadeIn, duration_ms); + } + + let new_equalizer = self.cloned_equalizer(self.output_sample_rate()); + let new_tempo = self.cloned_tempo(self.output_sample_rate()); + new_tempo.lock().set_speed(initial_rate); + let decoder_source = DecoderSource::new( + Arc::clone(&shared), + Arc::clone(&self.fft), + Arc::clone(&new_equalizer), + Arc::clone(&new_tempo), + metadata.sample_rate, + metadata.channels, + ); + + sink.set_volume(0.0); + sink.append(decoder_source); + self.sink = Some(Arc::clone(&sink)); + self.shared = Some(Arc::clone(&shared)); + self.decoder_thread = Some(decode_handle); + self.seek_base = start_seek_secs; + self.current_source = Some(source.to_string()); + self.audio_sample_rate = metadata.sample_rate; + self.audio_channels = metadata.channels; + self.audio_duration = metadata.duration_secs; + self.cover_raw = metadata.cover_raw.take(); + self.equalizer = new_equalizer; + self.tempo = new_tempo; + + self.state = PlayerState::Playing; + self.emit(PlayerEvent::StateChanged { + state: PlayerState::Playing, + }); + self.start_position_timer(); + self.start_fft_timer(); + + let target_volume = self.target_volume; + let cancel = Arc::new(AtomicBool::new(false)); + self.fade_cancel = Some(Arc::clone(&cancel)); + let handle = thread::spawn(move || { + crossfade_volume(old, sink, target_volume, duration_ms, &cancel); + }); + self.fade_handle = Some(handle); + + Ok(Some(metadata)) + } + /// 给 lib.rs async seek 用:原子发出停止信号 + take 所有旧线程 handle(不 join) /// /// 返回 None 表示当前没有解码线程(空闲 / 已停止 / 正在异步加载被 load 取走), @@ -601,6 +876,7 @@ impl InnerPlayer { position_timer: self.position_timer_handle.take(), fft_timer: self.fft_timer_handle.take(), fade_handle: self.fade_handle.take(), + prepared: None, }; let (norm_enabled, norm_gain) = match self.shared.take() { @@ -917,6 +1193,7 @@ impl InnerPlayer { } fn stop_internal(&mut self) { + self.preload_token.fetch_add(1, Ordering::AcqRel); // 1. 取消渐变并等待渐变线程退出(释放 Arc) self.cancel_fade(); // 2. 停止定时器并等待线程退出(释放 Arc 和 Arc) @@ -934,6 +1211,9 @@ impl InnerPlayer { if let Some(handle) = self.decoder_thread.take() { let _ = handle.join(); } + if let Some(prepared) = self.prepared.take() { + Self::stop_prepared(prepared); + } // 6. 清空共享缓冲区(即使还有外部 Arc 引用,缓冲区数据也立即释放) if let Some(ref shared) = self.shared { shared.drain_buffer(); diff --git a/native/audio-engine/src/shared.rs b/native/audio-engine/src/shared.rs index 75276dd6..d6b569ec 100644 --- a/native/audio-engine/src/shared.rs +++ b/native/audio-engine/src/shared.rs @@ -6,6 +6,54 @@ use parking_lot::{Condvar, Mutex}; use crate::metadata::ExternalLyric; +#[derive(Clone, Copy)] +pub enum HighPassRole { + FadeOut, + FadeIn, +} + +#[derive(Clone, Copy)] +pub struct HighPassAutomation { + start_samples: u64, + sample_rate: u32, + channels: u16, + duration_ms: u64, + role: HighPassRole, +} + +impl HighPassAutomation { + pub(crate) fn cutoff_at(self, consumed_samples: u64) -> f32 { + const BYPASS_FREQ: f32 = 10.0; + const SWAP_FREQ: f32 = 400.0; + let elapsed_samples = consumed_samples.saturating_sub(self.start_samples); + let elapsed_ms = + elapsed_samples as f32 * 1000.0 / self.sample_rate as f32 / self.channels as f32; + let duration_ms = self.duration_ms.max(1) as f32; + let mid_ms = duration_ms * 0.5; + match self.role { + HighPassRole::FadeOut => { + if elapsed_ms >= mid_ms { + return SWAP_FREQ; + } + let t = (elapsed_ms / mid_ms.max(1.0)).clamp(0.0, 1.0); + BYPASS_FREQ + (SWAP_FREQ - BYPASS_FREQ) * t + } + HighPassRole::FadeIn => { + let release_ms = (duration_ms * 0.25).min(600.0); + let release_end = mid_ms + release_ms; + if elapsed_ms <= mid_ms { + return SWAP_FREQ; + } + if elapsed_ms >= release_end { + return BYPASS_FREQ; + } + let t = ((elapsed_ms - mid_ms) / release_ms.max(1.0)).clamp(0.0, 1.0); + SWAP_FREQ + (BYPASS_FREQ - SWAP_FREQ) * t + } + } + } +} + /// 解码后的 PCM 音频数据块 pub struct AudioChunk { /// 交错排列的 f32 播放样本(L R L R ...) @@ -40,6 +88,8 @@ pub struct Shared { /// stop() 触发时一并设为 true,让正在阻塞的 ffmpeg IO 立即返回 AVERROR_EXIT /// 否则 packets().next() 这种同步调用要等到 rw_timeout(15s)才能感知 stop interrupt_flag: Mutex>>, + /// Automix bass swap 高通滤波自动化,仅在 crossfade 期间启用 + high_pass_automation: Mutex>, } /// 共享缓冲区最大容量(背压阈值) @@ -64,6 +114,7 @@ impl Shared { normalization_gain: AtomicU32::new(1.0_f32.to_bits()), normalization_enabled: AtomicBool::new(false), interrupt_flag: Mutex::new(None), + high_pass_automation: Mutex::new(None), }) } @@ -109,6 +160,23 @@ impl Shared { self.samples_consumed.load(Ordering::Relaxed) } + /// 启用 Automix bass swap 高通滤波包络 + pub fn set_high_pass_automation(&self, role: HighPassRole, duration_ms: u64) { + let start_samples = self.samples_consumed.load(Ordering::Relaxed); + *self.high_pass_automation.lock() = Some(HighPassAutomation { + start_samples, + sample_rate: self.sample_rate, + channels: self.channels, + duration_ms, + role, + }); + } + + /// 获取当前高通滤波包络快照,播放线程按 chunk 读取避免持锁处理采样 + pub fn high_pass_automation(&self) -> Option { + *self.high_pass_automation.lock() + } + /// 缓冲区是否为空(true 表示解码 underrun,sink 不消费可能是正常等待数据) pub fn is_buffer_empty(&self) -> bool { self.buffer.lock().is_empty() @@ -204,6 +272,15 @@ impl Shared { self.condvar.notify_all(); } + /// 复位停止态,用于同一个 Shared 在 seek 后继续接收解码数据 + pub fn clear_stop(&self) { + self.is_stopping.store(false, Ordering::Release); + self.is_eof.store(false, Ordering::Release); + self.all_consumed.store(false, Ordering::Release); + self.decode_failed.store(false, Ordering::Release); + self.samples_consumed.store(0, Ordering::Release); + } + /// 清空缓冲区并释放内存(stop 后调用,避免 AudioChunk 在 Arc 引用存活期间持续占用内存) pub fn drain_buffer(&self) { let mut buf = self.buffer.lock(); diff --git a/native/audio-engine/src/source.rs b/native/audio-engine/src/source.rs index d886167a..ed5a5a62 100644 --- a/native/audio-engine/src/source.rs +++ b/native/audio-engine/src/source.rs @@ -10,6 +10,35 @@ use crate::fft::FftAnalyzer; use crate::shared::Shared; use crate::tempo::StretchProcessor; +struct BiquadHighPass { + z1: f32, + z2: f32, +} + +impl BiquadHighPass { + const fn new() -> Self { + Self { z1: 0.0, z2: 0.0 } + } + + fn process(&mut self, input: f32, cutoff: f32, sample_rate: u32) -> f32 { + let cutoff = cutoff.clamp(10.0, 22_000.0); + let omega = 2.0 * std::f32::consts::PI * cutoff / sample_rate as f32; + let sin = omega.sin(); + let cos = omega.cos(); + let alpha = sin / 2.0; + let a0 = 1.0 + alpha; + let b0 = (1.0 + cos) * 0.5 / a0; + let b1 = -(1.0 + cos) / a0; + let b2 = (1.0 + cos) * 0.5 / a0; + let a1 = -2.0 * cos / a0; + let a2 = (1.0 - alpha) / a0; + let output = input.mul_add(b0, self.z1); + self.z1 = input.mul_add(b1, self.z2) - a1 * output; + self.z2 = input * b2 - a2 * output; + output + } +} + /// rodio 音频源,从共享缓冲区拉取样本。 /// 使用 condvar 阻塞等待数据,不会返回静音填充。 pub struct DecoderSource { @@ -23,6 +52,8 @@ pub struct DecoderSource { local_buffer: VecDeque, /// stretch 输出复用缓冲(避免每帧分配) tempo_scratch: Vec, + high_pass_left: BiquadHighPass, + high_pass_right: BiquadHighPass, sample_rate: u32, channels: u16, } @@ -43,10 +74,28 @@ impl DecoderSource { tempo, local_buffer: VecDeque::new(), tempo_scratch: Vec::new(), + high_pass_left: BiquadHighPass::new(), + high_pass_right: BiquadHighPass::new(), sample_rate, channels, } } + + fn apply_high_pass_automation(&mut self, samples: &mut [f32]) { + let Some(plan) = self.shared.high_pass_automation() else { + return; + }; + if self.channels != 2 { + return; + } + let base_samples = self.shared.samples_consumed_count(); + for (frame_index, frame) in samples.chunks_exact_mut(2).enumerate() { + let consumed = base_samples + frame_index as u64 * 2; + let cutoff = plan.cutoff_at(consumed); + frame[0] = self.high_pass_left.process(frame[0], cutoff, self.sample_rate); + frame[1] = self.high_pass_right.process(frame[1], cutoff, self.sample_rate); + } + } } impl Iterator for DecoderSource { @@ -69,6 +118,7 @@ impl Iterator for DecoderSource { // 填充本地缓冲,一次性批量计数(而非逐采样) if !chunk.player_samples.is_empty() { let mut samples = chunk.player_samples; + self.apply_high_pass_automation(&mut samples); // 对整 chunk 应用 EQ:每秒只锁 50~100 次,开销摊到几千个样本上 self.equalizer .lock() diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index cdb5f85a..2c8e07c0 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -15,6 +15,9 @@ export const defaultSystemConfig: SystemConfig = { player: { autoPlay: false, rememberLastTrack: true, + preloadNext: true, + automixEnabled: false, + automixMaxAnalyzeTimeSec: 60, fadeEnabled: true, fadeDuration: 200, outputDevice: null, diff --git a/shared/types/player.ts b/shared/types/player.ts index 0fd16a33..d0d22787 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -144,6 +144,92 @@ export interface LoadOptions { meta?: Track; } +/** player:crossfadeTo 的可选参数 */ +export interface CrossfadeOptions extends LoadOptions { + /** 交叉混音时长(毫秒) */ + durationMs?: number; + /** 下一首起播位置(毫秒) */ + startSeekMs?: number; + /** 下一首初始播放速度 */ + initialRate?: number; + /** 混音策略类型 */ + mixType?: "default" | "bassSwap"; + /** UI 与媒体状态提交延迟(毫秒) */ + uiSwitchDelayMs?: number; +} + +/** 音频分析结果 */ +export interface AudioAnalysis { + duration: number; + bpm?: number; + bpm_confidence?: number; + fade_in_pos: number; + fade_out_pos: number; + first_beat_pos?: number; + loudness?: number; + drop_pos?: number; + version: number; + analyze_window: number; + cut_in_pos?: number; + cut_out_pos?: number; + mix_center_pos: number; + mix_start_pos: number; + mix_end_pos: number; + energy_profile: number[]; + vocal_in_pos?: number; + vocal_out_pos?: number; + vocal_last_in_pos?: number; + outro_energy_level?: number; + key_root?: number; + key_mode?: number; + key_confidence?: number; + camelot_key?: string; +} + +/** 自动混音过渡建议 */ +export interface TransitionProposal { + duration: number; + current_track_mix_out: number; + next_track_mix_in: number; + mix_type: string; + filter_strategy: string; + compatibility_score: number; + key_compatible: boolean; + bpm_compatible: boolean; +} + +/** 自动混音音量与滤波自动化点 */ +export interface AutomationPoint { + timeOffset: number; + volume: number; + lowCut: number; + highCut: number; +} + +/** 自动混音高级过渡建议 */ +export interface AdvancedTransition { + start_time_current: number; + start_time_next: number; + duration: number; + pitch_shift_semitones: number; + playback_rate: number; + automation_current: AutomationPoint[]; + automation_next: AutomationPoint[]; + strategy: string; +} + +/** 自动混音执行计划 */ +export interface AutomixPlan { + track: Track; + index: number; + triggerTime: number; + crossfadeDuration: number; + startSeek: number; + initialRate: number; + uiSwitchDelay: number; + mixType: "default" | "bassSwap"; +} + /** 播放器状态快照 */ export interface PlayerStatus { state: PlayerState; @@ -163,6 +249,7 @@ export interface AudioDevice { export type PlayerEvent = | { type: "status"; data: PlayerStatus } | { type: "position"; data: { position: number; duration: number } } + | { type: "transitionCommit"; data: { source: string; duration: number; result: LoadResult } } | { type: "seek"; data: { position: number } } | { type: "ended" } | { type: "sourceError" } @@ -189,6 +276,32 @@ export interface IpcResponse { export interface PlayerApi { /** 加载音频(本地路径或网络地址)。可选下发权威 meta 用于 SMTC/托盘 */ load: (source: string, options?: LoadOptions) => Promise>; + /** 静默预载下一首音频 */ + preload: (source: string) => Promise>; + /** 交叉混音到目标音频 */ + crossfadeTo: (source: string, options?: CrossfadeOptions) => Promise>; + /** 分析完整音频特征 */ + analyzeAudioFile: ( + path: string, + maxAnalyzeTimeSec?: number, + ) => Promise>; + /** 仅分析音频头部特征 */ + analyzeAudioFileHead: ( + path: string, + maxAnalyzeTimeSec?: number, + ) => Promise>; + /** 计算两首歌的过渡建议 */ + suggestTransition: ( + currentPath: string, + nextPath: string, + ) => Promise>; + /** 计算两首歌的长段混音建议 */ + suggestLongMix: ( + currentPath: string, + nextPath: string, + ) => Promise>; + /** 取消下一首预载 */ + cancelPreload: () => Promise; /** 恢复播放 */ play: () => Promise; /** 暂停播放 */ diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 351e3d5b..f0d207d7 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -42,6 +42,12 @@ export interface PlayerSettings { autoPlay: boolean; /** 记忆上次播放的歌曲 */ rememberLastTrack: boolean; + /** 下一首预加载 */ + preloadNext: boolean; + /** 自动混音 */ + automixEnabled: boolean; + /** 自动混音最大分析时长(秒) */ + automixMaxAnalyzeTimeSec: number; /** 是否启用渐入渐出 */ fadeEnabled: boolean; /** 渐入渐出时长(毫秒) */ diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts new file mode 100644 index 00000000..ad4483e6 --- /dev/null +++ b/src/core/player/automix.ts @@ -0,0 +1,342 @@ +import type { + AdvancedTransition, + AudioAnalysis, + AutomixPlan, + Track, + TransitionProposal, +} from "@shared/types/player"; +import type { ResolvedTrackSource } from "@/services/audioSource"; +import { useSettingsStore } from "@/stores/settings"; +import { useStatusStore } from "@/stores/status"; +import { getPreloadedTrack, preloadNextTrack } from "./preload"; + +const PRELOAD_AHEAD_MS = 45_000; +const FALLBACK_DURATION_SEC = 8; +const MIN_CROSSFADE_SEC = 0.5; +const TRIGGER_TOLERANCE_MS = 250; + +export type AutomixResult = "idle" | "transitioned" | "fallback-next"; +export type AutomixPlay = (plan: AutomixPlan, resolved: ResolvedTrackSource) => Promise; + +let currentAnalysisKey: string | null = null; +let currentAnalysis: AudioAnalysis | null = null; +let currentAnalysisInFlight: Promise | null = null; +let nextAnalysisKey: string | null = null; +let nextAnalysis: AudioAnalysis | null = null; +let nextAnalysisInFlight: Promise | null = null; +let transitionKey: string | null = null; +let transitionProposal: TransitionProposal | null = null; +let advancedTransition: AdvancedTransition | null = null; +let transitionInFlight: Promise | null = null; +let scheduledPlan: AutomixPlan | null = null; +let transitioning = false; +let cancelToken = 0; + +const isLocalLikeSource = (source: string): boolean => !/^https?:\/\//i.test(source); + +const clampAnalyzeTime = (): number => { + const raw = useSettingsStore().system.player.automixMaxAnalyzeTimeSec || 60; + return Math.max(10, Math.min(300, raw)); +}; + +const snapToBeat = ( + time: number, + bpm: number | undefined, + firstBeat: number | undefined, + snapToBar = true, +): number => { + if (!bpm || bpm <= 0 || firstBeat === undefined) return time; + const spb = 60 / bpm; + const interval = snapToBar ? spb * 4 : spb; + const units = Math.round((time - firstBeat) / interval); + return firstBeat + units * interval; +}; + +const resetTransitionCache = (currentSource: string, nextSource: string): void => { + const key = `${currentSource}>>${nextSource}`; + if (transitionKey === key) return; + transitionKey = key; + transitionProposal = null; + advancedTransition = null; + transitionInFlight = null; +}; + +const ensureAnalysisReady = async (currentSource: string, nextSource: string): Promise => { + if (!isLocalLikeSource(currentSource) || !isLocalLikeSource(nextSource)) return; + const analyzeTime = clampAnalyzeTime(); + const token = cancelToken; + + if (currentAnalysisKey !== currentSource) { + currentAnalysisKey = currentSource; + currentAnalysis = null; + currentAnalysisInFlight = null; + } + if (!currentAnalysis && !currentAnalysisInFlight) { + currentAnalysisInFlight = window.api.player + .analyzeAudioFile(currentSource, analyzeTime) + .then((result) => { + if (token !== cancelToken || currentAnalysisKey !== currentSource) return; + currentAnalysis = result.success ? (result.data ?? null) : null; + }) + .finally(() => { + if (currentAnalysisKey === currentSource) currentAnalysisInFlight = null; + }); + } + + if (nextAnalysisKey !== nextSource) { + nextAnalysisKey = nextSource; + nextAnalysis = null; + nextAnalysisInFlight = null; + } + if (!nextAnalysis && !nextAnalysisInFlight) { + nextAnalysisInFlight = window.api.player + .analyzeAudioFileHead(nextSource, analyzeTime) + .then((result) => { + if (token !== cancelToken || nextAnalysisKey !== nextSource) return; + nextAnalysis = result.success ? (result.data ?? null) : null; + }) + .finally(() => { + if (nextAnalysisKey === nextSource) nextAnalysisInFlight = null; + }); + } + + resetTransitionCache(currentSource, nextSource); + if (!transitionProposal && !advancedTransition && !transitionInFlight) { + transitionInFlight = Promise.all([ + window.api.player.suggestTransition(currentSource, nextSource), + window.api.player.suggestLongMix(currentSource, nextSource), + ]) + .then(([transition, longMix]) => { + if (token !== cancelToken || transitionKey !== `${currentSource}>>${nextSource}`) return; + transitionProposal = transition.success ? (transition.data ?? null) : null; + advancedTransition = longMix.success ? (longMix.data ?? null) : null; + }) + .finally(() => { + if (transitionKey === `${currentSource}>>${nextSource}`) transitionInFlight = null; + }); + } + + await Promise.all([ + currentAnalysisInFlight ?? Promise.resolve(), + nextAnalysisInFlight ?? Promise.resolve(), + transitionInFlight ?? Promise.resolve(), + ]); +}; + +const applyAggressiveOutro = ( + analysis: AudioAnalysis, + triggerTime: number, + crossfadeDuration: number, + exitPoint: number, +): { triggerTime: number; crossfadeDuration: number } | null => { + if (!analysis.vocal_out_pos) return null; + const vocalOut = analysis.vocal_out_pos; + const tailLength = exitPoint - vocalOut; + if (tailLength <= 8) return null; + const outroEnergy = analysis.outro_energy_level ?? -70; + const isHighEnergy = outroEnergy > -12; + const beatsToWait = isHighEnergy ? 8 : 1; + let nextTrigger = triggerTime; + if (analysis.bpm && analysis.first_beat_pos !== undefined) { + const spb = 60 / analysis.bpm; + const relVocal = vocalOut - analysis.first_beat_pos; + let beatIndex = Math.floor(relVocal / spb); + if (relVocal % spb > spb * 0.9) beatIndex++; + let targetBeat = beatIndex + beatsToWait; + if (isHighEnergy) targetBeat = Math.ceil(targetBeat / 4) * 4; + nextTrigger = analysis.first_beat_pos + targetBeat * spb; + } else { + nextTrigger = vocalOut + (isHighEnergy ? 4 : 0.5); + } + if (nextTrigger >= triggerTime || nextTrigger >= exitPoint - 1) return null; + const maxFade = isHighEnergy ? 8 : 5; + return { + triggerTime: nextTrigger, + crossfadeDuration: Math.min(crossfadeDuration, maxFade, exitPoint - nextTrigger), + }; +}; + +const createFallbackPlan = (track: Track, index: number, durationSec: number): AutomixPlan => ({ + track, + index, + triggerTime: Math.max(0, durationSec - FALLBACK_DURATION_SEC), + crossfadeDuration: Math.min(FALLBACK_DURATION_SEC, durationSec), + startSeek: 0, + initialRate: 1, + uiSwitchDelay: FALLBACK_DURATION_SEC * 0.5, + mixType: "default", +}); + +const computePlan = ( + track: Track, + index: number, + currentSource: string, + nextSource: string, + durationSec: number, +): AutomixPlan => { + const current = currentAnalysisKey === currentSource ? currentAnalysis : null; + const next = nextAnalysisKey === nextSource ? nextAnalysis : null; + const transition = + transitionKey === `${currentSource}>>${nextSource}` ? transitionProposal : null; + const advanced = transitionKey === `${currentSource}>>${nextSource}` ? advancedTransition : null; + + if (advanced) { + const mixType = advanced.strategy.includes("Bass Swap") ? "bassSwap" : "default"; + return { + track, + index, + triggerTime: advanced.start_time_current, + crossfadeDuration: advanced.duration, + startSeek: advanced.start_time_next * 1000, + initialRate: advanced.playback_rate, + uiSwitchDelay: advanced.duration * 0.5, + mixType, + }; + } + + const canTrustExitPoint = !!current; + const vocalOut = current?.vocal_out_pos; + let rawFadeOut = current ? current.fade_out_pos || durationSec : durationSec; + rawFadeOut = Math.min(rawFadeOut, durationSec); + if (vocalOut !== undefined && rawFadeOut < vocalOut - 0.1) { + rawFadeOut = durationSec; + } + let exitPoint = rawFadeOut; + if (current?.cut_out_pos !== undefined) { + const cutOut = current.cut_out_pos; + const cutIn = current.cut_in_pos ?? current.fade_in_pos ?? 0; + if (Number.isFinite(cutOut) && cutOut > 0 && cutOut <= durationSec && cutOut - cutIn > 30) { + exitPoint = cutOut; + if (vocalOut !== undefined && exitPoint < vocalOut - 0.1) { + exitPoint = rawFadeOut; + } + } + } + + let triggerTime = exitPoint - FALLBACK_DURATION_SEC; + let crossfadeDuration = FALLBACK_DURATION_SEC; + let startSeek = 0; + let initialRate = 1; + let mixType: "default" | "bassSwap" = "default"; + + if (transition && transition.duration > MIN_CROSSFADE_SEC) { + const safeTrigger = Math.min(transition.current_track_mix_out, durationSec - 1); + triggerTime = safeTrigger; + crossfadeDuration = Math.min(transition.duration, durationSec - safeTrigger); + startSeek = transition.next_track_mix_in * 1000; + mixType = transition.filter_strategy.includes("Bass Swap") ? "bassSwap" : "default"; + } else if (current && next) { + let rawTrigger = exitPoint - crossfadeDuration; + rawTrigger = snapToBeat(rawTrigger, current.bpm, current.first_beat_pos, true); + triggerTime = durationSec - rawTrigger < 4 ? exitPoint - crossfadeDuration : rawTrigger; + startSeek = (next.fade_in_pos || 0) * 1000; + if (current.bpm && next.bpm) { + const confidenceA = current.bpm_confidence ?? 0; + const confidenceB = next.bpm_confidence ?? 0; + const ratio = current.bpm / next.bpm; + if (confidenceA > 0.4 && confidenceB > 0.4 && ratio >= 0.97 && ratio <= 1.03) { + initialRate = ratio; + } + } + } + + if (!advanced && canTrustExitPoint && current) { + const outro = applyAggressiveOutro(current, triggerTime, crossfadeDuration, exitPoint); + if (outro) { + triggerTime = outro.triggerTime; + crossfadeDuration = outro.crossfadeDuration; + } + } + if (triggerTime + crossfadeDuration > durationSec) { + crossfadeDuration = Math.max(MIN_CROSSFADE_SEC, durationSec - triggerTime); + } + if (triggerTime < 0) triggerTime = 0; + + return { + track, + index, + triggerTime, + crossfadeDuration, + startSeek, + initialRate, + uiSwitchDelay: crossfadeDuration * 0.5, + mixType, + }; +}; + +/** 取消当前自动混音调度 */ +export const cancelAutomix = (): void => { + cancelToken++; + currentAnalysisKey = null; + currentAnalysis = null; + currentAnalysisInFlight = null; + nextAnalysisKey = null; + nextAnalysis = null; + nextAnalysisInFlight = null; + transitionKey = null; + transitionProposal = null; + advancedTransition = null; + transitionInFlight = null; + scheduledPlan = null; + transitioning = false; +}; + +/** + * 由 position 事件驱动自动混音,不新增高频 IPC。 + * @param positionMs - 当前播放位置(毫秒) + * @param playMixed - 执行 crossfade 的播放器函数 + */ +export const tickAutomix = async ( + positionMs: number, + playMixed: AutomixPlay, +): Promise => { + const settings = useSettingsStore(); + const status = useStatusStore(); + if (!settings.system.player.automixEnabled || status.fmMode || status.trackLoading) { + return "idle"; + } + if ( + !status.currentSource || + !status.isPlaying || + status.duration <= FALLBACK_DURATION_SEC * 1000 + ) { + return "idle"; + } + const remaining = status.duration - positionMs; + if (remaining > PRELOAD_AHEAD_MS) return "idle"; + + void preloadNextTrack(); + const prepared = + getPreloadedTrack() ?? + (remaining <= FALLBACK_DURATION_SEC * 1000 ? await preloadNextTrack() : null); + if (!prepared) return "idle"; + + const durationSec = status.duration / 1000; + const planKey = `${status.currentSource}|${prepared.source}|${prepared.track.id}|${prepared.index}`; + if ( + !scheduledPlan || + planKey !== + `${status.currentSource}|${prepared.source}|${scheduledPlan.track.id}|${scheduledPlan.index}` + ) { + await ensureAnalysisReady(status.currentSource, prepared.source); + scheduledPlan = computePlan( + prepared.track, + prepared.index, + status.currentSource, + prepared.source, + durationSec, + ); + } + + const plan = scheduledPlan ?? createFallbackPlan(prepared.track, prepared.index, durationSec); + if (positionMs + TRIGGER_TOLERANCE_MS < plan.triggerTime * 1000) { + return "idle"; + } + if (transitioning) return "idle"; + + transitioning = true; + const ok = await playMixed(plan, prepared.resolved); + transitioning = false; + cancelAutomix(); + return ok ? "transitioned" : "fallback-next"; +}; diff --git a/src/core/player/events.ts b/src/core/player/events.ts index b6b6691c..4fc6ea21 100644 --- a/src/core/player/events.ts +++ b/src/core/player/events.ts @@ -8,7 +8,11 @@ import * as autoClose from "@/services/autoClose"; import * as abLoop from "@/services/abLoop"; import * as cacheScheduler from "@/services/cacheScheduler"; import * as playStats from "./stats"; +import { tickAutomix } from "./automix"; import { + crossfadeToTrack, + commitPendingCrossfade, + hasPendingCrossfadeCommit, hasReachedSeekTarget, isSeeking, markSeek, @@ -35,7 +39,9 @@ export const handleEvent = async (event: PlayerEvent): Promise => { switch (event.type) { case "status": // 歌曲加载中或 loading 事件不更新 UI,保持当前封面/进度/播放状态平滑过渡 - if (event.data.state === "loading" || status.trackLoading) break; + if (event.data.state === "loading" || status.trackLoading || hasPendingCrossfadeCommit()) { + break; + } status.state = event.data.state; // seek 期间不从 status 事件更新 position,避免回跳;position 更新统一由 position 事件负责 if (!isSeeking()) { @@ -52,6 +58,12 @@ export const handleEvent = async (event: PlayerEvent): Promise => { case "position": { // 歌曲加载中不更新进度 if (status.trackLoading) break; + if (hasPendingCrossfadeCommit()) { + const adjusted = playback.getCurrentTime(); + status.position = adjusted; + useMediaStore().updateLyricIndex(adjusted + status.lyricOffsetMs); + break; + } // seek 后丢弃旧位置,直到后端推送的位置到达 seek 目标附近 if (!hasReachedSeekTarget(event.data.position)) break; const adjusted = playback.setCurrentTime(event.data.position); @@ -66,11 +78,16 @@ export const handleEvent = async (event: PlayerEvent): Promise => { abLoop.checkLoop(adjusted); // 推进延时缓存调度 cacheScheduler.tick(adjusted); + const automixResult = await tickAutomix(adjusted, crossfadeToTrack); + if (automixResult === "fallback-next") await nextTrack(); break; } case "fftData": playback.setFftFrame(event.data); break; + case "transitionCommit": + await commitPendingCrossfade(event.data); + break; case "ended": { if (endedGuard) return; endedGuard = true; diff --git a/src/core/player/index.ts b/src/core/player/index.ts index c24ec7be..9c7891be 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -1,4 +1,5 @@ -import type { Track } from "@shared/types/player"; +import type { AutomixPlan, LoadResult, PlayerEvent, Track } from "@shared/types/player"; +import type { ResolvedTrackSource } from "@/services/audioSource"; import type { TagEditRequest, TagWriteOutcome } from "@shared/types/tagEditor"; import { handleEvent } from "./events"; import type { RepeatMode, ShuffleMode } from "@/stores/status"; @@ -16,6 +17,8 @@ import * as lyricLoader from "@/services/lyricLoader"; import * as coverLoader from "@/services/coverLoader"; import * as abLoop from "@/services/abLoop"; import * as cacheScheduler from "@/services/cacheScheduler"; +import * as nextPreload from "./preload"; +import { cancelAutomix } from "./automix"; import { resolveTrackSource } from "@/services/audioSource"; import { installPlayStats } from "./stats"; import { useFavorite } from "@/composables/useFavorite"; @@ -35,6 +38,16 @@ const MAX_CONSECUTIVE_FAILURES = 5; /** 失败后跳下一首的节流延迟(毫秒) */ const SKIP_ON_ERROR_DELAY_MS = 1000; +type PendingCrossfadeCommit = { + trackToken: number; + loadToken: number; + plan: AutomixPlan; + resolved: ResolvedTrackSource; + committed: boolean; +}; + +let pendingCrossfadeCommit: PendingCrossfadeCommit | null = null; + /** * 单曲级失败兜底 * 达到连续失败上限 / 队列长度则交 onQueueEnded 停下 @@ -59,6 +72,62 @@ const skipOnFailure = async (myToken: number, getCurrentToken: () => number): Pr /** load() 的结果;失败时附带错误码,跳曲决策交给调用方 */ export type LoadOutcome = { ok: true; track: Track | null } | { ok: false; error?: string }; +const applyCrossfadeCommit = async ( + pending: PendingCrossfadeCommit, + result: LoadResult, +): Promise => { + if (pending.committed) return true; + if (pending.trackToken !== trackToken || pending.loadToken !== loadToken) return false; + pending.committed = true; + + const status = useStatusStore(); + const media = useMediaStore(); + const { plan, resolved } = pending; + const { track } = plan; + const isOnline = track.source !== "local"; + + consecutiveFailures = 0; + const { detail, mediaInfo } = result; + media.setTrack(track); + if (isOnline) { + void lyricLoader.loadForTrack(null); + extractColorFromUrl(track.cover ?? track.coverOriginal ?? null); + void coverLoader.loadCoverForTrack(track); + } + media.enrichTrack(mediaInfo, detail); + const enriched = media.track; + if (!isOnline) { + lyricLoader.loadForTrack(detail); + extractColorFromUrl(enriched?.cover ?? null); + if (enriched) void coverLoader.loadCoverForTrack(enriched); + } + const dur = enriched?.duration ?? mediaInfo.duration; + status.playIndex = plan.index; + status.position = plan.startSeek; + status.duration = dur; + status.state = "playing"; + status.currentSource = resolved.source; + playback.setCurrentTime(plan.startSeek, { force: true }); + playback.setDuration(dur); + playback.setPlaying(true); + void useHistoryStore().record(track); + status.trackLoading = false; + void nextPreload.preloadNextTrack(); + return true; +}; + +/** 在主进程 crossfade commit 点提交渲染层当前曲信息 */ +export const commitPendingCrossfade = async ( + data: Extract["data"], +): Promise => { + const pending = pendingCrossfadeCommit; + if (!pending || pending.resolved.source !== data.source) return false; + return applyCrossfadeCommit(pending, data.result); +}; + +/** crossfade 已接管 native 新槽,但 UI 还没到中点提交 */ +export const hasPendingCrossfadeCommit = (): boolean => pendingCrossfadeCommit !== null; + /** * 切歌通用前置 * @param duration 新歌时长(毫秒),未知时传 0 @@ -73,6 +142,8 @@ const resetForLoad = (duration: number): void => { playback.setPlaying(false); // 上一首未达到缓存触发阈值的请求丢弃 cacheScheduler.cancel(); + cancelAutomix(); + nextPreload.cancelPreload(); }; /** @@ -120,6 +191,7 @@ export const load = async (source: string, autoPlay = true, meta?: Track): Promi status.currentSource = source; playback.setDuration(dur); playback.setPlaying(autoPlay); + void nextPreload.preloadNextTrack(); return { ok: true, track: enriched }; } status.state = "idle"; @@ -131,6 +203,49 @@ export const load = async (source: string, autoPlay = true, meta?: Track): Promi } }; +/** + * 以双槽 crossfade 切到指定曲目 + * @param plan - 自动混音执行计划 + * @param resolved - 已解析的播放源 + */ +export const crossfadeToTrack = async ( + plan: AutomixPlan, + resolved: ResolvedTrackSource, +): Promise => { + const myTrackToken = ++trackToken; + const myLoadToken = ++loadToken; + const status = useStatusStore(); + const { track } = plan; + cacheScheduler.cancel(); + const pending: PendingCrossfadeCommit = { + trackToken: myTrackToken, + loadToken: myLoadToken, + plan, + resolved, + committed: false, + }; + pendingCrossfadeCommit = pending; + try { + const result = await window.api.player.crossfadeTo(resolved.source, { + durationMs: plan.crossfadeDuration * 1000, + startSeekMs: plan.startSeek, + initialRate: plan.initialRate, + mixType: plan.mixType, + uiSwitchDelayMs: plan.uiSwitchDelay * 1000, + meta: track, + }); + if (myTrackToken !== trackToken || myLoadToken !== loadToken) return false; + if (!result.success || !result.data) { + if (result.error) handleError(result.error); + return false; + } + return applyCrossfadeCommit(pending, result.data); + } finally { + if (pendingCrossfadeCommit === pending) pendingCrossfadeCommit = null; + if (myTrackToken === trackToken) status.trackLoading = false; + } +}; + /** * 加载指定 Track 到播放器 * 乐观更新:立即显示歌曲信息,快速切歌时只有最后一次 load 生效 @@ -279,6 +394,7 @@ export const togglePlay = (): void => { /** 暂停播放 */ export const pause = async (): Promise => { const status = useStatusStore(); + cancelAutomix(); const prev = status.state; status.state = "paused"; playback.setPlaying(false); @@ -291,6 +407,10 @@ export const pause = async (): Promise => { /** 停止播放并重置进度 */ export const stop = async (): Promise => { + trackToken++; + loadToken++; + cancelAutomix(); + nextPreload.cancelPreload(); const result = await window.api.player.stop(); if (result.success) { const status = useStatusStore(); @@ -331,6 +451,7 @@ export const isSeeking = (): boolean => seekTarget !== null; */ export const seek = async (posMs: number): Promise => { const status = useStatusStore(); + cancelAutomix(); // 歌曲加载中 seek 无意义:引擎此刻没有可 seek 的解码线程, // 且 seekTarget 残留会让加载完成后的 position 推送被持续丢弃 if (status.trackLoading) return; @@ -416,6 +537,7 @@ export const refreshDevices = async (): Promise => { * @param deviceName - 设备名称,传 null 跟随系统默认 */ export const switchDevice = async (deviceName: string | null): Promise => { + nextPreload.cancelPreload(); const result = await window.api.player.setOutputDevice(deviceName); if (!result.success) return; const settings = useSettingsStore(); @@ -439,6 +561,7 @@ export const playFrom = async (items: readonly Track[], startIndex = 0): Promise const idx = Math.max(0, Math.min(startIndex, items.length - 1)); const isSameTrack = media.track?.id === items[idx]?.id; queue.setQueue(items); + nextPreload.cancelPreload(); status.playIndex = idx; if (status.shuffleMode === "on") { queue.shuffleQueue(status.playIndex); @@ -643,6 +766,8 @@ export const setRepeatMode = (mode: RepeatMode): void => { const status = useStatusStore(); if (status.repeatMode === mode) return; status.repeatMode = mode; + cancelAutomix(); + void nextPreload.preloadNextTrack(); syncPlayMode(); toast.info(i18n.global.t(`player.repeatMode.${mode}`), { icon: false }); }; @@ -684,6 +809,8 @@ export const setShuffleMode = (mode: ShuffleMode): void => { queue.unshuffleQueue(""); } } + cancelAutomix(); + void nextPreload.preloadNextTrack(); syncPlayMode(); toast.info(i18n.global.t(`player.shuffleMode.${mode}`), { icon: false }); }; @@ -697,6 +824,8 @@ export const removeFromQueue = async (index: number): Promise => { if (index < 0 || index >= queue.queueLength.value) return; const isCurrentPlaying = index === status.playIndex; queue.removeFromQueue(index); + cancelAutomix(); + void nextPreload.preloadNextTrack(); if (index < status.playIndex) { // 移除的在当前歌之前,索引前移 status.playIndex--; @@ -748,11 +877,15 @@ export const insertToQueue = (item: Track, afterIndex?: number): number => { const safeAt = Math.max(0, Math.min(raw, len - 1)); if (existingIdx === safeAt) return existingIdx; moveInQueue(existingIdx, safeAt); + cancelAutomix(); + void nextPreload.preloadNextTrack(); return safeAt; } // 插入:可以追加到末尾,clamp 到 length const safeAt = Math.max(0, Math.min(raw, len)); queue.insertToQueue(item, safeAt); + cancelAutomix(); + void nextPreload.preloadNextTrack(); if (safeAt <= status.playIndex) status.playIndex++; return safeAt; }; @@ -804,6 +937,8 @@ export const playNow = async (item: Track): Promise => { export const moveInQueue = (fromIndex: number, toIndex: number): void => { const status = useStatusStore(); queue.moveInQueue(fromIndex, toIndex); + cancelAutomix(); + void nextPreload.preloadNextTrack(); // 根据移动方向调整 playIndex if (status.playIndex === fromIndex) { status.playIndex = toIndex; @@ -815,6 +950,7 @@ export const moveInQueue = (fromIndex: number, toIndex: number): void => { }; let unsubscribe: (() => void) | null = null; +let stopAutomationWatch: (() => void) | null = null; let initialized = false; /** 初始化播放器 */ @@ -855,6 +991,23 @@ export const initPlayer = async (): Promise => { unsubscribe = window.api.player.onEvent(handleEvent); // 安装播放统计累加器 installPlayStats(); + if (stopAutomationWatch) stopAutomationWatch(); + stopAutomationWatch = watch( + [ + () => status.playIndex, + () => status.repeatMode, + () => status.shuffleMode, + () => settings.player.songLevel, + () => settings.system.player.preloadNext, + () => settings.system.player.automixEnabled, + () => queue.queue.value.map((track) => track.id).join("|"), + ], + () => { + cancelAutomix(); + nextPreload.cancelPreload(); + void nextPreload.preloadNextTrack(); + }, + ); // 订阅主进程下发的歌词偏移变化 const media = useMediaStore(); // 当前歌曲喜欢状态变化时同步到托盘菜单 @@ -903,4 +1056,8 @@ export const disposePlayer = (): void => { unsubscribe(); unsubscribe = null; } + if (stopAutomationWatch) { + stopAutomationWatch(); + stopAutomationWatch = null; + } }; diff --git a/src/core/player/preload.ts b/src/core/player/preload.ts new file mode 100644 index 00000000..e3adeab8 --- /dev/null +++ b/src/core/player/preload.ts @@ -0,0 +1,123 @@ +import type { Track } from "@shared/types/player"; +import type { ResolvedTrackSource } from "@/services/audioSource"; +import { useSettingsStore } from "@/stores/settings"; +import { useStatusStore } from "@/stores/status"; +import * as queue from "@/stores/queue"; +import { resolveTrackSource } from "@/services/audioSource"; + +export interface NextTrackCandidate { + track: Track; + index: number; +} + +export interface PreloadedTrack extends NextTrackCandidate { + source: string; + resolved: ResolvedTrackSource; + key: string; +} + +let active: PreloadedTrack | null = null; +let inFlight: Promise | null = null; +let inFlightKey: string | null = null; +let token = 0; + +const candidateKey = (candidate: NextTrackCandidate): string => { + const settings = useSettingsStore(); + return [ + candidate.track.id, + candidate.index, + settings.player.songLevel, + candidate.track.source, + candidate.track.serverId ?? "", + ].join("|"); +}; + +const triggerCacheDownload = (request: () => Promise): void => { + void request().catch((err) => { + console.warn("[preload] cache download failed", err); + }); +}; + +/** 计算当前播放状态下的下一首候选 */ +export const getNextTrackCandidate = (): NextTrackCandidate | null => { + const status = useStatusStore(); + if (status.fmMode) return null; + const len = queue.queueLength.value; + if (len === 0 || status.playIndex < 0) return null; + if (status.repeatMode === "one") { + const track = queue.getTrack(status.playIndex); + return track ? { track, index: status.playIndex } : null; + } + const nextIndex = + status.playIndex >= len - 1 ? (status.repeatMode === "list" ? 0 : -1) : status.playIndex + 1; + if (nextIndex < 0) return null; + const track = queue.getTrack(nextIndex); + return track ? { track, index: nextIndex } : null; +}; + +/** 取当前仍有效的预载候选 */ +export const getPreloadedTrack = (): PreloadedTrack | null => { + const candidate = getNextTrackCandidate(); + if (!candidate || !active) return null; + return active.key === candidateKey(candidate) ? active : null; +}; + +/** 取消渲染调度与 native prepared slot */ +export const cancelPreload = (): void => { + token++; + active = null; + inFlight = null; + inFlightKey = null; + void window.api.player.cancelPreload(); +}; + +/** + * 预载当前队列的下一首。 + * @returns 已解析且 native 预载成功的候选;取消、关闭或失败时返回 null + */ +export const preloadNextTrack = async (): Promise => { + const settings = useSettingsStore(); + if (!settings.system.player.preloadNext && !settings.system.player.automixEnabled) { + cancelPreload(); + return null; + } + const candidate = getNextTrackCandidate(); + if (!candidate) { + cancelPreload(); + return null; + } + const key = candidateKey(candidate); + if (active?.key === key) return active; + if (inFlight && inFlightKey === key) return inFlight; + + const myToken = ++token; + inFlightKey = key; + inFlight = (async () => { + const resolved = await resolveTrackSource(candidate.track); + if (myToken !== token || !resolved) return null; + if (resolved.cacheRequest) { + triggerCacheDownload(resolved.cacheRequest); + } + const preloadSource = resolved.source; + const result = await window.api.player.preload(preloadSource); + if (myToken !== token || !result.success) return null; + const prepared: PreloadedTrack = { + ...candidate, + source: preloadSource, + resolved: { + ...resolved, + source: preloadSource, + fromCache: resolved.fromCache || preloadSource !== resolved.source, + }, + key, + }; + active = prepared; + return prepared; + })().finally(() => { + if (inFlightKey === key) { + inFlight = null; + inFlightKey = null; + } + }); + return inFlight; +}; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 71e18edd..648a97c9 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -717,6 +717,18 @@ "label": "Remember Progress", "description": "Restore playback position on startup" }, + "preloadNext": { + "label": "Preload Next Track", + "description": "Resolve and prepare the next track early to reduce switch latency" + }, + "automixEnabled": { + "label": "Automix", + "description": "Automatically crossfade into the next track near the end" + }, + "automixMaxAnalyzeTimeSec": { + "label": "Max Analysis Time", + "description": "Maximum seconds of each track used for automix analysis" + }, "fadeEnabled": { "label": "Fade In/Out", "description": "Audio fade on play and pause" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d84339fe..1d406bf0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -705,6 +705,18 @@ "label": "记忆播放进度", "description": "启动时恢复上次歌曲的播放进度" }, + "preloadNext": { + "label": "预加载下一首", + "description": "提前解析并准备下一首歌曲,减少切歌等待" + }, + "automixEnabled": { + "label": "自动混音", + "description": "在曲末根据分析结果自动交叉混入下一首" + }, + "automixMaxAnalyzeTimeSec": { + "label": "最大分析时长", + "description": "每首歌用于自动混音分析的最长秒数" + }, "fadeEnabled": { "label": "渐入渐出", "description": "播放和暂停时音频淡入淡出" diff --git a/src/services/audioSource.ts b/src/services/audioSource.ts index c1372934..af6635c9 100644 --- a/src/services/audioSource.ts +++ b/src/services/audioSource.ts @@ -135,7 +135,7 @@ const resolveOnlineUrl = async ( export interface ResolvedTrackSource { source: string; fromCache: boolean; - cacheRequest?: () => Promise; + cacheRequest?: () => Promise; } /** @@ -168,9 +168,10 @@ export const resolveTrackSource = async (track: Track): Promise { - void window.api.cache.song.fetch(cacheKey, track.source, url); + return window.api.cache.song.fetch(cacheKey, track.source, url); }; } return result; diff --git a/src/settings/categories/player.ts b/src/settings/categories/player.ts index f7dd52b1..368e0adf 100644 --- a/src/settings/categories/player.ts +++ b/src/settings/categories/player.ts @@ -25,6 +25,31 @@ const playerCategory: SettingCategory = { binding: { store: "settings", path: "system.player.rememberLastTrack" }, defaultValue: false, }, + { + key: "preloadNext", + type: "switch", + binding: { store: "settings", path: "system.player.preloadNext" }, + defaultValue: true, + }, + { + key: "automixEnabled", + type: "switch", + binding: { store: "settings", path: "system.player.automixEnabled" }, + defaultValue: false, + tag: { text: "Beta" }, + children: [ + { + key: "automixMaxAnalyzeTimeSec", + type: "slider", + binding: { store: "settings", path: "system.player.automixMaxAnalyzeTimeSec" }, + min: 10, + max: 300, + step: 10, + defaultValue: 60, + marks: { 10: "10", 60: "60", 300: "300" }, + }, + ], + }, { key: "fadeEnabled", type: "switch", From 00656a03cf37b7e07c2e1ae5829ab604efe1bde3 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:23:42 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(time):=20=E4=BF=AE=E5=A4=8D=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=87=BD=E6=95=B0=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E9=BB=98=E8=AE=A4=E8=BF=94=E5=9B=9E=E5=80=BC=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 default 分支处理未知状态情况 - 返回空字符串避免 undefined 值导致的显示问题 - 确保函数在所有情况下都有明确的返回值 --- src/composables/useTimeFormat.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/composables/useTimeFormat.ts b/src/composables/useTimeFormat.ts index 5c9cad38..e2968683 100644 --- a/src/composables/useTimeFormat.ts +++ b/src/composables/useTimeFormat.ts @@ -38,6 +38,8 @@ export const useTimeFormat = () => { return formatTime(statusStore.duration); case "remaining": return "-" + formatTime(statusStore.duration - statusStore.position); + default: + return ""; } }); From 149c0cf7117d3850e37915a17471ff585aabfd43 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:08:07 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(i18n):=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正了 en-US.json 中 showLyricInBar 配置项后的多余逗号 - 修正了 zh-CN.json 中 showLyricInBar 配置项后的多余逗号 - 确保 JSON 文件格式正确性 --- src/i18n/locales/en-US.json | 11 +++++------ src/i18n/locales/zh-CN.json | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1c799d99..183800c5 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -771,12 +771,11 @@ "label": "Max Analysis Time", "description": "Maximum seconds of each track used for automix analysis" }, - "showLyricInBar": { - "label": "Show Lyric While Playing", - "description": "Replace artist name with current lyric in the bottom player bar" - }, - }, - "fadeEnabled": { + "showLyricInBar": { + "label": "Show Lyric While Playing", + "description": "Replace artist name with current lyric in the bottom player bar" + }, + "fadeEnabled": { "label": "Fade In/Out", "description": "Audio fade on play and pause" }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9c4f3d2c..c87bc9a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -759,12 +759,11 @@ "label": "最大分析时长", "description": "每首歌用于自动混音分析的最长秒数" }, - "showLyricInBar": { - "label": "播放时显示歌词", - "description": "底部播放栏播放时将歌手名替换为当前歌词" - }, - }, - "fadeEnabled": { + "showLyricInBar": { + "label": "播放时显示歌词", + "description": "底部播放栏播放时将歌手名替换为当前歌词" + }, + "fadeEnabled": { "label": "渐入渐出", "description": "播放和暂停时音频淡入淡出" }, From 2aa7dd504abe364379758621fde5161a29eae2e2 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:03:52 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(audio-engine):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E5=88=86=E6=9E=90=E7=89=88=E6=9C=AC=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E6=B7=B7=E9=9F=B3=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将音频分析版本从13升级到14以支持新的分析特性 - 重构BPM检测算法,增加低频环境相关性计算和置信度评估 - 改进关键检测逻辑,使用更精确的分数排序和置信度计算 - 优化音频处理流程中的持续时间跟踪机制 - 调整混音参数限制,包括最大交叉淡入淡出时间和节拍置信度阈值 - 增强音频引擎中的渐变步数计算,支持动态步长调整 - 实现音频时钟同步机制,确保变速播放的时间准确性 - 添加分析超时控制和错误处理机制 - 优化过渡计划验证,增加对节拍网格可靠性的检查 --- native/audio-engine/src/analysis.rs | 152 +++++++++++----------- native/audio-engine/src/player.rs | 30 +++-- native/audio-engine/src/source.rs | 53 ++++++-- src/core/player/automix.ts | 187 ++++++++++++++++++++-------- 4 files changed, 280 insertions(+), 142 deletions(-) diff --git a/native/audio-engine/src/analysis.rs b/native/audio-engine/src/analysis.rs index 352613bc..8ee4da15 100644 --- a/native/audio-engine/src/analysis.rs +++ b/native/audio-engine/src/analysis.rs @@ -120,7 +120,7 @@ pub struct AdvancedTransition { const ENV_RATE: f64 = 50.0; const WINDOW_SIZE_MS: usize = 20; -const ANALYSIS_VERSION: i32 = 13; +const ANALYSIS_VERSION: i32 = 14; const DEFAULT_SAMPLE_RATE: u32 = 44100; // Key Detection Constants @@ -386,6 +386,9 @@ impl TrackAnalyzer { } _ => None, }; + if let Some(duration) = estimated_duration { + self.duration = duration; + } let mut decoder = symphonia::default::get_codecs() .make(params, &DecoderOptions::default()) @@ -394,12 +397,8 @@ impl TrackAnalyzer { // Phase 1: Head self.process_segment(&mut format, &mut decoder, track_id, true, time_base)?; - // Phase 2: Tail if self.include_tail { if let Some(tot) = estimated_duration { - // If total duration is significantly longer than what we analyzed + max_analyze_time - // We only jump if there is unanalyzed gap. - // Actually, logic is: analyze tail if song is long enough. if tot > self.max_analyze_time * 2.0 { let seek_target = tot - self.max_analyze_time; let seek_time = Time::from(seek_target); @@ -413,11 +412,6 @@ impl TrackAnalyzer { ) .is_ok() { - // Reset duration to correct time after seek? - // Actually duration tracking inside process_segment handles packets. - // But we need to ensure we don't overwrite self.duration with wrong values if packets are weird. - // Wait, process_segment updates self.duration from packet timestamp. - // That is correct. self.process_segment( &mut format, &mut decoder, @@ -455,7 +449,7 @@ impl TrackAnalyzer { let key_max_samples = (f64::from(self.sample_rate) * self.max_analyze_time.min(30.0)) as usize; - let mut processed_duration_local = 0.0; // For fallback if time_base missing + let mut processed_duration_local = 0.0; let segment = if is_head { &mut self.head @@ -479,18 +473,9 @@ impl TrackAnalyzer { let t = tb.calc_time(packet.ts()); t.seconds as f64 + t.frac } else { - // If we sought, this local duration is wrong for absolute time, - // but if time_base is missing, seeking is likely impossible/unreliable anyway. - if !is_head { - // If no time_base, we can't really do tail analysis properly via seek. - // We just continue. - } - self.duration + processed_duration_local + processed_duration_local }; - self.duration = packet_time; - - // Stop condition if is_head && packet_time > self.max_analyze_time { break; } @@ -503,6 +488,8 @@ impl TrackAnalyzer { let spec = *decoded.spec(); let frames = decoded.frames(); let channels = spec.channels.count().min(8); + let packet_end = packet_time + frames as f64 / f64::from(self.sample_rate); + self.duration = self.duration.max(packet_end); if time_base.is_none() { processed_duration_local += frames as f64 / f64::from(self.sample_rate); @@ -913,51 +900,81 @@ fn calculate_smart_cut_in( // --- BPM & Key Detection Wrappers --- -fn detect_bpm(env: &[f32], _low_env: &[f32], rate: f64) -> (Option, Option, Option) { +fn detect_bpm(env: &[f32], low_env: &[f32], rate: f64) -> (Option, Option, Option) { if env.len() < 100 { return (None, None, None); } - // Simple Flux let flux: Vec = env.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect(); - if flux.len() < 110 { + let low_flux: Vec = low_env + .windows(2) + .map(|w| (w[1] - w[0]).max(0.0)) + .collect(); + if flux.len() < BPM_MAX_LAG * 2 { return (None, None, None); } - // Autocorrelation (60-180 BPM -> 0.33-1.0s -> 16-50 samples) - let mut best_corr = 0.0; - let mut best_lag = 0; - - for lag in BPM_MIN_LAG..BPM_MAX_LAG { - let mut sum = 0.0; - for i in 0..(flux.len() - lag) { - sum += flux[i] * flux[i + lag]; + let correlation = |signal: &[f32], lag: usize| -> f32 { + if signal.len() <= lag { + return 0.0; } - if sum > best_corr { - best_corr = sum; - best_lag = lag; + let mut cross = 0.0; + let mut energy_a = 0.0; + let mut energy_b = 0.0; + for i in 0..(signal.len() - lag) { + let a = signal[i]; + let b = signal[i + lag]; + cross += a * b; + energy_a += a * a; + energy_b += b * b; } + let denom = (energy_a * energy_b).sqrt(); + if denom > f32::EPSILON { + cross / denom + } else { + 0.0 + } + }; + + let mut candidates = Vec::with_capacity(BPM_MAX_LAG - BPM_MIN_LAG); + for lag in BPM_MIN_LAG..BPM_MAX_LAG { + let full_corr = correlation(&flux, lag); + let low_corr = correlation(&low_flux, lag); + candidates.push((lag, full_corr.mul_add(0.65, low_corr * 0.35))); } + candidates.sort_by(|a, b| b.1.total_cmp(&a.1)); - if best_corr <= 0.001 { + let Some(&(best_lag, best_corr)) = candidates.first() else { + return (None, None, None); + }; + if best_corr < 0.08 { return (None, None, None); } + let second_corr = candidates + .iter() + .find(|(lag, _)| lag.abs_diff(best_lag) > 1) + .map_or(0.0, |(_, corr)| *corr); + let prominence = ((best_corr - second_corr) / best_corr.max(0.001)).clamp(0.0, 1.0); + let confidence = (best_corr.clamp(0.0, 1.0) * 0.7 + prominence * 0.3) as f64; + let bpm = 60.0 / (best_lag as f64 / rate); - // Refine phase let first_beat = (0..best_lag) - .max_by_key(|&phase| { - let mut e = 0.0; - let mut idx = phase; - while idx < flux.len() { - e += flux[idx]; - idx += best_lag; - } - (e * 1000.0) as i32 + .max_by(|&a, &b| { + let phase_energy = |phase: usize| -> f32 { + let mut energy = 0.0; + let mut idx = phase; + while idx < flux.len() { + energy += flux[idx]; + idx += best_lag; + } + energy + }; + phase_energy(a).total_cmp(&phase_energy(b)) }) - .map(|p| p as f64 / rate); + .map(|phase| phase as f64 / rate); - (Some(bpm), Some(0.8), first_beat) + (Some(bpm), Some(confidence), first_beat) } fn detect_key(pcm: &[f32], sr: u32) -> (Option, Option, Option) { @@ -981,11 +998,8 @@ fn detect_key(pcm: &[f32], sr: u32) -> (Option, Option, Option) { }) .collect(); - // Processing chunks - for chunk in pcm.chunks(frame_size).step_by(FFT_STEP) { - if chunk.len() < frame_size { - break; - } + for start in (0..=pcm.len() - frame_size).step_by(FFT_STEP) { + let chunk = &pcm[start..start + frame_size]; for i in 0..frame_size { buffer[i] = Complex32::new(chunk[i] * window[i], 0.0); } @@ -1021,10 +1035,7 @@ fn detect_key(pcm: &[f32], sr: u32) -> (Option, Option, Option) { *x /= norm; } - let mut best_score = -1.0; - let mut best_root = 0; - let mut best_mode = 0; // 0=Maj, 1=Min - + let mut scores = Vec::with_capacity(24); for root in 0..12 { let mut s_maj = 0.0; let mut s_min = 0.0; @@ -1033,23 +1044,24 @@ fn detect_key(pcm: &[f32], sr: u32) -> (Option, Option, Option) { s_maj += c * major[idx] as f32; s_min += c * minor[idx] as f32; } - if s_maj > best_score { - best_score = s_maj; - best_root = root; - best_mode = 0; - } - if s_min > best_score { - best_score = s_min; - best_root = root; - best_mode = 1; - } + scores.push((s_maj, root, 0)); + scores.push((s_min, root, 1)); } + scores.sort_by(|a, b| b.0.total_cmp(&a.0)); - if best_score > 0.0 { - (Some(best_root as i32), Some(best_mode), Some(0.8)) - } else { - (None, None, None) + let Some(&(best_score, best_root, best_mode)) = scores.first() else { + return (None, None, None); + }; + if best_score <= 0.0 { + return (None, None, None); } + let second_score = scores.get(1).map_or(0.0, |candidate| candidate.0); + let confidence = ((best_score - second_score) / best_score.max(0.001)).clamp(0.0, 1.0); + ( + Some(best_root as i32), + Some(best_mode), + Some(f64::from(confidence)), + ) } // --- Transition Logic --- diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index e0eaf8c1..f0caa9a4 100644 --- a/native/audio-engine/src/player.rs +++ b/native/audio-engine/src/player.rs @@ -36,8 +36,13 @@ pub enum PlayerEvent { /// 事件发射器类型(跨线程安全) pub type EventEmitter = Arc; -/// 渐变步数 -const FADE_STEPS: u32 = 20; +/// 每秒渐变更新次数 +const FADE_STEPS_PER_SECOND: u64 = 100; +const MIN_FADE_STEPS: u64 = 20; + +fn fade_step_count(duration_ms: u64) -> u64 { + ((duration_ms * FADE_STEPS_PER_SECOND) / 1000).max(MIN_FADE_STEPS) +} /// 可取消的渐变:在独立线程中逐步调整音量,cancel 为 true 时提前退出 fn fade_volume(sink: &Sink, from: f32, to: f32, duration_ms: u64, cancel: &AtomicBool) { @@ -45,15 +50,14 @@ fn fade_volume(sink: &Sink, from: f32, to: f32, duration_ms: u64, cancel: &Atomi sink.set_volume(to); return; } - let step_duration = Duration::from_millis(duration_ms / u64::from(FADE_STEPS)); - for step in 1..=FADE_STEPS { + let steps = fade_step_count(duration_ms); + let step_duration = Duration::from_micros(duration_ms * 1000 / steps); + for step in 1..=steps { if cancel.load(Ordering::Relaxed) { return; } - let progress = step as f32 / FADE_STEPS as f32; + let progress = step as f32 / steps as f32; sink.set_volume(from + (to - from) * progress); - // 分片可取消:渐变时长用户可配,长渐变的整步 sleep 会让 cancel_fade 的 - // 同步 join 卡住最长一个步长 sleep_unless_stopped(cancel, step_duration); } } @@ -76,15 +80,17 @@ fn crossfade_volume( if duration_ms == 0 { new_sink.set_volume(target_volume); } else { - let step_duration = Duration::from_millis(duration_ms / u64::from(FADE_STEPS)); - for step in 1..=FADE_STEPS { + let steps = fade_step_count(duration_ms); + let step_duration = Duration::from_micros(duration_ms * 1000 / steps); + for step in 1..=steps { if cancel.load(Ordering::Relaxed) { break; } - let progress = step as f32 / FADE_STEPS as f32; - new_sink.set_volume(target_volume * progress); + let progress = step as f32 / steps as f32; + let angle = progress * std::f32::consts::FRAC_PI_2; + new_sink.set_volume(target_volume * angle.sin()); if let Some(ref sink) = old.sink { - sink.set_volume(target_volume * (1.0 - progress)); + sink.set_volume(target_volume * angle.cos()); } sleep_unless_stopped(cancel, step_duration); } diff --git a/native/audio-engine/src/source.rs b/native/audio-engine/src/source.rs index 0a7fe0ed..3631ae44 100644 --- a/native/audio-engine/src/source.rs +++ b/native/audio-engine/src/source.rs @@ -81,6 +81,13 @@ pub struct DecoderSource { tempo: Arc>, /// 本地缓冲,减少锁竞争 local_buffer: VecDeque, + /// 当前输出批次对应的源采样数,按实际 yield 进度推进播放时钟 + batch_source_samples: u64, + batch_output_samples: u64, + batch_output_emitted: u64, + batch_source_advanced: u64, + /// stretch 预热未产出时累积的源采样数 + pending_source_samples: u64, /// stretch 输出复用缓冲(避免每帧分配) tempo_scratch: Vec, high_pass_left: BiquadHighPass, @@ -105,6 +112,11 @@ impl DecoderSource { equalizer, tempo, local_buffer: VecDeque::new(), + batch_source_samples: 0, + batch_output_samples: 0, + batch_output_emitted: 0, + batch_source_advanced: 0, + pending_source_samples: 0, tempo_scratch: Vec::new(), high_pass_left: BiquadHighPass::new(), high_pass_right: BiquadHighPass::new(), @@ -114,6 +126,31 @@ impl DecoderSource { } } + fn advance_output_clock(&mut self) { + if self.batch_output_samples == 0 { + return; + } + self.batch_output_emitted += 1; + let target = self.batch_source_samples * self.batch_output_emitted / self.batch_output_samples; + let advance = target.saturating_sub(self.batch_source_advanced); + if advance > 0 { + self.shared.advance_consumed(advance); + self.batch_source_advanced = target; + } + if self.batch_output_emitted == self.batch_output_samples { + self.batch_source_samples = 0; + self.batch_output_samples = 0; + self.batch_output_emitted = 0; + self.batch_source_advanced = 0; + } + } + + fn next_buffered_sample(&mut self) -> Option { + let sample = self.local_buffer.pop_front()?; + self.advance_output_clock(); + Some(sample) + } + fn apply_high_pass_automation(&mut self, samples: &mut [f32]) { let Some(plan) = self.shared.high_pass_automation() else { return; @@ -140,7 +177,7 @@ impl Iterator for DecoderSource { fn next(&mut self) -> Option { // 快速路径:从本地缓冲返回(无原子操作) - if let Some(sample) = self.local_buffer.pop_front() { + if let Some(sample) = self.next_buffered_sample() { return Some(sample); } @@ -160,21 +197,23 @@ impl Iterator for DecoderSource { self.equalizer .lock() .process_interleaved_stereo(&mut samples); - // 源时间长度(按输入计数,与 speed 无关;让 consumed_position 反映源进度) let source_count = samples.len() as u64; - // 变速变调(bypass 时直接 extend,零开销) + self.pending_source_samples += source_count; self.tempo_scratch.clear(); self.tempo.lock().process(&samples, &mut self.tempo_scratch); if !self.tempo_scratch.is_empty() { self.limiter.process(&mut self.tempo_scratch); + self.batch_source_samples = self.pending_source_samples; + self.batch_output_samples = self.tempo_scratch.len() as u64; + self.batch_output_emitted = 0; + self.batch_source_advanced = 0; + self.pending_source_samples = 0; self.local_buffer.extend(self.tempo_scratch.drain(..)); } - self.shared.advance_consumed(source_count); - // stretch 在预热期可能本帧没产出,没样本就继续拉下一块 - let Some(s) = self.local_buffer.pop_front() else { + let Some(sample) = self.next_buffered_sample() else { continue; }; - return Some(s); + return Some(sample); } // 空数据块(重采样器预热期),继续获取下一个 } else { diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index ad4483e6..d3400f66 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -13,6 +13,10 @@ import { getPreloadedTrack, preloadNextTrack } from "./preload"; const PRELOAD_AHEAD_MS = 45_000; const FALLBACK_DURATION_SEC = 8; const MIN_CROSSFADE_SEC = 0.5; +const MAX_CROSSFADE_SEC = 12; +const MIN_BEAT_CONFIDENCE = 0.65; +const MAX_TEMPO_ADJUSTMENT = 0.04; +const ANALYSIS_DEADLINE_MS = 12_000; const TRIGGER_TOLERANCE_MS = 250; export type AutomixResult = "idle" | "transitioned" | "fallback-next"; @@ -34,6 +38,13 @@ let cancelToken = 0; const isLocalLikeSource = (source: string): boolean => !/^https?:\/\//i.test(source); +const settleBeforeDeadline = async (promises: Promise[]): Promise => { + await Promise.race([ + Promise.allSettled(promises).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, ANALYSIS_DEADLINE_MS)), + ]); +}; + const clampAnalyzeTime = (): number => { const raw = useSettingsStore().system.player.automixMaxAnalyzeTimeSec || 60; return Math.max(10, Math.min(300, raw)); @@ -78,6 +89,9 @@ const ensureAnalysisReady = async (currentSource: string, nextSource: string): P if (token !== cancelToken || currentAnalysisKey !== currentSource) return; currentAnalysis = result.success ? (result.data ?? null) : null; }) + .catch((error) => { + console.warn("[automix] current track analysis failed", error); + }) .finally(() => { if (currentAnalysisKey === currentSource) currentAnalysisInFlight = null; }); @@ -95,6 +109,9 @@ const ensureAnalysisReady = async (currentSource: string, nextSource: string): P if (token !== cancelToken || nextAnalysisKey !== nextSource) return; nextAnalysis = result.success ? (result.data ?? null) : null; }) + .catch((error) => { + console.warn("[automix] next track analysis failed", error); + }) .finally(() => { if (nextAnalysisKey === nextSource) nextAnalysisInFlight = null; }); @@ -111,16 +128,19 @@ const ensureAnalysisReady = async (currentSource: string, nextSource: string): P transitionProposal = transition.success ? (transition.data ?? null) : null; advancedTransition = longMix.success ? (longMix.data ?? null) : null; }) + .catch((error) => { + console.warn("[automix] transition analysis failed", error); + }) .finally(() => { if (transitionKey === `${currentSource}>>${nextSource}`) transitionInFlight = null; }); } - await Promise.all([ - currentAnalysisInFlight ?? Promise.resolve(), - nextAnalysisInFlight ?? Promise.resolve(), - transitionInFlight ?? Promise.resolve(), - ]); + await settleBeforeDeadline( + [currentAnalysisInFlight, nextAnalysisInFlight, transitionInFlight].filter( + (promise): promise is Promise => promise !== null, + ), + ); }; const applyAggressiveOutro = ( @@ -156,16 +176,44 @@ const applyAggressiveOutro = ( }; }; -const createFallbackPlan = (track: Track, index: number, durationSec: number): AutomixPlan => ({ - track, - index, - triggerTime: Math.max(0, durationSec - FALLBACK_DURATION_SEC), - crossfadeDuration: Math.min(FALLBACK_DURATION_SEC, durationSec), - startSeek: 0, - initialRate: 1, - uiSwitchDelay: FALLBACK_DURATION_SEC * 0.5, - mixType: "default", -}); +const normalizePlan = (plan: AutomixPlan, durationSec: number): AutomixPlan => { + const crossfadeDuration = Math.max( + MIN_CROSSFADE_SEC, + Math.min(plan.crossfadeDuration, MAX_CROSSFADE_SEC, durationSec), + ); + const triggerTime = Math.max(0, Math.min(plan.triggerTime, durationSec - crossfadeDuration)); + return { + ...plan, + triggerTime, + crossfadeDuration, + startSeek: Math.max(0, plan.startSeek), + initialRate: Math.max( + 1 - MAX_TEMPO_ADJUSTMENT, + Math.min(1 + MAX_TEMPO_ADJUSTMENT, plan.initialRate), + ), + uiSwitchDelay: crossfadeDuration * 0.5, + }; +}; + +const hasReliableBeatGrid = (analysis: AudioAnalysis | null): analysis is AudioAnalysis => + !!analysis?.bpm && + analysis.first_beat_pos !== undefined && + (analysis.bpm_confidence ?? 0) >= MIN_BEAT_CONFIDENCE; + +const createFallbackPlan = (track: Track, index: number, durationSec: number): AutomixPlan => + normalizePlan( + { + track, + index, + triggerTime: Math.max(0, durationSec - FALLBACK_DURATION_SEC), + crossfadeDuration: Math.min(FALLBACK_DURATION_SEC, durationSec), + startSeek: 0, + initialRate: 1, + uiSwitchDelay: FALLBACK_DURATION_SEC * 0.5, + mixType: "default", + }, + durationSec, + ); const computePlan = ( track: Track, @@ -180,18 +228,31 @@ const computePlan = ( transitionKey === `${currentSource}>>${nextSource}` ? transitionProposal : null; const advanced = transitionKey === `${currentSource}>>${nextSource}` ? advancedTransition : null; - if (advanced) { + const canUseAdvanced = + advanced && + current && + next && + hasReliableBeatGrid(current) && + hasReliableBeatGrid(next) && + advanced.playback_rate >= 1 - MAX_TEMPO_ADJUSTMENT && + advanced.playback_rate <= 1 + MAX_TEMPO_ADJUSTMENT && + advanced.duration >= MIN_CROSSFADE_SEC && + advanced.duration <= MAX_CROSSFADE_SEC; + if (canUseAdvanced) { const mixType = advanced.strategy.includes("Bass Swap") ? "bassSwap" : "default"; - return { - track, - index, - triggerTime: advanced.start_time_current, - crossfadeDuration: advanced.duration, - startSeek: advanced.start_time_next * 1000, - initialRate: advanced.playback_rate, - uiSwitchDelay: advanced.duration * 0.5, - mixType, - }; + return normalizePlan( + { + track, + index, + triggerTime: advanced.start_time_current, + crossfadeDuration: advanced.duration, + startSeek: advanced.start_time_next * 1000, + initialRate: advanced.playback_rate, + uiSwitchDelay: advanced.duration * 0.5, + mixType, + }, + durationSec, + ); } const canTrustExitPoint = !!current; @@ -218,29 +279,38 @@ const computePlan = ( let startSeek = 0; let initialRate = 1; let mixType: "default" | "bassSwap" = "default"; + let usedTransition = false; if (transition && transition.duration > MIN_CROSSFADE_SEC) { - const safeTrigger = Math.min(transition.current_track_mix_out, durationSec - 1); - triggerTime = safeTrigger; - crossfadeDuration = Math.min(transition.duration, durationSec - safeTrigger); - startSeek = transition.next_track_mix_in * 1000; - mixType = transition.filter_strategy.includes("Bass Swap") ? "bassSwap" : "default"; - } else if (current && next) { + const strategyNeedsBeatGrid = + transition.bpm_compatible || transition.filter_strategy.includes("Bass Swap"); + const canUseTransition = + !strategyNeedsBeatGrid || (hasReliableBeatGrid(current) && hasReliableBeatGrid(next)); + if (canUseTransition) { + const safeTrigger = Math.min(transition.current_track_mix_out, durationSec - 1); + triggerTime = safeTrigger; + crossfadeDuration = Math.min(transition.duration, durationSec - safeTrigger); + startSeek = transition.next_track_mix_in * 1000; + mixType = transition.filter_strategy.includes("Bass Swap") ? "bassSwap" : "default"; + usedTransition = true; + } + } + if (!usedTransition && current && next) { let rawTrigger = exitPoint - crossfadeDuration; - rawTrigger = snapToBeat(rawTrigger, current.bpm, current.first_beat_pos, true); + if (hasReliableBeatGrid(current)) { + rawTrigger = snapToBeat(rawTrigger, current.bpm, current.first_beat_pos, false); + } triggerTime = durationSec - rawTrigger < 4 ? exitPoint - crossfadeDuration : rawTrigger; startSeek = (next.fade_in_pos || 0) * 1000; - if (current.bpm && next.bpm) { - const confidenceA = current.bpm_confidence ?? 0; - const confidenceB = next.bpm_confidence ?? 0; + if (hasReliableBeatGrid(current) && hasReliableBeatGrid(next)) { const ratio = current.bpm / next.bpm; - if (confidenceA > 0.4 && confidenceB > 0.4 && ratio >= 0.97 && ratio <= 1.03) { + if (ratio >= 1 - MAX_TEMPO_ADJUSTMENT && ratio <= 1 + MAX_TEMPO_ADJUSTMENT) { initialRate = ratio; } } } - if (!advanced && canTrustExitPoint && current) { + if (!canUseAdvanced && canTrustExitPoint && current) { const outro = applyAggressiveOutro(current, triggerTime, crossfadeDuration, exitPoint); if (outro) { triggerTime = outro.triggerTime; @@ -252,16 +322,19 @@ const computePlan = ( } if (triggerTime < 0) triggerTime = 0; - return { - track, - index, - triggerTime, - crossfadeDuration, - startSeek, - initialRate, - uiSwitchDelay: crossfadeDuration * 0.5, - mixType, - }; + return normalizePlan( + { + track, + index, + triggerTime, + crossfadeDuration, + startSeek, + initialRate, + uiSwitchDelay: crossfadeDuration * 0.5, + mixType, + }, + durationSec, + ); }; /** 取消当前自动混音调度 */ @@ -295,6 +368,7 @@ export const tickAutomix = async ( if (!settings.system.player.automixEnabled || status.fmMode || status.trackLoading) { return "idle"; } + if (status.currentTrack?.cuePath) return "idle"; if ( !status.currentSource || !status.isPlaying || @@ -309,7 +383,7 @@ export const tickAutomix = async ( const prepared = getPreloadedTrack() ?? (remaining <= FALLBACK_DURATION_SEC * 1000 ? await preloadNextTrack() : null); - if (!prepared) return "idle"; + if (!prepared || prepared.track.cuePath) return "idle"; const durationSec = status.duration / 1000; const planKey = `${status.currentSource}|${prepared.source}|${prepared.track.id}|${prepared.index}`; @@ -335,8 +409,15 @@ export const tickAutomix = async ( if (transitioning) return "idle"; transitioning = true; - const ok = await playMixed(plan, prepared.resolved); - transitioning = false; - cancelAutomix(); - return ok ? "transitioned" : "fallback-next"; + try { + const ok = await playMixed(plan, prepared.resolved); + cancelAutomix(); + return ok ? "transitioned" : "fallback-next"; + } catch (error) { + console.error("[automix] crossfade failed", error); + cancelAutomix(); + return "fallback-next"; + } finally { + transitioning = false; + } }; From 3017de2118bc0d554dff846aae70d86403ebd51c Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:24:45 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(player):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=93=8D=E5=BA=A6=E6=A0=87=E5=87=86=E5=8C=96=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BB=A5=E5=B9=B3=E6=BB=91=E6=AD=8C=E6=9B=B2=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 computeLoudnessGainDb 函数计算新旧歌曲间的响度差异 - 在 AutomixPlan 中加入 loudnessGainDb 字段传递响度补偿值 - 扩展 AudioOptions 类型定义支持响度补偿参数 - 在音频引擎中接收并应用响度补偿值调整目标音量 - 更新 BassSwap 算法中高通滤波器的截止频率参数 - 优化高通滤波自动化逻辑并在完成后清理相关资源 --- electron/main/ipc/player.ts | 1 + native/audio-engine/index.d.ts | 2 ++ native/audio-engine/src/lib.rs | 12 ++++++++++- native/audio-engine/src/player.rs | 8 +++++++- native/audio-engine/src/shared.rs | 34 +++++++++++++++++++------------ native/audio-engine/src/source.rs | 25 ++++++++++++++++------- shared/types/player.ts | 4 ++++ src/core/player/automix.ts | 17 ++++++++++++++++ src/core/player/index.ts | 1 + 9 files changed, 82 insertions(+), 22 deletions(-) diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index eb2a4ad6..43568d6a 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -429,6 +429,7 @@ export const registerPlayerIpc = (): void => { startSeekMs: options.startSeekMs ?? 0, initialRate: options.initialRate ?? 1, mixType: options.mixType ?? "default", + loudnessGainDb: options.loudnessGainDb ?? 0, }); const durationMs = toMs(meta.duration); const commitDelayMs = Math.max( diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index adfb2fb3..43a70b12 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -185,6 +185,8 @@ export interface JsCrossfadeOptions { initialRate?: number /** 混音策略类型 */ mixType?: string + /** 新曲响度补偿(dB),正值升响,负值降响,限制在 ±6dB 内 */ + loudnessGainDb?: number } /** 一条外部歌词,返回给 JS 侧(仅格式和路径,内容按需加载) */ diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 80a8937a..1a5edf26 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -131,6 +131,9 @@ pub struct JsCrossfadeOptions { /// 混音策略类型 #[napi(js_name = "mixType")] pub mix_type: Option, + /// 新曲响度补偿(dB),正值升响,负值降响,限制在 ±6dB 内 + #[napi(js_name = "loudnessGainDb")] + pub loudness_gain_db: Option, } /// 音频输出设备信息 @@ -408,7 +411,13 @@ impl AudioPlayer { .as_ref() .and_then(|o| o.mix_type.as_deref()) .unwrap_or("default"); - info!(source = %source, duration_ms, start_seek_secs, initial_rate, mix_type, "交叉混音到音频源"); + let loudness_gain_db = options + .as_ref() + .and_then(|o| o.loudness_gain_db) + .filter(|v| v.is_finite()) + .unwrap_or(0.0) + .clamp(-6.0, 6.0) as f32; + info!(source = %source, duration_ms, start_seek_secs, initial_rate, mix_type, loudness_gain_db, "交叉混音到音频源"); let take = { let mut player = self.inner.lock(); @@ -490,6 +499,7 @@ impl AudioPlayer { start_seek_secs, initial_rate, mix_type, + loudness_gain_db, ) .into_napi()? }; diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index f0caa9a4..1ec1bcb5 100644 --- a/native/audio-engine/src/player.rs +++ b/native/audio-engine/src/player.rs @@ -785,6 +785,7 @@ impl InnerPlayer { start_seek_secs: f64, initial_rate: f32, mix_type: &str, + loudness_gain_db: f32, ) -> Result> { if token != self.load_token.load(Ordering::Acquire) { shared.stop(); @@ -839,10 +840,15 @@ impl InnerPlayer { self.start_fft_timer(); let target_volume = self.target_volume; + let effective_target = if loudness_gain_db.abs() > 0.001 { + (target_volume * crate::metadata::db_to_linear(loudness_gain_db)).clamp(0.0, 2.0) + } else { + target_volume + }; let cancel = Arc::new(AtomicBool::new(false)); self.fade_cancel = Some(Arc::clone(&cancel)); let handle = thread::spawn(move || { - crossfade_volume(old, sink, target_volume, duration_ms, &cancel); + crossfade_volume(old, sink, effective_target, duration_ms, &cancel); }); self.fade_handle = Some(handle); diff --git a/native/audio-engine/src/shared.rs b/native/audio-engine/src/shared.rs index d6b569ec..22ee3dba 100644 --- a/native/audio-engine/src/shared.rs +++ b/native/audio-engine/src/shared.rs @@ -22,35 +22,38 @@ pub struct HighPassAutomation { } impl HighPassAutomation { - pub(crate) fn cutoff_at(self, consumed_samples: u64) -> f32 { + pub(crate) fn cutoff_at(self, consumed_samples: u64) -> Option { const BYPASS_FREQ: f32 = 10.0; - const SWAP_FREQ: f32 = 400.0; + const SWAP_FREQ: f32 = 150.0; let elapsed_samples = consumed_samples.saturating_sub(self.start_samples); let elapsed_ms = elapsed_samples as f32 * 1000.0 / self.sample_rate as f32 / self.channels as f32; let duration_ms = self.duration_ms.max(1) as f32; let mid_ms = duration_ms * 0.5; - match self.role { + let cutoff = match self.role { HighPassRole::FadeOut => { if elapsed_ms >= mid_ms { - return SWAP_FREQ; + SWAP_FREQ + } else { + let t = (elapsed_ms / mid_ms.max(1.0)).clamp(0.0, 1.0); + BYPASS_FREQ + (SWAP_FREQ - BYPASS_FREQ) * t } - let t = (elapsed_ms / mid_ms.max(1.0)).clamp(0.0, 1.0); - BYPASS_FREQ + (SWAP_FREQ - BYPASS_FREQ) * t } HighPassRole::FadeIn => { let release_ms = (duration_ms * 0.25).min(600.0); let release_end = mid_ms + release_ms; if elapsed_ms <= mid_ms { - return SWAP_FREQ; + SWAP_FREQ + } else if elapsed_ms >= release_end { + return None; // 已完成,清除自动化 + } else { + let t = ((elapsed_ms - mid_ms) / release_ms.max(1.0)).clamp(0.0, 1.0); + SWAP_FREQ + (BYPASS_FREQ - SWAP_FREQ) * t } - if elapsed_ms >= release_end { - return BYPASS_FREQ; - } - let t = ((elapsed_ms - mid_ms) / release_ms.max(1.0)).clamp(0.0, 1.0); - SWAP_FREQ + (BYPASS_FREQ - SWAP_FREQ) * t } - } + }; + // FadeOut 在中点后固定 SWAP_FREQ,等 Crossfade 结束槽被释放即清除 + Some(cutoff) } } @@ -172,6 +175,11 @@ impl Shared { }); } + /// 清除高通滤波自动化(FadeIn 完成后由播放线程调用) + pub fn clear_high_pass_automation(&self) { + *self.high_pass_automation.lock() = None; + } + /// 获取当前高通滤波包络快照,播放线程按 chunk 读取避免持锁处理采样 pub fn high_pass_automation(&self) -> Option { *self.high_pass_automation.lock() diff --git a/native/audio-engine/src/source.rs b/native/audio-engine/src/source.rs index 3631ae44..90399ff3 100644 --- a/native/audio-engine/src/source.rs +++ b/native/audio-engine/src/source.rs @@ -159,15 +159,26 @@ impl DecoderSource { return; } let base_samples = self.shared.samples_consumed_count(); + let mut completed = false; for (frame_index, frame) in samples.chunks_exact_mut(2).enumerate() { let consumed = base_samples + frame_index as u64 * 2; - let cutoff = plan.cutoff_at(consumed); - frame[0] = self - .high_pass_left - .process(frame[0], cutoff, self.sample_rate); - frame[1] = self - .high_pass_right - .process(frame[1], cutoff, self.sample_rate); + match plan.cutoff_at(consumed) { + Some(cutoff) => { + frame[0] = self.high_pass_left.process(frame[0], cutoff, self.sample_rate); + frame[1] = self.high_pass_right.process(frame[1], cutoff, self.sample_rate); + } + None => { + // FadeIn 自动化已完成,后续帧不再经过高通,同一批内不处理 + completed = true; + break; + } + } + } + if completed { + self.shared.clear_high_pass_automation(); + // 滤波器历史清零,避免下次意外复用残留状态 + self.high_pass_left = BiquadHighPass::new(); + self.high_pass_right = BiquadHighPass::new(); } } } diff --git a/shared/types/player.ts b/shared/types/player.ts index 0ebbd6de..2ed5243c 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -164,6 +164,8 @@ export interface CrossfadeOptions extends LoadOptions { mixType?: "default" | "bassSwap"; /** UI 与媒体状态提交延迟(毫秒) */ uiSwitchDelayMs?: number; + /** 新曲响度补偿(dB),用于对齐过渡区短时响度;正值升响,负值降响 */ + loudnessGainDb?: number; } /** 音频分析结果 */ @@ -236,6 +238,8 @@ export interface AutomixPlan { initialRate: number; uiSwitchDelay: number; mixType: "default" | "bassSwap"; + /** 新曲相对旧曲的响度补偿(dB,正值升响,负值降响,限制 ±6dB) */ + loudnessGainDb?: number; } /** 播放器状态快照 */ diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index d3400f66..b23f8b0f 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -176,6 +176,21 @@ const applyAggressiveOutro = ( }; }; +const MAX_LOUDNESS_CORRECTION_DB = 6; + +const computeLoudnessGainDb = ( + current: AudioAnalysis | null, + next: AudioAnalysis | null, +): number => { + const outgoingLufs = current?.loudness ?? null; + const incomingLufs = next?.loudness ?? null; + if (outgoingLufs === null || incomingLufs === null) return 0; + if (!Number.isFinite(outgoingLufs) || !Number.isFinite(incomingLufs)) return 0; + // 旧曲比新曲响时,新曲需要升响(正值);新曲比旧曲响时,需要降响(负值) + const delta = outgoingLufs - incomingLufs; + return Math.max(-MAX_LOUDNESS_CORRECTION_DB, Math.min(MAX_LOUDNESS_CORRECTION_DB, delta)); +}; + const normalizePlan = (plan: AutomixPlan, durationSec: number): AutomixPlan => { const crossfadeDuration = Math.max( MIN_CROSSFADE_SEC, @@ -250,6 +265,7 @@ const computePlan = ( initialRate: advanced.playback_rate, uiSwitchDelay: advanced.duration * 0.5, mixType, + loudnessGainDb: computeLoudnessGainDb(current, next), }, durationSec, ); @@ -332,6 +348,7 @@ const computePlan = ( initialRate, uiSwitchDelay: crossfadeDuration * 0.5, mixType, + loudnessGainDb: computeLoudnessGainDb(current, next), }, durationSec, ); diff --git a/src/core/player/index.ts b/src/core/player/index.ts index d5646a9b..01d760bc 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -339,6 +339,7 @@ export const crossfadeToTrack = async ( initialRate: plan.initialRate, mixType: plan.mixType, uiSwitchDelayMs: plan.uiSwitchDelay * 1000, + loudnessGainDb: plan.loudnessGainDb ?? 0, meta: track, }); if (myTrackToken !== trackToken || myLoadToken !== loadToken) return false; From d9f1428a17fe7699d44503e5e3ed091c9fe0a8f2 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:08:48 +0800 Subject: [PATCH 06/11] =?UTF-8?q?refactor(audio-engine):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=87=AA=E5=8A=A8=E6=B7=B7=E9=9F=B3=E5=88=86=E6=9E=90?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将原有的四次独立分析请求合并为单次双曲分析 - 减少文件IO操作,提升分析效率 - 引入PairAnalysisResult结构统一管理双曲分析结果 - 移除过时的单独过渡建议和长段混音建议函数 - 重构自动化混音逻辑以适配新的分析接口 --- electron/main/ipc/player.ts | 16 ++- electron/preload/index.ts | 3 + native/audio-engine/index.d.ts | 19 +++- native/audio-engine/src/analysis.rs | 89 +++++++++------- shared/types/player.ts | 14 +++ src/core/player/automix.ts | 160 ++++++++++------------------ 6 files changed, 154 insertions(+), 147 deletions(-) diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 43568d6a..70a27147 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -61,7 +61,8 @@ type AnalysisWorkerMethod = | "analyzeAudioFile" | "analyzeAudioFileHead" | "suggestTransition" - | "suggestLongMix"; + | "suggestLongMix" + | "analyzePair"; const ANALYSIS_WORKER_SOURCE = ` const { parentPort, workerData } = require("node:worker_threads"); @@ -576,6 +577,19 @@ export const registerPlayerIpc = (): void => { return fail(ErrorCode.UNKNOWN, error); } }); + ipcMain.handle( + "player:analyzePair", + async (_event, currentPath: string, nextPath: string, currentMaxTimeSec?: number) => { + try { + return { + success: true, + data: await runAnalysisWorker("analyzePair", [currentPath, nextPath, currentMaxTimeSec]), + }; + } catch (error) { + return fail(ErrorCode.UNKNOWN, error); + } + }, + ); // 恢复播放 ipcMain.handle("player:play", async () => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8ed4dbd5..9348095a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -61,6 +61,9 @@ const api = { // 计算两首歌的长段混音建议 suggestLongMix: (currentPath: string, nextPath: string) => ipcRenderer.invoke("player:suggestLongMix", currentPath, nextPath), + // 单次双曲分析:一次调用同时得到两轨分析结果与过渡建议 + analyzePair: (currentPath: string, nextPath: string, currentMaxTimeSec?: number) => + ipcRenderer.invoke("player:analyzePair", currentPath, nextPath, currentMaxTimeSec), // 取消下一首预载 cancelPreload: () => ipcRenderer.invoke("player:cancelPreload"), // 恢复播放 diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index 43a70b12..6c67ee46 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -121,6 +121,9 @@ export declare function analyzeAudioFile(path: string, maxAnalyzeTime?: number | export declare function analyzeAudioFileHead(path: string, maxAnalyzeTime?: number | undefined | null): AudioAnalysis | null +/** 单次双曲分析:仅打开每个文件一次,同时计算过渡建议与长段混音建议 */ +export declare function analyzePair(currentPath: string, nextPath: string, currentMaxTimeSec?: number | undefined | null): PairAnalysisResult | null + export interface AudioAnalysis { duration: number bpm?: number @@ -349,6 +352,18 @@ export interface JsTrackTags { */ export declare function makeImageThumbnail(data: Buffer, maxSize: number): Promise +/** 单次双曲分析结果:两首歌共享一次文件打开,减少重复 IO */ +export interface PairAnalysisResult { + /** 当前曲完整分析 */ + current: AudioAnalysis + /** 下一首头部分析 */ + next: AudioAnalysis + /** 过渡建议(从已分析数据直接计算,无额外 IO) */ + transition?: TransitionProposal + /** 长段混音建议(从已分析数据直接计算,无额外 IO) */ + longMix?: AdvancedTransition +} + /** 读取文件的可编辑标签(异步,阻塞 IO 在 tokio 阻塞线程执行) */ export declare function readTrackTags(path: string): Promise @@ -360,10 +375,6 @@ export declare function readTrackTags(path: string): Promise */ export declare function scanDirs(dirs: Array, callback: (event: JsScanEvent) => void, coverCacheDir?: string | undefined | null, incrementalData?: Array | undefined | null): void -export declare function suggestLongMix(currentPath: string, nextPath: string): AdvancedTransition | null - -export declare function suggestTransition(currentPath: string, nextPath: string): TransitionProposal | null - export interface TransitionProposal { duration: number current_track_mix_out: number diff --git a/native/audio-engine/src/analysis.rs b/native/audio-engine/src/analysis.rs index 8ee4da15..3e1389ef 100644 --- a/native/audio-engine/src/analysis.rs +++ b/native/audio-engine/src/analysis.rs @@ -116,7 +116,18 @@ pub struct AdvancedTransition { pub strategy: String, } -// --- Constants --- +/// 单次双曲分析结果:两首歌共享一次文件打开,减少重复 IO +#[napi(object)] +pub struct PairAnalysisResult { + /// 当前曲完整分析 + pub current: AudioAnalysis, + /// 下一首头部分析 + pub next: AudioAnalysis, + /// 过渡建议(从已分析数据直接计算,无额外 IO) + pub transition: Option, + /// 长段混音建议(从已分析数据直接计算,无额外 IO) + pub long_mix: Option, +} const ENV_RATE: f64 = 50.0; const WINDOW_SIZE_MS: usize = 20; @@ -1108,11 +1119,7 @@ const STRATEGIES: &[MixStrategy] = &[ MixStrategy::new("Quick Blend", "Quick Fade", 4.0, true, false), ]; -#[napi] -pub fn suggest_transition(current_path: String, next_path: String) -> Option { - let cur = TrackAnalyzer::new(current_path, None, true).analyze()?; - let next = TrackAnalyzer::new(next_path, Some(120.0), false).analyze()?; - +fn transition_from_analysis(cur: &AudioAnalysis, next: &AudioAnalysis) -> TransitionProposal { let bpm_a = cur.bpm.unwrap_or(128.0); let bpm_b = next.bpm.unwrap_or(128.0); let bpm_compatible = (bpm_a - bpm_b).abs() / bpm_a < 0.06; @@ -1122,10 +1129,8 @@ pub fn suggest_transition(current_path: String, next_path: String) -> Option Option Option dur { - return Some(TransitionProposal { + return TransitionProposal { duration: dur, current_track_mix_out: cur_out - dur, next_track_mix_in: next_in, @@ -1171,12 +1170,11 @@ pub fn suggest_transition(current_path: String, next_path: String) -> Option Option Option { - let cur = TrackAnalyzer::new(current_path, None, true).analyze()?; - let next = TrackAnalyzer::new(next_path, Some(180.0), false).analyze()?; - +fn long_mix_from_analysis(cur: &AudioAnalysis, next: &AudioAnalysis) -> AdvancedTransition { let bpm_a = cur.bpm.unwrap_or(128.0); let bpm_b = next.bpm.unwrap_or(128.0); let playback_rate = bpm_a / bpm_b; - let target_bars = 32.0; let sec_per_bar = 240.0 / bpm_a; let duration = target_bars * sec_per_bar; - - // Anchor: End of Current Bass -> Start of Next Bass (Drop) - let cur_end = cur.duration - 5.0; // Near end + let cur_end = cur.duration - 5.0; let next_start = next .drop_pos .or(next.vocal_in_pos) .unwrap_or(32.0 * 240.0 / bpm_b); - - // Automation let (auto_a, auto_b) = generate_bass_swap_automation(duration); - - Some(AdvancedTransition { + AdvancedTransition { start_time_current: (cur_end - duration).max(0.0), start_time_next: (next_start - duration / playback_rate).max(0.0), duration, - pitch_shift_semitones: 0, // Simplified for now + pitch_shift_semitones: 0, playback_rate, automation_current: auto_a, automation_next: auto_b, strategy: "Long Bass Swap".to_string(), + } +} + +/// 单次双曲分析:仅打开每个文件一次,同时计算过渡建议与长段混音建议 +#[napi] +pub fn analyze_pair( + current_path: String, + next_path: String, + current_max_time_sec: Option, +) -> Option { + let max_time = current_max_time_sec + .map(|t| t) + .unwrap_or(60.0) + .clamp(10.0, 300.0); + let cur = TrackAnalyzer::new(current_path, Some(max_time), true).analyze()?; + // 下一首只需头部,但分析窗口取两个子函数中较大值(180s) + let next = TrackAnalyzer::new(next_path, Some(180.0), false).analyze()?; + let transition = transition_from_analysis(&cur, &next); + let long_mix = long_mix_from_analysis(&cur, &next); + Some(PairAnalysisResult { + current: cur, + next, + transition: Some(transition), + long_mix: Some(long_mix), }) } + // --- Utils --- fn get_camelot_key(root: i32, mode: i32) -> Option { diff --git a/shared/types/player.ts b/shared/types/player.ts index 2ed5243c..bbb4cd36 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -284,6 +284,14 @@ export interface IpcResponse { error?: string; } +/** 单次双曲分析结果 */ +export interface PairAnalysisResult { + current: AudioAnalysis; + next: AudioAnalysis; + transition: TransitionProposal | null; + long_mix: AdvancedTransition | null; +} + /** 播放器 API */ export interface PlayerApi { /** 加载音频(本地路径或网络地址)。可选下发权威 meta 用于 SMTC/托盘 */ @@ -312,6 +320,12 @@ export interface PlayerApi { currentPath: string, nextPath: string, ) => Promise>; + /** 单次双曲分析:一次调用同时获取两轨特征与过渡建议 */ + analyzePair: ( + currentPath: string, + nextPath: string, + currentMaxTimeSec?: number, + ) => Promise>; /** 取消下一首预载 */ cancelPreload: () => Promise; /** 恢复播放 */ diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index b23f8b0f..272f671e 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -2,6 +2,7 @@ import type { AdvancedTransition, AudioAnalysis, AutomixPlan, + PairAnalysisResult, Track, TransitionProposal, } from "@shared/types/player"; @@ -16,35 +17,26 @@ const MIN_CROSSFADE_SEC = 0.5; const MAX_CROSSFADE_SEC = 12; const MIN_BEAT_CONFIDENCE = 0.65; const MAX_TEMPO_ADJUSTMENT = 0.04; -const ANALYSIS_DEADLINE_MS = 12_000; +/** 距触发时间低于此值时强制计划生成,不再等待分析完成 */ +const PLAN_DEADLINE_MS = 8_000; const TRIGGER_TOLERANCE_MS = 250; export type AutomixResult = "idle" | "transitioned" | "fallback-next"; export type AutomixPlay = (plan: AutomixPlan, resolved: ResolvedTrackSource) => Promise; -let currentAnalysisKey: string | null = null; +/** 双曲分析状态机:单次 analyzePair 替代原来的4个并发请求 */ +let pairKey: string | null = null; let currentAnalysis: AudioAnalysis | null = null; -let currentAnalysisInFlight: Promise | null = null; -let nextAnalysisKey: string | null = null; let nextAnalysis: AudioAnalysis | null = null; -let nextAnalysisInFlight: Promise | null = null; -let transitionKey: string | null = null; let transitionProposal: TransitionProposal | null = null; let advancedTransition: AdvancedTransition | null = null; -let transitionInFlight: Promise | null = null; +let pairAnalysisInFlight: Promise | null = null; let scheduledPlan: AutomixPlan | null = null; let transitioning = false; let cancelToken = 0; const isLocalLikeSource = (source: string): boolean => !/^https?:\/\//i.test(source); -const settleBeforeDeadline = async (promises: Promise[]): Promise => { - await Promise.race([ - Promise.allSettled(promises).then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ANALYSIS_DEADLINE_MS)), - ]); -}; - const clampAnalyzeTime = (): number => { const raw = useSettingsStore().system.player.automixMaxAnalyzeTimeSec || 60; return Math.max(10, Math.min(300, raw)); @@ -63,84 +55,39 @@ const snapToBeat = ( return firstBeat + units * interval; }; -const resetTransitionCache = (currentSource: string, nextSource: string): void => { +/** + * 后台异步分析当前曲/下一首对。 + * 不 await — 调用后立即返回;tickAutomix 每次 tick 读取模块级状态快照。 + */ +const kickPairAnalysis = (currentSource: string, nextSource: string): void => { + if (!isLocalLikeSource(currentSource) || !isLocalLikeSource(nextSource)) return; const key = `${currentSource}>>${nextSource}`; - if (transitionKey === key) return; - transitionKey = key; + if (pairKey === key) return; // 同一对,已在飞行中或已完成,跳过 + pairKey = key; + currentAnalysis = null; + nextAnalysis = null; transitionProposal = null; advancedTransition = null; - transitionInFlight = null; -}; - -const ensureAnalysisReady = async (currentSource: string, nextSource: string): Promise => { - if (!isLocalLikeSource(currentSource) || !isLocalLikeSource(nextSource)) return; - const analyzeTime = clampAnalyzeTime(); const token = cancelToken; - - if (currentAnalysisKey !== currentSource) { - currentAnalysisKey = currentSource; - currentAnalysis = null; - currentAnalysisInFlight = null; - } - if (!currentAnalysis && !currentAnalysisInFlight) { - currentAnalysisInFlight = window.api.player - .analyzeAudioFile(currentSource, analyzeTime) - .then((result) => { - if (token !== cancelToken || currentAnalysisKey !== currentSource) return; - currentAnalysis = result.success ? (result.data ?? null) : null; - }) - .catch((error) => { - console.warn("[automix] current track analysis failed", error); - }) - .finally(() => { - if (currentAnalysisKey === currentSource) currentAnalysisInFlight = null; - }); - } - - if (nextAnalysisKey !== nextSource) { - nextAnalysisKey = nextSource; - nextAnalysis = null; - nextAnalysisInFlight = null; - } - if (!nextAnalysis && !nextAnalysisInFlight) { - nextAnalysisInFlight = window.api.player - .analyzeAudioFileHead(nextSource, analyzeTime) - .then((result) => { - if (token !== cancelToken || nextAnalysisKey !== nextSource) return; - nextAnalysis = result.success ? (result.data ?? null) : null; - }) - .catch((error) => { - console.warn("[automix] next track analysis failed", error); - }) - .finally(() => { - if (nextAnalysisKey === nextSource) nextAnalysisInFlight = null; - }); - } - - resetTransitionCache(currentSource, nextSource); - if (!transitionProposal && !advancedTransition && !transitionInFlight) { - transitionInFlight = Promise.all([ - window.api.player.suggestTransition(currentSource, nextSource), - window.api.player.suggestLongMix(currentSource, nextSource), - ]) - .then(([transition, longMix]) => { - if (token !== cancelToken || transitionKey !== `${currentSource}>>${nextSource}`) return; - transitionProposal = transition.success ? (transition.data ?? null) : null; - advancedTransition = longMix.success ? (longMix.data ?? null) : null; - }) - .catch((error) => { - console.warn("[automix] transition analysis failed", error); - }) - .finally(() => { - if (transitionKey === `${currentSource}>>${nextSource}`) transitionInFlight = null; - }); - } - - await settleBeforeDeadline( - [currentAnalysisInFlight, nextAnalysisInFlight, transitionInFlight].filter( - (promise): promise is Promise => promise !== null, - ), - ); + const analyzeTime = clampAnalyzeTime(); + pairAnalysisInFlight = window.api.player + .analyzePair(currentSource, nextSource, analyzeTime) + .then((result) => { + if (token !== cancelToken || pairKey !== key) return; + const data = result.success ? (result.data ?? null) : null; + if (data) { + currentAnalysis = data.current; + nextAnalysis = data.next; + transitionProposal = data.transition; + advancedTransition = data.long_mix; + } + }) + .catch((error: unknown) => { + console.warn("[automix] pair analysis failed", error); + }) + .finally(() => { + if (pairKey === key) pairAnalysisInFlight = null; + }); }; const applyAggressiveOutro = ( @@ -237,11 +184,11 @@ const computePlan = ( nextSource: string, durationSec: number, ): AutomixPlan => { - const current = currentAnalysisKey === currentSource ? currentAnalysis : null; - const next = nextAnalysisKey === nextSource ? nextAnalysis : null; - const transition = - transitionKey === `${currentSource}>>${nextSource}` ? transitionProposal : null; - const advanced = transitionKey === `${currentSource}>>${nextSource}` ? advancedTransition : null; + const expectedKey = `${currentSource}>>${nextSource}`; + const current = pairKey === expectedKey ? currentAnalysis : null; + const next = pairKey === expectedKey ? nextAnalysis : null; + const transition = pairKey === expectedKey ? transitionProposal : null; + const advanced = pairKey === expectedKey ? advancedTransition : null; const canUseAdvanced = advanced && @@ -326,7 +273,7 @@ const computePlan = ( } } - if (!canUseAdvanced && canTrustExitPoint && current) { + if (!advanced && !usedTransition && canTrustExitPoint && current) { const outro = applyAggressiveOutro(current, triggerTime, crossfadeDuration, exitPoint); if (outro) { triggerTime = outro.triggerTime; @@ -357,16 +304,12 @@ const computePlan = ( /** 取消当前自动混音调度 */ export const cancelAutomix = (): void => { cancelToken++; - currentAnalysisKey = null; + pairKey = null; currentAnalysis = null; - currentAnalysisInFlight = null; - nextAnalysisKey = null; nextAnalysis = null; - nextAnalysisInFlight = null; - transitionKey = null; transitionProposal = null; advancedTransition = null; - transitionInFlight = null; + pairAnalysisInFlight = null; scheduledPlan = null; transitioning = false; }; @@ -402,14 +345,23 @@ export const tickAutomix = async ( (remaining <= FALLBACK_DURATION_SEC * 1000 ? await preloadNextTrack() : null); if (!prepared || prepared.track.cuePath) return "idle"; + // 预载完成后立即后台启动分析,不 await,让分析在后台运行 + kickPairAnalysis(status.currentSource, prepared.source); + const durationSec = status.duration / 1000; const planKey = `${status.currentSource}|${prepared.source}|${prepared.track.id}|${prepared.index}`; - if ( + const planStale = !scheduledPlan || planKey !== - `${status.currentSource}|${prepared.source}|${scheduledPlan.track.id}|${scheduledPlan.index}` - ) { - await ensureAnalysisReady(status.currentSource, prepared.source); + `${status.currentSource}|${prepared.source}|${scheduledPlan.track.id}|${scheduledPlan.index}`; + + if (planStale) { + // 分析仍在飞行中且距曲末还有充足时间:推迟计划生成,等分析完成后会更准确 + const analysisRunning = !!pairAnalysisInFlight; + const mustDecideNow = remaining <= PLAN_DEADLINE_MS + FALLBACK_DURATION_SEC * 1000; + if (analysisRunning && !mustDecideNow) { + return "idle"; + } scheduledPlan = computePlan( prepared.track, prepared.index, From 6354c85fd657665ebe1e3830a17df3db6cd933bf Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:27:08 +0800 Subject: [PATCH 07/11] =?UTF-8?q?refactor(player):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=AB=98=E7=BA=A7=E8=BF=87=E6=B8=A1=E5=8A=9F=E8=83=BD=E4=BB=A5?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E8=87=AA=E5=8A=A8=E6=B7=B7=E9=9F=B3=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 PairAnalysisResult 类型引用 - 移除高级过渡相关代码块和计算逻辑 - 简化过渡策略判断条件 - 优化自动混音计划生成流程 - 清理无用的过渡参数和变量声明 - 统一混音策略处理方式 --- src/core/player/automix.ts | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index 272f671e..04e4c0e4 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -2,7 +2,6 @@ import type { AdvancedTransition, AudioAnalysis, AutomixPlan, - PairAnalysisResult, Track, TransitionProposal, } from "@shared/types/player"; @@ -188,36 +187,6 @@ const computePlan = ( const current = pairKey === expectedKey ? currentAnalysis : null; const next = pairKey === expectedKey ? nextAnalysis : null; const transition = pairKey === expectedKey ? transitionProposal : null; - const advanced = pairKey === expectedKey ? advancedTransition : null; - - const canUseAdvanced = - advanced && - current && - next && - hasReliableBeatGrid(current) && - hasReliableBeatGrid(next) && - advanced.playback_rate >= 1 - MAX_TEMPO_ADJUSTMENT && - advanced.playback_rate <= 1 + MAX_TEMPO_ADJUSTMENT && - advanced.duration >= MIN_CROSSFADE_SEC && - advanced.duration <= MAX_CROSSFADE_SEC; - if (canUseAdvanced) { - const mixType = advanced.strategy.includes("Bass Swap") ? "bassSwap" : "default"; - return normalizePlan( - { - track, - index, - triggerTime: advanced.start_time_current, - crossfadeDuration: advanced.duration, - startSeek: advanced.start_time_next * 1000, - initialRate: advanced.playback_rate, - uiSwitchDelay: advanced.duration * 0.5, - mixType, - loudnessGainDb: computeLoudnessGainDb(current, next), - }, - durationSec, - ); - } - const canTrustExitPoint = !!current; const vocalOut = current?.vocal_out_pos; let rawFadeOut = current ? current.fade_out_pos || durationSec : durationSec; @@ -273,7 +242,7 @@ const computePlan = ( } } - if (!advanced && !usedTransition && canTrustExitPoint && current) { + if (!usedTransition && canTrustExitPoint && current) { const outro = applyAggressiveOutro(current, triggerTime, crossfadeDuration, exitPoint); if (outro) { triggerTime = outro.triggerTime; From 4ca6546fbc0e09e79dc73eb6b3c633e6e8835b8d Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:31:36 +0800 Subject: [PATCH 08/11] =?UTF-8?q?refactor(player):=20=E7=A7=BB=E9=99=A4=20?= =?UTF-8?q?automix=20=E6=A8=A1=E5=9D=97=E4=B8=AD=E7=9A=84=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=E8=BF=87=E6=B8=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从类型导入中删除 AdvancedTransition 类型 - 从模块作用域变量中移除 advancedTransition 变量 - 在清理函数中移除对 advancedTransition 的重置操作 - 从分析数据处理中移除 long_mix 数据赋值逻辑 --- src/core/player/automix.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index 04e4c0e4..30a0cf24 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -1,5 +1,4 @@ import type { - AdvancedTransition, AudioAnalysis, AutomixPlan, Track, @@ -28,7 +27,6 @@ let pairKey: string | null = null; let currentAnalysis: AudioAnalysis | null = null; let nextAnalysis: AudioAnalysis | null = null; let transitionProposal: TransitionProposal | null = null; -let advancedTransition: AdvancedTransition | null = null; let pairAnalysisInFlight: Promise | null = null; let scheduledPlan: AutomixPlan | null = null; let transitioning = false; @@ -66,7 +64,6 @@ const kickPairAnalysis = (currentSource: string, nextSource: string): void => { currentAnalysis = null; nextAnalysis = null; transitionProposal = null; - advancedTransition = null; const token = cancelToken; const analyzeTime = clampAnalyzeTime(); pairAnalysisInFlight = window.api.player @@ -78,7 +75,6 @@ const kickPairAnalysis = (currentSource: string, nextSource: string): void => { currentAnalysis = data.current; nextAnalysis = data.next; transitionProposal = data.transition; - advancedTransition = data.long_mix; } }) .catch((error: unknown) => { @@ -277,7 +273,6 @@ export const cancelAutomix = (): void => { currentAnalysis = null; nextAnalysis = null; transitionProposal = null; - advancedTransition = null; pairAnalysisInFlight = null; scheduledPlan = null; transitioning = false; From 932204513ad39b3ef198dcad5b7b14f8221dddac Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:34:05 +0800 Subject: [PATCH 09/11] =?UTF-8?q?style(player):=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96automix=E6=A8=A1=E5=9D=97=E7=9A=84=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=AF=AD=E5=8F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将多行类型导入合并为单行导入语句 - 移除不必要的换行和空格 - 统一导入语句的代码风格 --- src/core/player/automix.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index 30a0cf24..9f1a010f 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -1,9 +1,4 @@ -import type { - AudioAnalysis, - AutomixPlan, - Track, - TransitionProposal, -} from "@shared/types/player"; +import type { AudioAnalysis, AutomixPlan, Track, TransitionProposal } from "@shared/types/player"; import type { ResolvedTrackSource } from "@/services/audioSource"; import { useSettingsStore } from "@/stores/settings"; import { useStatusStore } from "@/stores/status"; From 3411781112bfd6359753c8f07fd9be0daa44e835 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:39:14 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(player):=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B7=B7=E9=9F=B3=E4=B8=AD=E7=9A=84BPM?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在BPM计算中添加非空断言操作符以避免类型检查错误 - 确保当前曲目和下一曲目的BPM值都被正确处理 - 保持原有的节拍网格检测和速率调整逻辑不变 --- src/core/player/automix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts index 9f1a010f..023af0e1 100644 --- a/src/core/player/automix.ts +++ b/src/core/player/automix.ts @@ -226,7 +226,7 @@ const computePlan = ( triggerTime = durationSec - rawTrigger < 4 ? exitPoint - crossfadeDuration : rawTrigger; startSeek = (next.fade_in_pos || 0) * 1000; if (hasReliableBeatGrid(current) && hasReliableBeatGrid(next)) { - const ratio = current.bpm / next.bpm; + const ratio = current.bpm! / next.bpm!; if (ratio >= 1 - MAX_TEMPO_ADJUSTMENT && ratio <= 1 + MAX_TEMPO_ADJUSTMENT) { initialRate = ratio; } From 0146d27b2eddacc63a3b626383387b7cee44e3dd Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:30:43 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix(audio):=20=E8=A7=A3=E5=86=B3=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E8=A7=A3=E7=A0=81=E5=99=A8=E9=94=99=E8=AF=AF=E4=BC=A0?= =?UTF-8?q?=E6=92=AD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 decoder.rs 中为 resume_decode 调用添加错误传播操作符 - 在 lib.rs 中为 resume_decode 调用添加 into_napi 错误转换 - 修复 SideBar.vue 中路由跳转的代码格式问题 --- native/audio-engine/src/decoder.rs | 2 +- native/audio-engine/src/lib.rs | 3 ++- src/components/layout/SideBar.vue | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/native/audio-engine/src/decoder.rs b/native/audio-engine/src/decoder.rs index fcdf3f83..1f68ed40 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -167,7 +167,7 @@ pub fn start_decode_at( anyhow::bail!("音频 seek 失败"); } shared.clear_stop(); - let handle = resume_decode(data, shared); + let handle = resume_decode(data, shared)?; Ok((metadata, handle)) } diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 010af7ae..39d29fca 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -442,7 +442,8 @@ impl AudioPlayer { data.reset_interrupt(); if data.seek(start_seek_secs) { prepared.shared.clear_stop(); - let handle = decoder::resume_decode(data, Arc::clone(&prepared.shared)); + let handle = + decoder::resume_decode(data, Arc::clone(&prepared.shared)).into_napi()?; Ok::<_, Error>((prepared.metadata, handle, prepared.shared)) } else { let shared = Shared::new(output_sample_rate, decoder::TARGET_CHANNELS); diff --git a/src/components/layout/SideBar.vue b/src/components/layout/SideBar.vue index 6c5c68ee..48941a5f 100644 --- a/src/components/layout/SideBar.vue +++ b/src/components/layout/SideBar.vue @@ -58,9 +58,7 @@ const handleCreate = (): void => { /** 新建成功后跳转到该歌单 */ const handleCreated = (playlistId: string, scope: ContentScope): void => { - router.push( - `/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`, - ); + router.push(`/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`); }; /** 我的歌单分组头部 */