diff --git a/Cargo.lock b/Cargo.lock index e7536be8..acc713ec 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", @@ -839,6 +846,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" @@ -909,6 +925,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" @@ -2736,6 +2758,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 ee393c8f..70a27147 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,79 @@ 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" + | "analyzePair"; + +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}`)); + }); + }); interface CueRange { startMs: number; @@ -173,6 +242,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 => { // 注册实例创建/重建时的回调 @@ -280,25 +376,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) { @@ -324,6 +402,195 @@ 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", + loudnessGainDb: options.loudnessGainDb ?? 0, + }); + 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: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 () => { try { @@ -347,6 +614,7 @@ export const registerPlayerIpc = (): void => { // 停止播放并释放资源 ipcMain.handle("player:stop", () => { try { + loadSeq++; activeCueRange = null; getPlayer().stop(); return { success: true }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1fcf4b2d..9348095a 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"; @@ -44,6 +44,28 @@ 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), + // 单次双曲分析:一次调用同时得到两轨分析结果与过渡建议 + analyzePair: (currentPath: string, nextPath: string, currentMaxTimeSec?: number) => + ipcRenderer.invoke("player:analyzePair", currentPath, nextPath, currentMaxTimeSec), + // 取消下一首预载 + 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 45809863..da33cd31 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 b013ae07..6c67ee46 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,58 @@ 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 declare function analyzePair(currentPath: string, nextPath: string, currentMaxTimeSec?: number | undefined | null): PairAnalysisResult | 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 +178,20 @@ export interface JsAudioDevice { isDefault: boolean } +/** 交叉混音参数 */ +export interface JsCrossfadeOptions { + /** 过渡时长(毫秒) */ + durationMs?: number + /** 下一首起播位置(毫秒) */ + startSeekMs?: number + /** 下一首初始播放速度 */ + initialRate?: number + /** 混音策略类型 */ + mixType?: string + /** 新曲响度补偿(dB),正值升响,负值降响,限制在 ±6dB 内 */ + loudnessGainDb?: number +} + /** 一条外部歌词,返回给 JS 侧(仅格式和路径,内容按需加载) */ export interface JsExternalLyric { /** 格式(如 "lrc", "ttml", "yrc", "qrc") */ @@ -280,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 @@ -291,6 +375,17 @@ 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 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..3e1389ef --- /dev/null +++ b/native/audio-engine/src/analysis.rs @@ -0,0 +1,1337 @@ +#![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, +} + +/// 单次双曲分析结果:两首歌共享一次文件打开,减少重复 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; +const ANALYSIS_VERSION: i32 = 14; +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, + }; + if let Some(duration) = estimated_duration { + self.duration = duration; + } + + 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)?; + + if self.include_tail { + if let Some(tot) = estimated_duration { + 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() + { + 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; + + 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 { + processed_duration_local + }; + + 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); + 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); + } + + 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); + } + + let flux: Vec = env.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect(); + 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); + } + + let correlation = |signal: &[f32], lag: usize| -> f32 { + if signal.len() <= lag { + return 0.0; + } + 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)); + + 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); + let first_beat = (0..best_lag) + .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(|phase| phase as f64 / rate); + + (Some(bpm), Some(confidence), 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(); + + 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); + } + 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 scores = Vec::with_capacity(24); + 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; + } + scores.push((s_maj, root, 0)); + scores.push((s_min, root, 1)); + } + scores.sort_by(|a, b| b.0.total_cmp(&a.0)); + + 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 --- + +#[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), +]; + +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; + 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; + + 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; + } + let start = cur_out - dur; + if start < cur.mix_center_pos - 30.0 { + continue; + } + return 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, + }; + } + + if bpm_compatible { + let dur = 16.0 * sec_per_bar; + if cur.duration - cur_out > dur { + return 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, + }; + } + } + + 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, + } +} + +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; + 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); + let (auto_a, auto_b) = generate_bass_swap_automation(duration); + 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, + 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 { + 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/decoder.rs b/native/audio-engine/src/decoder.rs index 9d77ae3b..1f68ed40 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -146,6 +146,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) -> Result> { if let Some(flag) = data.interrupt_handle() { diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index f365c326..39d29fca 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; @@ -28,6 +29,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 的输出 @@ -116,6 +118,26 @@ 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, + /// 新曲响度补偿(dB),正值升响,负值降响,限制在 ±6dB 内 + #[napi(js_name = "loudnessGainDb")] + pub loudness_gain_db: Option, +} + /// 音频输出设备信息 #[napi(object)] pub struct JsAudioDevice { @@ -305,6 +327,192 @@ 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"); + 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(); + 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)).into_napi()?; + 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, + loudness_gain_db, + ) + .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 { diff --git a/native/audio-engine/src/player.rs b/native/audio-engine/src/player.rs index 67ed82f5..82bc032c 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; @@ -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,19 +50,67 @@ 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); } } +/// 同步淡出旧 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 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 / 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 * angle.cos()); + } + 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 +128,8 @@ pub struct InnerPlayer { /// 使用 Arc 包装,允许 fade 线程在 Mutex 外操作音量 sink: Option>, shared: Option>, + /// 下一首预载槽:只保留一个候选,切歌/停止/新预载会释放旧槽 + prepared: Option, /// 解码线程句柄,join 后可回收 DecoderData 复用于 seek decoder_thread: Option>, fft: Arc, @@ -123,6 +178,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 +197,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 +219,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 +321,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 +350,7 @@ impl InnerPlayer { initial_rate, ))), load_token: Arc::new(AtomicU64::new(0)), + preload_token: Arc::new(AtomicU64::new(0)), }) } @@ -303,6 +396,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 +663,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 +697,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 +707,154 @@ 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, + loudness_gain_db: f32, + ) -> 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 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, effective_target, duration_ms, &cancel); + }); + self.fade_handle = Some(handle); + + Ok(Some(metadata)) + } + /// 给 lib.rs async seek 用:原子发出停止信号 + take 所有旧线程 handle(不 join) /// /// 返回 None 表示当前没有解码线程(空闲 / 已停止 / 正在异步加载被 load 取走), @@ -601,6 +888,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 +1205,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 +1223,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 56efa8e8..89bec43b 100644 --- a/native/audio-engine/src/shared.rs +++ b/native/audio-engine/src/shared.rs @@ -6,6 +6,57 @@ 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) -> Option { + const BYPASS_FREQ: f32 = 10.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; + let cutoff = match self.role { + HighPassRole::FadeOut => { + if elapsed_ms >= mid_ms { + SWAP_FREQ + } else { + 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 { + 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 + } + } + }; + // FadeOut 在中点后固定 SWAP_FREQ,等 Crossfade 结束槽被释放即清除 + Some(cutoff) + } +} + /// 解码后的 PCM 音频数据块 pub struct AudioChunk { /// 交错排列的 f32 播放样本(L R L R ...) @@ -47,6 +98,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>, } /// 共享缓冲区最大容量(背压阈值) @@ -71,6 +124,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), }) } @@ -116,6 +170,28 @@ 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, + }); + } + + /// 清除高通滤波自动化(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() + } + /// 缓冲区是否为空(true 表示解码 underrun,sink 不消费可能是正常等待数据) pub fn is_buffer_empty(&self) -> bool { self.buffer.lock().is_empty() @@ -209,6 +285,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 b1eb3c9b..29e878b6 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::{PopResult, 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 + } +} + const OUTPUT_CEILING: f32 = 0.98; const LIMITER_RELEASE: f32 = 0.0005; const UNDERRUN_SILENCE_MS: u32 = 20; @@ -43,7 +72,7 @@ impl OutputLimiter { } /// rodio 音频源,从共享缓冲区拉取样本。 -/// 使用 condvar 阻塞等待数据,不会返回静音填充。 +/// 使用非阻塞 try_pop;解码暂时跟不上时输出短静音垫片。 pub struct DecoderSource { shared: Arc, fft: Arc, @@ -53,8 +82,17 @@ 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, + high_pass_right: BiquadHighPass, /// 解码暂时跟不上时输出的短静音垫片,避免阻塞实时输出链路 underrun_silence_remaining: usize, limiter: OutputLimiter, @@ -77,13 +115,77 @@ 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(), underrun_silence_remaining: 0, limiter: OutputLimiter::new(), sample_rate, channels, } } + + 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; + }; + if self.channels != 2 { + 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; + 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(); + } + } } impl Iterator for DecoderSource { @@ -91,7 +193,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); } if self.underrun_silence_remaining > 0 { @@ -102,7 +204,6 @@ impl Iterator for DecoderSource { // 慢速路径:从共享缓冲区非阻塞获取,跳过空数据块 loop { match self.shared.try_pop() { - // 将 FFT 样本推送给分析器 PopResult::Chunk(chunk) => { if !chunk.fft_samples.is_empty() { self.fft.push_samples(&chunk.fft_samples); @@ -111,25 +212,28 @@ 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() .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); } // 空数据块(重采样器预热期),继续获取下一个 } diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 4157b297..4c879d13 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 69791ac8..bbb4cd36 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -152,6 +152,96 @@ export interface LoadOptions { meta?: Track; } +/** player:crossfadeTo 的可选参数 */ +export interface CrossfadeOptions extends LoadOptions { + /** 交叉混音时长(毫秒) */ + durationMs?: number; + /** 下一首起播位置(毫秒) */ + startSeekMs?: number; + /** 下一首初始播放速度 */ + initialRate?: number; + /** 混音策略类型 */ + mixType?: "default" | "bassSwap"; + /** UI 与媒体状态提交延迟(毫秒) */ + uiSwitchDelayMs?: number; + /** 新曲响度补偿(dB),用于对齐过渡区短时响度;正值升响,负值降响 */ + loudnessGainDb?: 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"; + /** 新曲相对旧曲的响度补偿(dB,正值升响,负值降响,限制 ±6dB) */ + loudnessGainDb?: number; +} + /** 播放器状态快照 */ export interface PlayerStatus { state: PlayerState; @@ -171,6 +261,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" } @@ -193,10 +284,50 @@ 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/托盘 */ 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>; + /** 单次双曲分析:一次调用同时获取两轨特征与过渡建议 */ + analyzePair: ( + currentPath: string, + nextPath: string, + currentMaxTimeSec?: number, + ) => Promise>; + /** 取消下一首预载 */ + cancelPreload: () => Promise; /** 恢复播放 */ play: () => Promise; /** 暂停播放 */ diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 67036fed..29e2b3c9 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/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}`); }; /** 我的歌单分组头部 */ diff --git a/src/core/player/automix.ts b/src/core/player/automix.ts new file mode 100644 index 00000000..023af0e1 --- /dev/null +++ b/src/core/player/automix.ts @@ -0,0 +1,351 @@ +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"; +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 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; + +/** 双曲分析状态机:单次 analyzePair 替代原来的4个并发请求 */ +let pairKey: string | null = null; +let currentAnalysis: AudioAnalysis | null = null; +let nextAnalysis: AudioAnalysis | null = null; +let transitionProposal: TransitionProposal | 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 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; +}; + +/** + * 后台异步分析当前曲/下一首对。 + * 不 await — 调用后立即返回;tickAutomix 每次 tick 读取模块级状态快照。 + */ +const kickPairAnalysis = (currentSource: string, nextSource: string): void => { + if (!isLocalLikeSource(currentSource) || !isLocalLikeSource(nextSource)) return; + const key = `${currentSource}>>${nextSource}`; + if (pairKey === key) return; // 同一对,已在飞行中或已完成,跳过 + pairKey = key; + currentAnalysis = null; + nextAnalysis = null; + transitionProposal = null; + const token = cancelToken; + 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; + } + }) + .catch((error: unknown) => { + console.warn("[automix] pair analysis failed", error); + }) + .finally(() => { + if (pairKey === key) pairAnalysisInFlight = null; + }); +}; + +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 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, + 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, + index: number, + currentSource: string, + nextSource: string, + durationSec: number, +): AutomixPlan => { + const expectedKey = `${currentSource}>>${nextSource}`; + const current = pairKey === expectedKey ? currentAnalysis : null; + const next = pairKey === expectedKey ? nextAnalysis : null; + const transition = pairKey === expectedKey ? transitionProposal : null; + 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"; + let usedTransition = false; + + if (transition && transition.duration > MIN_CROSSFADE_SEC) { + 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; + 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 (hasReliableBeatGrid(current) && hasReliableBeatGrid(next)) { + const ratio = current.bpm! / next.bpm!; + if (ratio >= 1 - MAX_TEMPO_ADJUSTMENT && ratio <= 1 + MAX_TEMPO_ADJUSTMENT) { + initialRate = ratio; + } + } + } + + if (!usedTransition && 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 normalizePlan( + { + track, + index, + triggerTime, + crossfadeDuration, + startSeek, + initialRate, + uiSwitchDelay: crossfadeDuration * 0.5, + mixType, + loudnessGainDb: computeLoudnessGainDb(current, next), + }, + durationSec, + ); +}; + +/** 取消当前自动混音调度 */ +export const cancelAutomix = (): void => { + cancelToken++; + pairKey = null; + currentAnalysis = null; + nextAnalysis = null; + transitionProposal = null; + pairAnalysisInFlight = 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.currentTrack?.cuePath) 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 || 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}`; + const planStale = + !scheduledPlan || + planKey !== + `${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, + 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; + 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; + } +}; diff --git a/src/core/player/events.ts b/src/core/player/events.ts index 13f7ef0c..19e8ca17 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, @@ -60,7 +64,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()) { @@ -77,6 +83,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); @@ -91,8 +103,15 @@ 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(); const track = useMediaStore().track; - if (track?.cueEndMs != null && status.isPlaying && status.duration > 0) { + if ( + automixResult !== "fallback-next" && + track?.cueEndMs != null && + status.isPlaying && + status.duration > 0 + ) { if (adjusted >= status.duration - 250) await finishCurrentTrack(); } break; @@ -100,6 +119,9 @@ export const handleEvent = async (event: PlayerEvent): Promise => { case "fftData": playback.setFftFrame(event.data); break; + case "transitionCommit": + await commitPendingCrossfade(event.data); + break; case "ended": { await finishCurrentTrack(); break; diff --git a/src/core/player/index.ts b/src/core/player/index.ts index 55c72a0e..01d760bc 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,7 +17,9 @@ 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 { resolveTrackSource, type ResolvedTrackSource } from "@/services/audioSource"; +import * as nextPreload from "./preload"; +import { cancelAutomix } from "./automix"; +import { resolveTrackSource } from "@/services/audioSource"; import { installPlayStats } from "./stats"; import { useFavorite } from "@/composables/useFavorite"; import { extractColorFromUrl } from "@/utils/color"; @@ -61,6 +64,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 停下 @@ -85,6 +98,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 @@ -99,6 +168,8 @@ const resetForLoad = (duration: number): void => { playback.setPlaying(false); // 上一首未达到缓存触发阈值的请求丢弃 cacheScheduler.cancel(); + cancelAutomix(); + nextPreload.cancelPreload(); }; /** @@ -152,6 +223,7 @@ export const load = async ( status.currentSource = source; playback.setDuration(dur); playback.setPlaying(autoPlay); + void nextPreload.preloadNextTrack(); return { ok: true, track: enriched }; } status.state = "idle"; @@ -238,6 +310,50 @@ const loadTrackSourceWithFallback = async ( } }; +/** + * 以双槽 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, + loudnessGainDb: plan.loudnessGainDb ?? 0, + 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 生效 @@ -391,6 +507,7 @@ export const togglePlay = (): void => { /** 暂停播放 */ export const pause = async (): Promise => { const status = useStatusStore(); + cancelAutomix(); const prev = status.state; status.state = "paused"; playback.setPlaying(false); @@ -403,6 +520,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(); @@ -443,6 +564,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; @@ -528,6 +650,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(); @@ -551,6 +674,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); @@ -755,6 +879,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 }); }; @@ -796,6 +922,8 @@ export const setShuffleMode = (mode: ShuffleMode): void => { queue.unshuffleQueue(""); } } + cancelAutomix(); + void nextPreload.preloadNextTrack(); syncPlayMode(); toast.info(i18n.global.t(`player.shuffleMode.${mode}`), { icon: false }); }; @@ -809,6 +937,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--; @@ -860,11 +990,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; }; @@ -916,6 +1050,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; @@ -927,6 +1063,7 @@ export const moveInQueue = (fromIndex: number, toIndex: number): void => { }; let unsubscribe: (() => void) | null = null; +let stopAutomationWatch: (() => void) | null = null; let initialized = false; /** 初始化播放器 */ @@ -967,6 +1104,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(); // 当前歌曲喜欢状态变化时同步到托盘菜单 @@ -1018,4 +1172,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 4d9a7070..6dfef8d4 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -770,6 +770,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" + }, "showLyricInBar": { "label": "Show Lyric While Playing", "description": "Replace artist name with current lyric in the bottom player bar" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5bf0937a..118bc998 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -758,6 +758,18 @@ "label": "记忆播放进度", "description": "启动时恢复上次歌曲的播放进度" }, + "preloadNext": { + "label": "预加载下一首", + "description": "提前解析并准备下一首歌曲,减少切歌等待" + }, + "automixEnabled": { + "label": "自动混音", + "description": "在曲末根据分析结果自动交叉混入下一首" + }, + "automixMaxAnalyzeTimeSec": { + "label": "最大分析时长", + "description": "每首歌用于自动混音分析的最长秒数" + }, "showLyricInBar": { "label": "播放时显示歌词", "description": "底部播放栏播放时将歌手名替换为当前歌词" diff --git a/src/services/audioSource.ts b/src/services/audioSource.ts index 66272342..a7d5cbf5 100644 --- a/src/services/audioSource.ts +++ b/src/services/audioSource.ts @@ -172,7 +172,7 @@ export interface ResolvedTrackSource { fromCache: boolean; provider: "local" | "cache" | "streaming" | "official" | "plugin" | "trial"; pluginId?: string; - cacheRequest?: () => Promise; + cacheRequest?: () => Promise; } /** @@ -213,9 +213,10 @@ export const resolveTrackSource = async ( const cacheUrl = await store.getStreamUrl(track, { playSessionId: crypto.randomUUID(), }); - void window.api.cache.song.fetch(cacheKey, "streaming", cacheUrl); + return window.api.cache.song.fetch(cacheKey, "streaming", cacheUrl); } catch (err) { console.warn("[cache] streaming getStreamUrl failed", err); + return null; } }; } @@ -242,7 +243,7 @@ export const resolveTrackSource = async ( }; if (cacheEnabled && !resolved.isTrial) { result.cacheRequest = async () => { - 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 db030f85..05c843ad 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: "showLyricInBar", type: "switch",