Skip to content

Commit 1122157

Browse files
authored
Merge pull request #958 from ITManCHINA/web-audio-optimization
✨ feat: Web Audio 音频输出优化
2 parents e98159f + 02cfe54 commit 1122157

10 files changed

Lines changed: 132 additions & 2 deletions

File tree

src/components/Setting/config/play.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,63 @@ export const usePlaySettings = (): SettingConfig => {
544544
set: (v) => handleAudioEngineSelect(v),
545545
}),
546546
},
547+
{
548+
key: "audioLatencyHint",
549+
label: "Web Audio 延迟策略",
550+
type: "select",
551+
tags: [{ text: "Beta", type: "warning" }],
552+
description:
553+
"调整 Web Audio 的延迟策略,修改后需重启。<br>" +
554+
"“低延迟模式(interactive)”延迟更低但可能不稳定;<br>" +
555+
"“高效能模式(playback)”延迟偏高但播放更稳定。<br>" +
556+
"已针对“高效能模式(playback)”补偿了音频输出延迟,理论上不会造成歌词与音频不同步的问题。",
557+
options: [
558+
{ label: "低延迟模式(interactive)", value: "interactive" },
559+
{ label: "高效能模式(playback)", value: "playback" },
560+
],
561+
value: computed({
562+
get: () => settingStore.audioLatencyHint,
563+
set: (v) => {
564+
window.$dialog.warning({
565+
title: "更改延迟策略",
566+
content: "此操作需要重启应用才能生效,是否立即重启?",
567+
positiveText: "重启",
568+
negativeText: "取消",
569+
onPositiveClick: () => {
570+
settingStore.audioLatencyHint = v;
571+
if (isElectron) {
572+
window.electron.ipcRenderer.send("win-restart");
573+
} else {
574+
window.location.reload();
575+
}
576+
},
577+
});
578+
},
579+
}),
580+
show: computed(
581+
() =>
582+
settingStore.playbackEngine === "web-audio" &&
583+
settingStore.audioEngine === "element",
584+
),
585+
},
586+
{
587+
key: "audioDelayCompensation",
588+
label: "音频与歌词同步补偿",
589+
type: "input-number",
590+
description:
591+
"手动补偿音频与歌词进度延迟。<br>正值歌词变快,负值歌词进度变慢。<br>适用于移动端等自动延迟检测不准的设备。",
592+
tags: [{ text: "Beta", type: "warning" }],
593+
show: computed(() => settingStore.audioLatencyHint === "playback"),
594+
min: -1000,
595+
max: 1000,
596+
step: 10,
597+
suffix: "ms",
598+
value: computed({
599+
get: () => settingStore.audioDelayCompensation,
600+
set: (v) => (settingStore.audioDelayCompensation = v ?? 0),
601+
}),
602+
defaultValue: 0,
603+
},
547604
{
548605
key: "playSongDemo",
549606
label: "播放试听",

src/core/audio-player/AudioElementPlayer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ export class AudioElementPlayer extends BaseAudioPlayer {
196196
if (this.isInternalSeeking) {
197197
return this.targetSeekTime;
198198
}
199-
return this.audioElement.currentTime || 0;
199+
// 基础时间 - 自动延迟补偿 + 手动延迟补偿
200+
return (
201+
(this.audioElement.currentTime || 0) -
202+
this.compensatedLatency +
203+
this.audioDelayCompensation / 1000
204+
);
200205
}
201206

202207
/** 获取是否暂停状态 */

src/core/audio-player/BaseAudioPlayer.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useSettingStore } from "@/stores";
12
import { TypedEventTarget } from "@/utils/TypedEventTarget";
23
import type { IExtendedAudioContext } from "@/types/audio/context";
34
import { AudioEffectManager } from "./AudioEffectManager";
@@ -65,6 +66,11 @@ export abstract class BaseAudioPlayer
6566
/** 输入节点 (子类将源连接到此处) */
6667
protected inputNode: GainNode | null = null;
6768

69+
protected compensatedLatency = 0;
70+
71+
/** 用户手动设置的音频延迟补偿 (毫秒) */
72+
protected audioDelayCompensation = 0;
73+
6874
protected effectManager: AudioEffectManager | null = null;
6975

7076
/** 初始化状态 */
@@ -103,6 +109,14 @@ export abstract class BaseAudioPlayer
103109
processedNode.connect(this.gainNode);
104110
this.gainNode.connect(getSharedMasterInput());
105111

112+
const settingStore = useSettingStore();
113+
if (settingStore.audioLatencyHint === "playback") {
114+
this.compensatedLatency =
115+
(this.audioCtx.outputLatency || 0) + (this.audioCtx.baseLatency || 0);
116+
} else {
117+
this.compensatedLatency = 0;
118+
}
119+
106120
// 应用初始音量
107121
this.gainNode.gain.value = this.volume;
108122

@@ -478,6 +492,14 @@ export abstract class BaseAudioPlayer
478492
return this.effectManager ? this.effectManager.getFilterGains() : [];
479493
}
480494

495+
/**
496+
* 设置歌词同步偏移
497+
* @param offset 偏移量 (毫秒)
498+
*/
499+
public setAudioDelayCompensation(offset: number): void {
500+
this.audioDelayCompensation = offset;
501+
}
502+
481503
/** 加载资源 */
482504
public abstract load(url: string): Promise<void>;
483505

