From 07037a36a61e50e09f2a1fb1efe4e32a9018de5c Mon Sep 17 00:00:00 2001 From: sakurai Date: Fri, 4 Sep 2026 12:42:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Windows=20=E5=B9=B3=E5=8F=B0=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20WASAPI=20=E7=8B=AC=E5=8D=A0=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/config.ts | 10 + electron/main/ipc/player.ts | 15 + electron/main/services/engine.ts | 9 + native/audio-engine/index.d.ts | 10 +- native/audio-engine/src/audio_output.rs | 137 ++++- native/audio-engine/src/bindings/player.rs | 46 +- native/audio-engine/src/decoder.rs | 5 + native/audio-engine/src/lib.rs | 3 + native/audio-engine/src/playback.rs | 34 +- native/audio-engine/src/player/events.rs | 2 + native/audio-engine/src/player/mod.rs | 38 +- native/audio-engine/src/player/transition.rs | 4 + native/audio-engine/src/wasapi_exclusive.rs | 564 +++++++++++++++++++ shared/defaults/settings.ts | 1 + shared/types/player.ts | 3 +- shared/types/settings.ts | 5 + src/components/player/FullPlayer/index.vue | 6 +- src/core/player/events.ts | 11 + src/i18n/locales/en-US.json | 11 + src/i18n/locales/zh-CN.json | 11 + src/settings/categories/appearance.ts | 3 +- src/settings/categories/player.ts | 12 + 22 files changed, 912 insertions(+), 28 deletions(-) create mode 100644 native/audio-engine/src/wasapi_exclusive.rs diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index 19ac5e7df..f4961a562 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -14,7 +14,10 @@ import { setEqualizerEnabled, setEqualizerBands, setPreampGain, + getPlayer, + setExclusiveMode, } from "@main/services/engine"; +import { requestReinit } from "@main/services/device"; import { setTaskbarProgress, applyMainWindowZoom, @@ -72,6 +75,13 @@ const applyConfigChange = (keyPath: string, value: unknown, previous: unknown): case "player.equalizer.preamp": setPreampGain(value as number); break; + case "player.audioOutputMode": + // 独占模式仅 Windows 引擎支持;切模式后重建输出立即生效 + if (isWin) { + setExclusiveMode(value === "exclusive"); + requestReinit(getPlayer()); + } + break; case "system.taskbarProgress": if (!value) setTaskbarProgress(-1); break; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 4aa719508..08b83b7fd 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -196,6 +196,13 @@ const registerNativeEvents = (inst: InstanceType { // 注册实例创建/重建时的回调 onPlayerCreated(registerNativeEvents); onPlayerCreated(startDeviceMonitoring); + // 启动时同步独占模式开关到引擎(默认共享,无需处理) + onPlayerCreated((inst) => { + if (store.get("player.audioOutputMode") === "exclusive") { + inst.setExclusiveMode(true).catch((error) => { + playerLog.warn("应用独占模式配置失败:", error); + }); + } + }); // 加载音频文件 ipcMain.handle("player:load", async (_event, source: string, options: LoadOptions = {}) => { cancelPendingReinit(); diff --git a/electron/main/services/engine.ts b/electron/main/services/engine.ts index ce2b57046..12a8e2487 100644 --- a/electron/main/services/engine.ts +++ b/electron/main/services/engine.ts @@ -86,6 +86,15 @@ export const setPreampGain = (preampDb: number): void => { } }; +/** 同步 WASAPI 独占模式开关到播放器(仅 Windows 生效) */ +export const setExclusiveMode = (enabled: boolean): void => { + if (playerInstance) { + playerInstance.setExclusiveMode(enabled).catch((error) => { + playerLog.warn("切换音频输出模式失败:", error); + }); + } +}; + /** 同步当前封面缓存目录到原生引擎(缓存路径切换时调用) */ export const syncCoverCacheDir = (): void => { if (playerInstance) { diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index 10e89c042..7f4ecdfde 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -108,6 +108,12 @@ export declare class AudioPlayer { * 旧配置存的是显示名,此处原样返回,由 `open_device` 回退解析 */ getSelectedDeviceName(): string | null + /** + * 设置音频输出模式为 WASAPI 独占(仅 Windows 生效,立即重建设备) + * + * 设备被占用或格式不支持时自动回退共享模式,并通过 outputFallback 事件通知 + */ + setExclusiveMode(enabled: boolean): Promise /** 设置播放速度(自动 clamp 到 [0.5, 2.0]) */ setSpeed(speed: number): void /** 设置音调偏移(半音,自动 clamp 到 [-12, 12]) */ @@ -190,7 +196,7 @@ export interface JsMusicMetadata { /** 播放器事件,推送给 JS 侧 */ export interface JsPlayerEvent { - /** 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" | "outputFailed" */ + /** 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" | "outputFailed" | "outputFallback" */ type: string /** 状态(仅 stateChanged 时有值) */ state?: string @@ -200,6 +206,8 @@ export interface JsPlayerEvent { duration?: number /** FFT 频谱数据(仅 fftData 时有值,128 个频段,值域 0.0 ~ 1.0) */ fftData?: JsFftData + /** 回退原因分类键(仅 outputFallback 时有值:deviceBusy / formatUnsupported / unavailable) */ + reason?: string } /** 播放器状态快照 */ diff --git a/native/audio-engine/src/audio_output.rs b/native/audio-engine/src/audio_output.rs index 06df8fed1..5914d82c9 100644 --- a/native/audio-engine/src/audio_output.rs +++ b/native/audio-engine/src/audio_output.rs @@ -19,13 +19,27 @@ use crate::source::DecoderSource; /// 禁止获取 `InnerPlayer` 锁、join 线程、枚举设备、创建新流或调用 NAPI async 方法。 pub type OutputFailureCallback = Arc; +/// 独占模式回退回调:参数为回退原因分类键(deviceBusy / formatUnsupported / unavailable), +/// 由协商失败的工作线程调用,只允许发送轻量事件 +pub type ExclusiveFallbackCallback = Arc; + +/// 平台输出流:共享模式走 cpal,Windows 独占模式走 WASAPI 专属流 +pub(crate) enum OutputStream { + Shared(cpal::Stream), + #[cfg(target_os = "windows")] + Exclusive(crate::wasapi_exclusive::ExclusiveStream), +} + /// 输出设备与配置句柄。`Send`,可放进 `InnerPlayer` 而不需 `unsafe impl Send`。 /// -/// 不持有 `cpal::Stream`——输出流由每次加载音源时的 `PlaybackHandle::attach` 按此配置创建, +/// 不持有输出流——输出流由每次加载音源时的 `PlaybackHandle::attach` 按此配置创建, /// 因此切歌时无需跨线程移交流,也天然避免新旧流重叠占用设备。 pub struct AudioOutput { device: cpal::Device, config: SupportedStreamConfig, + /// Windows 独占模式协商结果;`Some` 时输出走 WASAPI 独占流 + #[cfg(target_os = "windows")] + exclusive: Option, /// 该输出流的单调代次,用于诊断和过滤销毁后迟到的流错误 generation: u64, on_failure: OutputFailureCallback, @@ -38,8 +52,10 @@ impl AudioOutput { /// * `device_id` - 输出设备 ID,`None` 走系统默认设备 /// * `requested_sample_rate` - 期望输出采样率;设备支持时按此速率打开(音源精确采样率), /// 否则回退到设备默认配置。`None` 表示直接用设备默认配置 + /// * `source_bits` - 音源位深,独占模式协商候选的优先依据 /// * `generation` - 输出流单调代次,见 [`AudioOutput`] 字段说明 /// * `on_failure` - 运行期流错误回调,见 [`OutputFailureCallback`] + /// * `exclusive` - 独占模式开关,`Some` 携带回退回调;协商失败时自动回退共享并上报 /// /// # Errors /// - 找不到指定设备 @@ -47,11 +63,41 @@ impl AudioOutput { pub fn new( device_id: Option<&str>, requested_sample_rate: Option, + source_bits: Option, generation: u64, on_failure: OutputFailureCallback, + exclusive: Option<&ExclusiveFallbackCallback>, ) -> Result { - let (device, config) = open_device(device_id, requested_sample_rate) - .with_audio_kind(AudioErrorKind::Device)?; + let (device, config, exclusive_format) = open_device( + device_id, + requested_sample_rate, + source_bits, + exclusive, + ) + .with_audio_kind(AudioErrorKind::Device)?; + #[cfg(not(target_os = "windows"))] + let _ = exclusive_format; + #[cfg(target_os = "windows")] + if let Some(format) = exclusive_format { + info!( + id = device_id_string(&device).as_deref().unwrap_or("-"), + name = %device, + rate = format.sample_rate, + channels = format.channels, + bits = format.valid_bits, + "打开独占模式音频输出配置" + ); + } + #[cfg(target_os = "windows")] + if exclusive_format.is_none() { + info!( + id = device_id_string(&device).as_deref().unwrap_or("-"), + name = %device, + sample_rate = config.sample_rate(), + "打开音频输出配置" + ); + } + #[cfg(not(target_os = "windows"))] info!( id = device_id_string(&device).as_deref().unwrap_or("-"), name = %device, @@ -61,6 +107,8 @@ impl AudioOutput { Ok(Self { device, config, + #[cfg(target_os = "windows")] + exclusive: exclusive_format, generation, on_failure, }) @@ -68,28 +116,58 @@ impl AudioOutput { /// 实际输出流采样率(播放重采样目标) pub fn sample_rate(&self) -> u32 { + #[cfg(target_os = "windows")] + if let Some(format) = &self.exclusive { + return format.sample_rate; + } self.config.sample_rate() } /// 实际输出流声道数 pub fn channels(&self) -> u16 { + #[cfg(target_os = "windows")] + if let Some(format) = &self.exclusive { + return format.channels; + } self.config.channels() } /// 按本配置创建一次播放的输出流,实时回调从 `source` 拉取样本。 - /// 调用方持有返回的 `Stream`,直到本次播放结束。 + /// 调用方持有返回的流,直到本次播放结束。 pub(crate) fn build_stream( &self, source: DecoderSource, volume: Arc, stopped: Arc, - ) -> Result { + paused: bool, + ) -> Result { + #[cfg(target_os = "windows")] + if let Some(format) = self.exclusive { + let device_id = device_id_string(&self.device); + let on_failure = Arc::clone(&self.on_failure); + return run_in_mta(move || { + crate::wasapi_exclusive::open_exclusive_stream( + device_id.as_deref(), + format, + source, + volume, + stopped, + paused, + on_failure, + ) + .map(OutputStream::Exclusive) + }) + .with_audio_kind(AudioErrorKind::Device); + } + #[cfg(not(target_os = "windows"))] + let _ = paused; let device = self.device.clone(); - let config = self.config.clone(); + let config = self.config; let on_failure = Arc::clone(&self.on_failure); run_in_mta(move || { build_typed_stream_for_format(&device, &config, source, volume, stopped, on_failure) }) + .map(OutputStream::Shared) .with_audio_kind(AudioErrorKind::Device) } } @@ -249,6 +327,12 @@ pub fn default_device_id() -> Option { .unwrap_or_default() } +/// 独占模式协商结果类型:非 Windows 平台无此概念 +#[cfg(target_os = "windows")] +type ExclusiveFormatOpt = Option; +#[cfg(not(target_os = "windows"))] +type ExclusiveFormatOpt = (); + /// 按设备 ID(`None` 为默认设备)解析设备与输出配置。 /// 设备支持 `requested_sample_rate` 时按该速率打开,否则使用设备默认配置。 /// 样本格式优先沿用设备默认格式:PipeWire 等后端上报的 supported 列表包含 @@ -257,7 +341,9 @@ pub fn default_device_id() -> Option { fn open_device_internal( device_id: Option<&str>, requested_sample_rate: Option, -) -> Result<(cpal::Device, SupportedStreamConfig)> { + source_bits: Option, + exclusive: Option<&ExclusiveFallbackCallback>, +) -> Result<(cpal::Device, SupportedStreamConfig, ExclusiveFormatOpt)> { let host = cpal::default_host(); let device = match device_id { Some(selector) => { @@ -272,14 +358,32 @@ fn open_device_internal( let default_config = device .default_output_config() .context("读取输出设备配置失败")?; + #[cfg(target_os = "windows")] { let _ = requested_sample_rate; - Ok((device, default_config)) + let mut exclusive_format = None; + if let Some(on_fallback) = exclusive { + // 独占模式:优先按音源采样率/位深协商,失败时回退共享并上报原因 + match crate::wasapi_exclusive::negotiate_exclusive_format( + device_id_string(&device).as_deref(), + requested_sample_rate.unwrap_or(default_config.sample_rate()), + source_bits.unwrap_or(24), + default_config.channels(), + ) { + Ok(format) => exclusive_format = Some(format), + Err(error) => { + warn!(reason = error.reason(), error = %error, "独占模式协商失败,回退共享模式"); + on_fallback(error.reason()); + } + } + } + Ok((device, default_config, exclusive_format)) } #[cfg(not(target_os = "windows"))] { + let _ = (source_bits, exclusive); let config = match requested_sample_rate { Some(rate) => { if rate == default_config.sample_rate() { @@ -325,16 +429,27 @@ fn open_device_internal( } None => default_config, }; - Ok((device, config)) + Ok((device, config, ())) } } fn open_device( device_id: Option<&str>, requested_sample_rate: Option, -) -> Result<(cpal::Device, SupportedStreamConfig)> { + source_bits: Option, + exclusive: Option<&ExclusiveFallbackCallback>, +) -> Result<(cpal::Device, SupportedStreamConfig, ExclusiveFormatOpt)> { let id_owned = device_id.map(String::from); - run_in_mta(move || open_device_internal(id_owned.as_deref(), requested_sample_rate)) + // 回退回调只在 MTA 工作线程被调用,Arc 在此克隆进闭包 + let fallback_owned = exclusive.cloned(); + run_in_mta(move || { + open_device_internal( + id_owned.as_deref(), + requested_sample_rate, + source_bits, + fallback_owned.as_ref(), + ) + }) } /// 按样本格式分发到类型化构建 diff --git a/native/audio-engine/src/bindings/player.rs b/native/audio-engine/src/bindings/player.rs index 87a8ebed1..e063e012c 100644 --- a/native/audio-engine/src/bindings/player.rs +++ b/native/audio-engine/src/bindings/player.rs @@ -115,7 +115,7 @@ pub struct JsFftData { #[napi(object)] #[derive(Default)] pub struct JsPlayerEvent { - /// 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" | "outputFailed" + /// 事件类型:"stateChanged" | "ended" | "sourceError" | "position" | "fftData" | "outputStalled" | "outputFailed" | "outputFallback" #[napi(js_name = "type")] pub event_type: String, /// 状态(仅 stateChanged 时有值) @@ -126,6 +126,8 @@ pub struct JsPlayerEvent { pub duration: Option, /// FFT 频谱数据(仅 fftData 时有值,128 个频段,值域 0.0 ~ 1.0) pub fft_data: Option, + /// 回退原因分类键(仅 outputFallback 时有值:deviceBusy / formatUnsupported / unavailable) + pub reason: Option, } /// 播放器状态快照 @@ -189,6 +191,8 @@ impl AudioPlayer { output_generation, on_failure, device_id, + exclusive_mode, + on_fallback, ) = { let mut player = self.inner.lock(); let position = player.position(); @@ -196,6 +200,8 @@ impl AudioPlayer { let device_id = player.selected_device().map(String::from); let output_generation = player.reserve_output_generation(); let on_failure = player.make_failure_callback(output_generation); + let on_fallback = player.make_fallback_callback(output_generation); + let exclusive_mode = player.is_exclusive_mode(); let seek_take = player.take_for_async_seek(); let fallback_source = player.current_source().map(String::from); ( @@ -206,6 +212,8 @@ impl AudioPlayer { output_generation, on_failure, device_id, + exclusive_mode, + on_fallback, ) }; @@ -217,6 +225,7 @@ impl AudioPlayer { current_source, was_playing, original_sample_rate, + original_bits, output_sample_rate: _, output_channels: _, token, @@ -231,8 +240,10 @@ impl AudioPlayer { let output = match audio_output::AudioOutput::new( device_id.as_deref(), Some(original_sample_rate), + Some(original_bits), output_generation, on_failure, + exclusive_mode.then_some(&on_fallback), ) { Ok(output) => output, Err(error) => return ReinitOutcome::OutputFailed { error }, @@ -416,11 +427,16 @@ impl AudioPlayer { event_type: "outputStalled".into(), ..Default::default() }, - PlayerEvent::OutputFailed => JsPlayerEvent { - event_type: "outputFailed".into(), - ..Default::default() - }, - }; + PlayerEvent::OutputFailed => JsPlayerEvent { + event_type: "outputFailed".into(), + ..Default::default() + }, + PlayerEvent::OutputFallback { reason } => JsPlayerEvent { + event_type: "outputFallback".into(), + reason: Some(reason), + ..Default::default() + }, + }; tsfn.call(js_event, ThreadsafeFunctionCallMode::NonBlocking); }); @@ -486,6 +502,8 @@ impl AudioPlayer { device_id, output_generation, failure_callback, + fallback_callback, + exclusive_mode, equalizer, tempo, ) = { @@ -493,6 +511,8 @@ impl AudioPlayer { let (old_threads, token) = player.take_for_async_load(handle.clone()); let output_generation = player.reserve_output_generation(); let failure_callback = player.make_failure_callback(output_generation); + let fallback_callback = player.make_fallback_callback(output_generation); + let exclusive_mode = player.is_exclusive_mode(); ( old_threads, token, @@ -502,6 +522,8 @@ impl AudioPlayer { player.selected_device().map(String::from), output_generation, failure_callback, + fallback_callback, + exclusive_mode, player.equalizer_handle(), player.tempo_handle(), ) @@ -522,8 +544,10 @@ impl AudioPlayer { let output = audio_output::AudioOutput::new( device_id.as_deref(), Some(prepared.original_sample_rate()), + Some(prepared.bits_per_sample()), output_generation, failure_callback, + exclusive_mode.then_some(&fallback_callback), )?; let shared = Shared::new(output.sample_rate(), output.channels()); shared.set_normalization_enabled(normalization_enabled); @@ -682,6 +706,7 @@ impl AudioPlayer { current_source, was_playing, original_sample_rate: _, + original_bits: _, output_sample_rate, output_channels, token, @@ -934,6 +959,15 @@ impl AudioPlayer { self.inner.lock().selected_device().map(String::from) } + /// 设置音频输出模式为 WASAPI 独占(仅 Windows 生效,立即重建设备) + /// + /// 设备被占用或格式不支持时自动回退共享模式,并通过 outputFallback 事件通知 + #[napi] + pub async fn set_exclusive_mode(&self, enabled: bool) -> Result<()> { + self.inner.lock().set_exclusive_mode(enabled); + self.reinit_output().await + } + /// 设置播放速度(自动 clamp 到 [0.5, 2.0]) #[napi] pub fn set_speed(&self, speed: f64) { diff --git a/native/audio-engine/src/decoder.rs b/native/audio-engine/src/decoder.rs index d2cc58ebc..0b8fac767 100644 --- a/native/audio-engine/src/decoder.rs +++ b/native/audio-engine/src/decoder.rs @@ -97,6 +97,11 @@ impl PreparedDecoder { pub fn original_sample_rate(&self) -> u32 { self.metadata.original_sample_rate } + + /// 音源有效位深,独占模式协商候选的优先依据 + pub fn bits_per_sample(&self) -> u32 { + self.metadata.bits_per_sample + } } /// 统一结束解码线程;panic 属于源错误,但仍需结束 source 迭代 diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 146532115..fddedb221 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -19,4 +19,7 @@ mod shared; mod source; mod tempo; +#[cfg(target_os = "windows")] +mod wasapi_exclusive; + pub use bindings::*; diff --git a/native/audio-engine/src/playback.rs b/native/audio-engine/src/playback.rs index 95fefe0d8..3dffef970 100644 --- a/native/audio-engine/src/playback.rs +++ b/native/audio-engine/src/playback.rs @@ -5,21 +5,21 @@ use anyhow::{Context, Result}; use cpal::traits::StreamTrait; use tracing::warn; -use crate::audio_output::AudioOutput; +use crate::audio_output::{AudioOutput, OutputStream}; use crate::error::{AudioErrorKind, AudioResultExt}; use crate::source::DecoderSource; -/// 平台统一的播放控制句柄:持有一条独立的 `cpal::Stream`。 +/// 平台统一的播放控制句柄:持有一条独立输出流(cpal 共享流或 WASAPI 独占流)。 /// 每次加载/seek 由 `attach` 创建,播放期间音量与停止通过原子标志与实时回调通信。 pub struct PlaybackHandle { - stream: cpal::Stream, + stream: OutputStream, volume: Arc, stopped: Arc, } impl PlaybackHandle { /// 按 `output` 的配置创建输出流并接入 `source`。 - /// 传入 `volume` 为初始音量,`paused` 为 true 时保持停止(恢复时由 `play` 启动)。 + /// 传入 `volume` 为初始音量,`paused` 为 true 时保持暂停(恢复时由 `play` 启动)。 pub fn attach( output: &AudioOutput, source: DecoderSource, @@ -28,7 +28,7 @@ impl PlaybackHandle { ) -> Result { let volume = Arc::new(AtomicU32::new(volume.to_bits())); let stopped = Arc::new(AtomicBool::new(false)); - let stream = output.build_stream(source, Arc::clone(&volume), Arc::clone(&stopped))?; + let stream = output.build_stream(source, Arc::clone(&volume), Arc::clone(&stopped), paused)?; if !paused { stream .play() @@ -63,3 +63,27 @@ impl PlaybackHandle { self.volume.store(volume.to_bits(), Ordering::Relaxed); } } + +impl OutputStream { + fn play(&self) -> Result<()> { + match self { + Self::Shared(stream) => stream.play().map_err(Into::into), + #[cfg(target_os = "windows")] + Self::Exclusive(stream) => { + stream.play(); + Ok(()) + } + } + } + + fn pause(&self) -> Result<()> { + match self { + Self::Shared(stream) => stream.pause().map_err(Into::into), + #[cfg(target_os = "windows")] + Self::Exclusive(stream) => { + stream.pause(); + Ok(()) + } + } + } +} diff --git a/native/audio-engine/src/player/events.rs b/native/audio-engine/src/player/events.rs index ef783eafd..8729b3003 100644 --- a/native/audio-engine/src/player/events.rs +++ b/native/audio-engine/src/player/events.rs @@ -19,6 +19,8 @@ pub enum PlayerEvent { OutputStalled, /// 输出流在运行期失效(CPAL 流错误),由 JS 侧触发输出重建 OutputFailed, + /// WASAPI 独占模式打开失败已回退共享模式,reason 为分类键 + OutputFallback { reason: String }, } /// 事件发射器类型(跨线程安全) diff --git a/native/audio-engine/src/player/mod.rs b/native/audio-engine/src/player/mod.rs index 67fbe221c..207963fa6 100644 --- a/native/audio-engine/src/player/mod.rs +++ b/native/audio-engine/src/player/mod.rs @@ -7,7 +7,7 @@ use ffmpeg_audio::HttpCancelHandle; use parking_lot::Mutex; use tracing::{debug, info}; -use crate::audio_output::{AudioOutput, OutputFailureCallback}; +use crate::audio_output::{AudioOutput, ExclusiveFallbackCallback, OutputFailureCallback}; use crate::decoder; use crate::equalizer::{Equalizer, EQ_BAND_COUNT}; use crate::fft::FftAnalyzer; @@ -79,6 +79,10 @@ pub struct InnerPlayer { output_generation: Arc, /// 当前音频源的原始采样率 original_sample_rate: u32, + /// 当前音频源的有效位深,独占模式协商候选的优先依据 + original_bits: u32, + /// WASAPI 独占模式开关(仅 Windows 生效,重建设备时生效) + exclusive_mode: bool, /// 正在打开的网络音源中断句柄,确保切歌和 stop 能取消元数据探测 pending_load_handle: Option, } @@ -97,11 +101,15 @@ impl InnerPlayer { if self.output.is_none() { let generation = self.reserve_output_generation(); let on_failure = self.make_failure_callback(generation); + let on_fallback = self.make_fallback_callback(generation); + let exclusive = self.exclusive_mode.then_some(on_fallback); self.output = Some(AudioOutput::new( self.selected_device.as_deref(), requested_sample_rate, + None, generation, on_failure, + exclusive.as_ref(), )?); } self.output @@ -123,6 +131,21 @@ impl InnerPlayer { }) } + /// 构造独占模式回退回调:只发送轻量 `PlayerEvent::OutputFallback` + pub fn make_fallback_callback(&self, generation: u64) -> ExclusiveFallbackCallback { + let Some(cb) = self.event_callback.as_ref().map(Arc::clone) else { + return std::sync::Arc::new(|_| {}); + }; + let active_generation = Arc::clone(&self.output_generation); + std::sync::Arc::new(move |reason: &str| { + if active_generation.load(Ordering::Acquire) == generation { + cb(PlayerEvent::OutputFallback { + reason: reason.to_string(), + }); + } + }) + } + /// 预留下一代输出流,并立即使旧输出的回调失效。 pub fn reserve_output_generation(&self) -> u64 { self.output_generation.fetch_add(1, Ordering::AcqRel) + 1 @@ -185,6 +208,8 @@ impl InnerPlayer { load_token: Arc::new(AtomicU64::new(0)), output_generation: Arc::new(AtomicU64::new(0)), original_sample_rate: decoder::DEFAULT_TARGET_SAMPLE_RATE, + original_bits: 16, + exclusive_mode: false, pending_load_handle: None, }) } @@ -195,6 +220,17 @@ impl InnerPlayer { self.selected_device = device_id; } + /// 设置独占模式开关(下一次重建设备时生效) + pub fn set_exclusive_mode(&mut self, enabled: bool) { + info!(enabled, "切换音频输出模式"); + self.exclusive_mode = enabled; + } + + /// 独占模式开关是否已启用 + pub fn is_exclusive_mode(&self) -> bool { + self.exclusive_mode + } + /// 获取当前选择的输出设备(None = 跟随系统默认) pub fn selected_device(&self) -> Option<&str> { self.selected_device.as_deref() diff --git a/native/audio-engine/src/player/transition.rs b/native/audio-engine/src/player/transition.rs index 9cc5a9741..dfbc3ee3d 100644 --- a/native/audio-engine/src/player/transition.rs +++ b/native/audio-engine/src/player/transition.rs @@ -53,6 +53,8 @@ pub struct SeekTake { pub was_playing: bool, /// 当前音频源原始采样率 pub original_sample_rate: u32, + /// 当前音频源有效位深,独占模式重建协商候选的优先依据 + pub original_bits: u32, /// 当前输出设备采样率(新 Shared 沿用,与复用的重采样器目标一致) pub output_sample_rate: u32, /// 当前输出设备声道数 @@ -199,6 +201,7 @@ impl InnerPlayer { current_source: self.current_source.clone(), was_playing: self.state == PlayerState::Playing, original_sample_rate: self.original_sample_rate, + original_bits: self.original_bits, output_sample_rate: self.output_sample_rate(), output_channels: self.output_channels(), token, @@ -310,6 +313,7 @@ impl InnerPlayer { self.audio_duration = metadata.duration_secs; self.original_sample_rate = metadata.original_sample_rate; + self.original_bits = metadata.bits_per_sample; self.cover_raw = metadata.cover_raw.take(); if auto_play { diff --git a/native/audio-engine/src/wasapi_exclusive.rs b/native/audio-engine/src/wasapi_exclusive.rs new file mode 100644 index 000000000..c68961d0a --- /dev/null +++ b/native/audio-engine/src/wasapi_exclusive.rs @@ -0,0 +1,564 @@ +//! Windows WASAPI 独占模式输出(绕过系统混音器,bit-perfect 回放)。 +//! +//! 与共享模式(cpal)互斥:协商成功的格式即解码重采样目标, +//! 渲染线程以事件驱动方式从 `DecoderSource` 拉取 f32 样本, +//! 按协商位深转成整型交给声卡。设备被其他程序独占或格式不支持时, +//! 协商阶段返回带稳定分类的错误,由调用方回退共享模式。 + +#![cfg(target_os = "windows")] + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; + +use anyhow::{Context, Result}; +use tracing::{debug, info, warn}; +use windows::core::{GUID, PCWSTR}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0, WAIT_EVENT}; +use windows::Win32::Media::Audio::{ + eConsole, eRender, IAudioClient, IAudioRenderClient, IMMDevice, IMMDeviceEnumerator, + MMDeviceEnumerator, AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED, AUDCLNT_E_DEVICE_IN_USE, + AUDCLNT_SHAREMODE_EXCLUSIVE, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, WAVEFORMATEX, + WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, +}; +use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; +use windows::Win32::System::Threading::{ + CreateEventW, INFINITE, SetEvent, WaitForMultipleObjects, +}; + +use crate::source::DecoderSource; + +/// KSDATAFORMAT_SUBTYPE_PCM +const KSDATAFORMAT_SUBTYPE_PCM: GUID = GUID::from_u128(0x00000001_0000_0010_8000_00aa00389b71); + +/// WAVE_FORMAT_EXTENSIBLE +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; + +/// SPEAKER_MONO +const SPEAKER_MONO: u32 = 0x4; +/// SPEAKER_STEREO +const SPEAKER_STEREO: u32 = 0x3; +/// SPEAKER_5POINT1(含低音炮) +const SPEAKER_5POINT1: u32 = 0x3F; +/// SPEAKER_7POINT1 +const SPEAKER_7POINT1: u32 = 0x63; + +/// 渲染等待句柄索引:关闭信号 +const SHUTDOWN_EVENT_INDEX: u32 = 1; + +/// 独占模式协商出的输出格式 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExclusiveFormat { + /// 采样率(Hz) + pub sample_rate: u32, + /// 声道数 + pub channels: u16, + /// 容器位深(16 / 32) + pub container_bits: u16, + /// 有效位深(16 / 24 / 32) + pub valid_bits: u16, +} + +/// 独占模式打开失败分类,调用方据此决定回退提示文案 +#[derive(Debug, thiserror::Error)] +pub enum ExclusiveOpenError { + /// 设备已被其他程序独占 + #[error("device in use")] + DeviceInUse, + /// 设备不接受任何候选格式 + #[error("format unsupported")] + FormatUnsupported, + /// 端点解析或其他系统错误 + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl ExclusiveOpenError { + /// 回退原因分类键,JS 侧按此取 i18n 文案 + pub fn reason(&self) -> &'static str { + match self { + Self::DeviceInUse => "deviceBusy", + Self::FormatUnsupported => "formatUnsupported", + Self::Other(_) => "unavailable", + } + } +} + +/// 按 cpal 设备 ID 字符串解析 WASAPI 端点 ID。 +/// cpal 0.18 存储的就是 `IMMDevice::GetId` 字符串(形如 `{0.0.0.00000000}.{guid}`), +/// 序列化时可能带后端前缀,取首个 `{` 起的子串即可剥离。 +fn endpoint_id_from_device_id(device_id: &str) -> &str { + match device_id.find('{') { + Some(index) => &device_id[index..], + None => device_id, + } +} + +/// 按端点 ID 或系统默认解析渲染端点 +fn resolve_endpoint(device_id: Option<&str>) -> Result { + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).context("创建设备枚举器失败")?; + match device_id { + Some(id) => { + let wide: Vec = endpoint_id_from_device_id(id) + .encode_utf16() + .chain([0]) + .collect(); + enumerator + .GetDevice(PCWSTR(wide.as_ptr())) + .with_context(|| format!("解析输出端点 '{id}' 失败")) + } + None => enumerator + .GetDefaultAudioEndpoint(eRender, eConsole) + .context("解析默认输出端点失败"), + } + } +} + +/// 声道掩码:仅覆盖常见布局,其余布局独占模式协商本就难以通过 +fn channel_mask(channels: u16) -> u32 { + match channels { + 1 => SPEAKER_MONO, + 2 => SPEAKER_STEREO, + 6 => SPEAKER_5POINT1, + 8 => SPEAKER_7POINT1, + _ => 0, + } +} + +/// 构造 PCM 整型 WAVEFORMATEXTENSIBLE +fn build_wave_format(format: &ExclusiveFormat) -> WAVEFORMATEXTENSIBLE { + let block_align = format.channels * format.container_bits / 8; + WAVEFORMATEXTENSIBLE { + Format: WAVEFORMATEX { + wFormatTag: WAVE_FORMAT_EXTENSIBLE, + nChannels: format.channels, + nSamplesPerSec: format.sample_rate, + wBitsPerSample: format.container_bits, + nBlockAlign: block_align, + nAvgBytesPerSec: format.sample_rate * u32::from(block_align), + cbSize: std::mem::size_of::() as u16, + }, + Samples: WAVEFORMATEXTENSIBLE_0 { + wValidBitsPerSample: format.valid_bits, + }, + dwChannelMask: channel_mask(format.channels), + SubFormat: KSDATAFORMAT_SUBTYPE_PCM, + } +} + +/// 位深候选:优先音源位深(bit-perfect),16bit 作通用兜底 +fn valid_bits_candidates(source_bits: u32) -> Vec { + match source_bits { + 0..=16 => vec![16, 24], + 24 => vec![24, 16], + _ => vec![32, 24, 16], + } +} + +/// 采样率候选:音源原始值优先,回退设备常见的离散值 +fn sample_rate_candidates(source_rate: u32) -> Vec { + let mut rates = vec![source_rate, 48_000, 44_100, 96_000, 192_000]; + rates.dedup(); + rates +} + +/// 独占模式格式协商:逐个尝试候选组合,返回首个被设备接受的格式。 +/// 探测用的 IAudioClient 随函数退出释放,不占用设备独占锁 +fn negotiate_format( + device: &IMMDevice, + source_rate: u32, + source_bits: u32, + fallback_channels: u16, +) -> Result { + unsafe { + let probe: IAudioClient = device + .Activate(CLSCTX_ALL, None) + .context("激活探测音频客户端失败")?; + + let mut channels_candidates = vec![fallback_channels, 2]; + channels_candidates.dedup(); + + for &channels in &channels_candidates { + for &valid_bits in &valid_bits_candidates(source_bits) { + for &rate in &sample_rate_candidates(source_rate) { + let format = ExclusiveFormat { + sample_rate: rate, + channels, + container_bits: if valid_bits == 16 { 16 } else { 32 }, + valid_bits, + }; + let wave = build_wave_format(&format); + // windows 0.62 中 IsFormatSupported 返回原始 HRESULT + let hr = probe.IsFormatSupported( + AUDCLNT_SHAREMODE_EXCLUSIVE, + &wave as *const WAVEFORMATEXTENSIBLE as *const WAVEFORMATEX, + None, + ); + if hr.is_ok() { + info!( + rate = format.sample_rate, + channels = format.channels, + bits = format.valid_bits, + "独占模式格式协商成功" + ); + return Ok(format); + } + if hr == AUDCLNT_E_DEVICE_IN_USE { + return Err(ExclusiveOpenError::DeviceInUse); + } + } + } + } + Err(ExclusiveOpenError::FormatUnsupported) + } +} + +/// 独占模式格式协商入口:解析端点并逐个尝试候选格式 +pub fn negotiate_exclusive_format( + device_id: Option<&str>, + source_rate: u32, + source_bits: u32, + fallback_channels: u16, +) -> Result { + let endpoint = resolve_endpoint(device_id).map_err(ExclusiveOpenError::Other)?; + negotiate_format(&endpoint, source_rate, source_bits, fallback_channels) +} + +/// 内核事件句柄包装:HANDLE 在 windows-rs 中为裸指针(!Send/!Sync), +/// 但句柄仅用于 SetEvent / CloseHandle / WaitForMultipleObjects 等线程安全的内核调用 +#[derive(Clone, Copy)] +struct EventHandle(HANDLE); + +unsafe impl Send for EventHandle {} +unsafe impl Sync for EventHandle {} + +impl EventHandle { + fn set(&self) { + unsafe { + let _ = SetEvent(self.0); + } + } + + fn close(&self) { + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +/// COM 接口指针包装:windows-rs 接口默认 !Send; +/// 本模块运行在 MTA,接口调用无 apartment 亲和性,跨线程移动安全 +struct ComSend(T); + +unsafe impl Send for ComSend {} + +/// 渲染线程等待用句柄对守卫:中途出错时保证关闭 +struct EventHandles(EventHandle, EventHandle); + +impl Drop for EventHandles { + fn drop(&mut self) { + self.0.close(); + self.1.close(); + } +} + +/// f32 → 整型样本转换(应用音量增益后写入) +fn convert_sample(sample: f32, gain: f32, valid_bits: u16) -> i32 { + let clamped = (sample * gain).clamp(-1.0, 1.0); + match valid_bits { + 16 => (clamped * 32_767.0) as i32, + 24 => ((clamped * 8_388_607.0) as i32) << 8, + _ => (clamped * 2_147_483_647.0) as i32, + } +} + +/// 独占模式输出流:事件驱动渲染线程 + WASAPI 独占客户端。 +/// 暂停/停止通过共享原子标志生效于下一个设备周期(约 10ms),无 COM 并发调用 +pub struct ExclusiveStream { + /// 保持 COM 客户端存活,Drop 时由渲染线程退出后统一 Stop + client: Option, + /// 设备周期事件(自动重置) + period_event: EventHandle, + /// 渲染线程关闭信号(手动重置) + shutdown_event: EventHandle, + render_thread: Option>, + paused: Arc, +} + +// 句柄经 EventHandle 包装(内核等待/信号调用线程安全); +// IAudioClient 为 MTA 内的 COM 接口指针,可跨线程调用; +// paused 由渲染线程独占读写语义之外的原子标志,仅作静音开关 +unsafe impl Send for ExclusiveStream {} +unsafe impl Sync for ExclusiveStream {} + +impl ExclusiveStream { + /// 恢复输出 + pub fn play(&self) { + self.paused.store(false, Ordering::Release); + } + + /// 暂停输出(下一周期起静音) + pub fn pause(&self) { + self.paused.store(true, Ordering::Release); + } +} + +impl Drop for ExclusiveStream { + fn drop(&mut self) { + self.shutdown_event.set(); + if let Some(handle) = self.render_thread.take() { + let _ = handle.join(); + } + if let Some(client) = self.client.take() { + unsafe { + let _ = client.Stop(); + } + } + drop(EventHandles(self.period_event, self.shutdown_event)); + debug!("独占模式输出流已释放"); + } +} + +/// 按协商格式创建独占模式输出流 +/// +/// # Arguments +/// * `device_id` - cpal 设备 ID 字符串,`None` 走系统默认端点 +/// * `format` - 协商成功的独占格式 +/// * `source` - 解码样本读取器(渲染线程独占) +/// * `volume` - 音量原子(f32 bits),与 PlaybackHandle 共享 +/// * `stopped` - 停止标志,与 PlaybackHandle 共享 +/// * `paused` - 初始是否暂停 +/// * `on_failure` - 运行期设备错误回调(代次守卫由回调自身保证) +pub fn open_exclusive_stream( + device_id: Option<&str>, + format: ExclusiveFormat, + mut source: DecoderSource, + volume: Arc, + stopped: Arc, + paused: bool, + on_failure: Arc, +) -> Result { + let device_id_owned = device_id.map(String::from); + let endpoint = resolve_endpoint(device_id_owned.as_deref())?; + + unsafe { + let client: IAudioClient = + endpoint.Activate(CLSCTX_ALL, None).context("激活音频客户端失败")?; + + // 独占 + 事件驱动:缓冲时长必须等于设备周期,未对齐时按实际帧数重试 + let mut default_period = 0i64; + client.GetDevicePeriod(Some(&mut default_period), None)?; + let wave = build_wave_format(&format); + let wave_ptr = &wave as *const WAVEFORMATEXTENSIBLE as *const WAVEFORMATEX; + let mut init = client.Initialize( + AUDCLNT_SHAREMODE_EXCLUSIVE, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + default_period, + default_period, + wave_ptr, + None, + ); + if let Err(error) = &init { + if error.code() == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED { + let aligned_frames = client.GetBufferSize()?; + let aligned_duration = + i64::from(aligned_frames) * 10_000_000 / i64::from(format.sample_rate); + init = client.Initialize( + AUDCLNT_SHAREMODE_EXCLUSIVE, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + aligned_duration, + aligned_duration, + wave_ptr, + None, + ); + } + } + init.context("初始化独占模式音频客户端失败")?; + + let period_event = CreateEventW(None, false, false, None).context("创建周期事件失败")?; + let shutdown_event = CreateEventW(None, true, false, None).context("创建关闭事件失败")?; + let period_handle = EventHandle(period_event); + let shutdown_handle = EventHandle(shutdown_event); + let handles = EventHandles(period_handle, shutdown_handle); + + let stream = (|| -> Result { + client + .SetEventHandle(period_handle.0) + .context("设置事件句柄失败")?; + let render: IAudioRenderClient = client.GetService().context("获取渲染客户端失败")?; + let buffer_frames = client.GetBufferSize()?; + + let paused_flag = Arc::new(AtomicBool::new(paused)); + // 事件驱动模式下必须先填满缓冲再启动 + prefill_buffer( + &render, + buffer_frames, + &mut source, + &volume, + &stopped, + &paused_flag, + &format, + )?; + client.Start().context("启动独占模式输出失败")?; + + let thread_client = ComSend(client.clone()); + let thread_render = ComSend(render); + let thread_format = format; + let thread_paused = Arc::clone(&paused_flag); + let render_thread = std::thread::Builder::new() + .name("wasapi-exclusive".into()) + .spawn(move || { + render_loop( + thread_client, + thread_render, + period_handle, + shutdown_handle, + buffer_frames, + thread_format, + source, + volume, + stopped, + thread_paused, + on_failure, + ); + }) + .context("启动独占渲染线程失败")?; + + info!( + rate = format.sample_rate, + channels = format.channels, + bits = format.valid_bits, + frames = buffer_frames, + "独占模式输出流已创建" + ); + Ok(ExclusiveStream { + client: Some(client), + period_event: period_handle, + shutdown_event: shutdown_handle, + render_thread: Some(render_thread), + paused: paused_flag, + }) + })(); + + match stream { + Ok(stream) => { + // 句柄所有权已移交 ExclusiveStream,守卫只负责错误路径清理 + std::mem::forget(handles); + Ok(stream) + } + Err(error) => Err(error), + } + } +} + +/// 按当前音量/静音状态填满整个缓冲(启动前置填充) +fn prefill_buffer( + render: &IAudioRenderClient, + buffer_frames: u32, + source: &mut DecoderSource, + volume: &Arc, + stopped: &Arc, + paused: &Arc, + format: &ExclusiveFormat, +) -> Result<()> { + unsafe { + let ptr = render.GetBuffer(buffer_frames).context("启动预填充失败")?; + let gain = f32::from_bits(volume.load(Ordering::Relaxed)); + let silent = stopped.load(Ordering::Acquire) || paused.load(Ordering::Acquire); + let block_align = usize::from(format.channels * format.container_bits / 8); + let byte_buffer = + std::slice::from_raw_parts_mut(ptr, buffer_frames as usize * block_align); + fill_buffer(byte_buffer, source, gain, silent, format); + render.ReleaseBuffer(buffer_frames, 0).context("启动预填充提交失败") + } +} + +/// 渲染主循环:每个设备周期填充一次缓冲,退出后停止客户端 +#[allow(clippy::too_many_arguments)] +fn render_loop( + client: ComSend, + render: ComSend, + period_event: EventHandle, + shutdown_event: EventHandle, + buffer_frames: u32, + format: ExclusiveFormat, + mut source: DecoderSource, + volume: Arc, + stopped: Arc, + paused: Arc, + on_failure: Arc, +) { + let client = client.0; + let render = render.0; + let wait_handles = [period_event.0, shutdown_event.0]; + let block_align = usize::from(format.channels * format.container_bits / 8); + + loop { + let wait = unsafe { WaitForMultipleObjects(&wait_handles, false, INFINITE) }; + if wait == WAIT_EVENT(WAIT_OBJECT_0.0 + SHUTDOWN_EVENT_INDEX) { + break; + } + + let padding = match unsafe { client.GetCurrentPadding() } { + Ok(padding) => padding, + Err(error) => { + warn!(error = %error, "独占模式读取缓冲水位失败"); + on_failure(); + break; + } + }; + let available = buffer_frames.saturating_sub(padding); + if available == 0 { + continue; + } + + let buffer_ptr = match unsafe { render.GetBuffer(available) } { + Ok(ptr) => ptr, + Err(error) => { + warn!(error = %error, "独占模式获取渲染缓冲失败"); + on_failure(); + break; + } + }; + + let gain = f32::from_bits(volume.load(Ordering::Relaxed)); + let silent = stopped.load(Ordering::Acquire) || paused.load(Ordering::Acquire); + let byte_buffer = + unsafe { std::slice::from_raw_parts_mut(buffer_ptr, available as usize * block_align) }; + fill_buffer(byte_buffer, &mut source, gain, silent, &format); + + if let Err(error) = unsafe { render.ReleaseBuffer(available, 0) } { + warn!(error = %error, "独占模式提交渲染缓冲失败"); + on_failure(); + break; + } + } + + unsafe { + let _ = client.Stop(); + } + debug!("独占模式渲染线程退出"); +} + +/// 将渲染缓冲按有效位深填充(交错样本,静音时填零) +fn fill_buffer( + buffer: &mut [u8], + source: &mut DecoderSource, + gain: f32, + silent: bool, + format: &ExclusiveFormat, +) { + let bytes_per_sample = usize::from(format.container_bits / 8); + let mut raw = [0u8; 4]; + for chunk in buffer.chunks_exact_mut(bytes_per_sample) { + let value = if silent { + 0 + } else { + convert_sample(source.next().unwrap_or(0.0), gain, format.valid_bits) + }; + raw[..bytes_per_sample].copy_from_slice(&value.to_le_bytes()[..bytes_per_sample]); + chunk.copy_from_slice(&raw[..bytes_per_sample]); + } +} diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index d272cf4f0..b9bcfe1c2 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -20,6 +20,7 @@ export const defaultSystemConfig: SystemConfig = { outputDevice: null, volume: 1, loudnessNormalization: false, + audioOutputMode: "shared", equalizer: { enabled: false, preset: "flat", diff --git a/shared/types/player.ts b/shared/types/player.ts index 7a9242d50..316de9e7e 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -221,7 +221,8 @@ export type PlayerEvent = | { type: "toggleLike" } | { type: "fftData"; data: FftData } | { type: "error"; error: string } - | { type: "deviceChanged"; data: { defaultDevice: string | null } }; + | { type: "deviceChanged"; data: { defaultDevice: string | null } } + | { type: "outputFallback"; data: { reason: string } }; /** FFT 数据 */ export interface FftData { diff --git a/shared/types/settings.ts b/shared/types/settings.ts index e1594dfce..d2831b189 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -37,6 +37,9 @@ export interface EqualizerSettings { } /** 播放器配置 */ +/** 音频输出模式 */ +export type AudioOutputMode = "shared" | "exclusive"; + export interface PlayerSettings { /** 加载后自动播放 */ autoPlay: boolean; @@ -52,6 +55,8 @@ export interface PlayerSettings { volume: number; /** 音量均衡(响度归一化) */ loudnessNormalization: boolean; + /** 音频输出模式:共享(默认)/ WASAPI 独占(仅 Windows) */ + audioOutputMode: AudioOutputMode; /** 均衡器配置 */ equalizer: EqualizerSettings; /** 按 `{Track.id}|{歌词源}` 记忆的歌词偏移(ms,正值为歌词提前);为 0 时不写入 */ diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 0880d3fdf..572f05742 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -282,7 +282,11 @@ const showComments = (): void => {
diff --git a/src/core/player/events.ts b/src/core/player/events.ts index a08d99718..41d22181e 100644 --- a/src/core/player/events.ts +++ b/src/core/player/events.ts @@ -1,4 +1,6 @@ import type { PlayerEvent } from "@shared/types/player"; +import i18n from "@/i18n"; +import { toast } from "@/composables/useToast"; import { useMediaStore } from "@/stores/media"; import { useStatusStore } from "@/stores/status"; import { useFavorite } from "@/composables/useFavorite"; @@ -144,5 +146,14 @@ export const handleEvent = async (event: PlayerEvent): Promise => { refreshDevices(); break; } + case "outputFallback": { + // WASAPI 独占模式不可用已回退共享,按原因分类提示 + const key = `settings.audioOutputMode.fallback.${event.data.reason}`; + const message = i18n.global.te(key) + ? i18n.global.t(key) + : i18n.global.t("settings.audioOutputMode.fallback.unavailable"); + toast.warning(message); + break; + } } }; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index e5f9b0a61..de813276f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -879,6 +879,17 @@ "description": "Select audio output device", "default": "System Default" }, + "audioOutputMode": { + "label": "Audio Output Mode", + "description": "Exclusive mode bypasses the system mixer and sends the original sample rate and bit depth directly to the sound card (bit-perfect), ideal for external DACs / Hi-Fi gear; other apps cannot play audio while exclusive", + "shared": "Shared Mode", + "exclusive": "WASAPI Exclusive Mode", + "fallback": { + "deviceBusy": "The device is being used exclusively by another app, falling back to shared mode", + "formatUnsupported": "The device does not support the required exclusive-mode format, falling back to shared mode", + "unavailable": "Exclusive mode is currently unavailable, falling back to shared mode" + } + }, "pauseOnDeviceSwitch": { "label": "Pause on Device Switch", "description": "Pause playback when switching the output device, e.g. when a Bluetooth device disconnects" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5f059335c..4301ee4db 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -867,6 +867,17 @@ "description": "选择音频输出设备", "default": "系统默认" }, + "audioOutputMode": { + "label": "音频输出模式", + "description": "独占模式绕过系统混音器,按音源采样率与位深直通声卡(bit-perfect),适合外接 DAC / Hi-Fi 设备;独占期间其他应用无法播放声音", + "shared": "共享模式", + "exclusive": "WASAPI 独占模式", + "fallback": { + "deviceBusy": "设备已被其他程序独占,已回退共享模式", + "formatUnsupported": "设备不支持独占模式所需的音频格式,已回退共享模式", + "unavailable": "独占模式暂不可用,已回退共享模式" + } + }, "pauseOnDeviceSwitch": { "label": "切换时暂停播放", "description": "切换输出设备时暂停播放,例如蓝牙设备的断联" diff --git a/src/settings/categories/appearance.ts b/src/settings/categories/appearance.ts index fd7505ac5..12f01a6c5 100644 --- a/src/settings/categories/appearance.ts +++ b/src/settings/categories/appearance.ts @@ -243,8 +243,7 @@ const appearanceCategory: SettingCategory = { contentKey: "settings.confirm.highResourceContent", type: "warning", }, - childrenCondition: () => - useSettingsStore().player.playerBgType === "animation", + childrenCondition: () => useSettingsStore().player.playerBgType === "animation", hideChildren: true, children: [ { diff --git a/src/settings/categories/player.ts b/src/settings/categories/player.ts index 2b6144653..22e07b8a7 100644 --- a/src/settings/categories/player.ts +++ b/src/settings/categories/player.ts @@ -1,5 +1,6 @@ import type { SettingCategory } from "@/types/settings-schema"; import DeviceSelector from "@/components/settings/custom/DeviceSelector.vue"; +import { isWin } from "@/utils/config"; import IconLucidePlay from "~icons/lucide/play"; const playerCategory: SettingCategory = { @@ -141,6 +142,17 @@ const playerCategory: SettingCategory = { type: "custom", component: DeviceSelector, }, + { + key: "audioOutputMode", + type: "select", + binding: { store: "settings", path: "system.player.audioOutputMode" }, + options: [ + { value: "shared", labelKey: "settings.audioOutputMode.shared" }, + { value: "exclusive", labelKey: "settings.audioOutputMode.exclusive" }, + ], + defaultValue: "shared", + visible: () => isWin, + }, { key: "pauseOnDeviceSwitch", type: "switch",