src/core/audio-player/IPlaybackEngine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ export interface IPlaybackEngine {
136136
*/
137137
getRate(): number;
138138

139+
/**
140+
* 设置音频延迟手动补偿
141+
* @param offset 偏移量 (毫秒)
142+
*/
143+
setAudioDelayCompensation(offset: number): void;
144+
139145
/**
140146
* 设置音频输出设备
141147
* @param deviceId 设备 ID

src/core/audio-player/MpvPlayer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ export class MpvPlayer extends EventTarget implements IPlaybackEngine {
268268
}
269269
}
270270

271+
public setAudioDelayCompensation(offset: number): void {
272+
// MPV 引擎不使用 Web Audio API,此设置无效
273+
void offset;
274+
}
275+
271276
public getErrorCode(): number {
272277
return this._errorCode;
273278
}

src/core/audio-player/ffmpeg-engine/FFmpegAudioPlayer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@ export class FFmpegAudioPlayer extends BaseAudioPlayer {
371371
return this.currentTempo;
372372
}
373373

374+
public setAudioDelayCompensation(offset: number): void {
375+
// FFmpeg 引擎使用独立的时钟同步机制,此设置无效
376+
void offset;
377+
}
378+
374379
public async setTempo(tempo: number) {
375380
if (!this.worker) return;
376381
const trueTime = this.currentTime;

src/core/automix/SharedAudioContext.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useSettingStore } from "@/stores";
12
import type { IExtendedAudioContext } from "@/types/audio/context";
23

34
/** 共享音频上下文 */
@@ -13,14 +14,17 @@ let masterLimiter: DynamicsCompressorNode | null = null;
1314
*/
1415
export const getSharedAudioContext = (): IExtendedAudioContext => {
1516
if (!sharedContext) {
17+
const settingStore = useSettingStore();
1618
const AudioContextClass =
1719
window.AudioContext ||
1820
(
1921
window as unknown as {
2022
webkitAudioContext: typeof AudioContext;
2123
}
2224
).webkitAudioContext;
23-
sharedContext = new AudioContextClass() as IExtendedAudioContext;
25+
sharedContext = new AudioContextClass({
26+
latencyHint: settingStore.audioLatencyHint,
27+
}) as IExtendedAudioContext;
2428
}
2529
return sharedContext;
2630
};

src/core/player/AudioManager.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,15 @@ class AudioManager extends TypedEventTarget<AudioEventMap> implements IPlaybackE
366366
return this.engine.getRate();
367367
}
368368

369+
/**
370+
* 设置音频延迟手动补偿
371+
* @param offset 偏移量 (毫秒)
372+
*/
373+
public setAudioDelayCompensation(offset: number): void {
374+
// FFmpeg 和 MPV 引擎可能没有实现此方法
375+
this.engine.setAudioDelayCompensation?.(offset);
376+
}
377+
369378
/**
370379
* 设置输出设备
371380
*/
@@ -504,6 +513,16 @@ export const useAudioManager = (): AudioManager => {
504513
settingStore.playbackEngine,
505514
settingStore.audioEngine,
506515
);
516+
517+
// 监听音频延迟补偿变化
518+
watch(
519+
() => settingStore.audioDelayCompensation,
520+
(offset) => {
521+
win[AUDIO_MANAGER_KEY]?.setAudioDelayCompensation(offset);
522+
},
523+
{ immediate: true }, // 立即执行一次以应用初始值
524+
);
525+
507526
console.log(`[AudioManager] 创建新实例, engine: ${win[AUDIO_MANAGER_KEY].engineType}`);
508527
}
509528
return win[AUDIO_MANAGER_KEY];

src/stores/setting.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export interface SettingState {
154154
playDevice: "default" | string;
155155
/** 音频引擎: element (原生) 或 ffmpeg */
156156
audioEngine: "element" | "ffmpeg";
157+
/** Web Audio 延迟策略 */
158+
audioLatencyHint: "interactive" | "playback";
157159
/** 自动播放 */
158160
autoPlay: boolean;
159161
/** 预载下一首 */
@@ -224,6 +226,8 @@ export interface SettingState {
224226
wordFadeWidth: number;
225227
/** 歌词时延调节步长(毫秒) */
226228
lyricOffsetStep: number;
229+
/** 音频延迟手动补偿(毫秒) */
230+
audioDelayCompensation: number;
227231
/** 启用在线 TTML 歌词 */
228232
enableOnlineTTMLLyric: boolean;
229233
/** 启用 QM 歌词 */
@@ -513,6 +517,7 @@ export const useSettingStore = defineStore("setting", {
513517
songLevel: "exhigh",
514518
playDevice: "default",
515519
audioEngine: "element",
520+
audioLatencyHint: "interactive",
516521
autoPlay: false,
517522
useNextPrefetch: true,
518523
songVolumeFade: true,
@@ -557,6 +562,7 @@ export const useSettingStore = defineStore("setting", {
557562
hidePassedLines: false,
558563
wordFadeWidth: 0.5,
559564
lyricOffsetStep: 500,
565+
audioDelayCompensation: 0,
560566
enableOnlineTTMLLyric: false,
561567
enableQQMusicLyric: false,
562568
lyricPriority: "auto",

src/types/audio/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** 扩充 AudioContext 接口以支持 setSinkId (实验性 API) */
22
export interface IExtendedAudioContext extends AudioContext {
33
setSinkId(deviceId: string): Promise<void>;
4+
readonly latencyHint?: "playback" | "interactive";
45
}

0 commit comments

Comments
 (0)