feat: 新增 均衡器

This commit is contained in:
底层用户
2025-10-21 18:15:51 +08:00
parent 9baf571478
commit d4d16b71ae
7 changed files with 538 additions and 20 deletions

1
src/assets/icons/Eq.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M7 17V7q0-.425.288-.712T8 6t.713.288T9 7v10q0 .425-.288.713T8 18t-.712-.288T7 17m4 4V3q0-.425.288-.712T12 2t.713.288T13 3v18q0 .425-.288.713T12 22t-.712-.288T11 21m-8-8v-2q0-.425.288-.712T4 10t.713.288T5 11v2q0 .425-.288.713T4 14t-.712-.288T3 13m12 4V7q0-.425.288-.712T16 6t.713.288T17 7v10q0 .425-.288.713T16 18t-.712-.288T15 17m4-4v-2q0-.425.288-.712T20 10t.713.288T21 11v2q0 .425-.288.713T20 14t-.712-.288T19 13"/></svg>

After

Width:  |  Height:  |  Size: 650 B

View File

@@ -0,0 +1,165 @@
<template>
<div class="equalizer">
<n-flex align="center" justify="space-between" :size="8">
<n-flex wrap :size="8" class="eq-presets">
<n-tag
v-for="(preset, key) in presetList"
:key="key"
:type="currentPreset === key ? 'primary' : 'default'"
:bordered="currentPreset === key"
:disabled="!enabled"
round
@click="applyPreset(key as PresetKey)"
>
{{ preset.label }}
</n-tag>
</n-flex>
<n-switch v-model:value="enabled" :round="false" :disabled="!isElectron" />
</n-flex>
<div class="eq-sliders">
<div v-for="(freq, i) in frequencies" :key="freq" class="eq-col">
<div class="eq-freq">{{ freqLabels[i] }}</div>
<n-slider
v-model:value="bands[i]"
:min="-12"
:max="12"
:step="0.5"
:tooltip="false"
vertical
:disabled="!enabled || !isElectron"
@update:value="onBandChange(i, $event)"
/>
<div class="eq-value">{{ formatDb(bands[i]) }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { isElectron } from "@/utils/helper";
import { useStatusStore } from "@/stores";
import player from "@/utils/player";
const statusStore = useStatusStore();
type PresetKey = keyof typeof presetList;
// 10 段中心频率
const frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const freqLabels = [
"31Hz",
"62Hz",
"125Hz",
"250Hz",
"500Hz",
"1kHz",
"2kHz",
"4kHz",
"8kHz",
"16kHz",
];
// 预设(单位 dB范围建议在 [-12, 12]
const presetList = {
acoustic: { label: "原声", bands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
pop: { label: "流行", bands: [0, 2, 4, 4, 1, -1, -1, 1, 2, 2] },
rock: { label: "摇滚", bands: [4, 3, 2, 0, -1, 1, 2, 3, 3, 2] },
classical: { label: "古典", bands: [0, 0, 1, 2, 3, 3, 2, 1, 0, 0] },
jazz: { label: "爵士", bands: [0, 2, 3, 2, 0, 1, 2, 2, 1, 0] },
vocal: { label: "人声", bands: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2] },
dance: { label: "舞曲", bands: [5, 4, 3, 1, -1, 0, 2, 3, 3, 2] },
custom: { label: "自定义", bands: [] as number[] },
} as const;
const enabled = ref<boolean>(statusStore.eqEnabled);
const currentPreset = ref<PresetKey>((statusStore.eqPreset as PresetKey) || "custom");
const bands = ref<number[]>(
statusStore.eqBands?.length === 10 ? [...statusStore.eqBands] : Array(10).fill(0),
);
/** 格式化 dB 文本 */
const formatDb = (v: number) => `${v >= 0 ? "+" : ""}${v}dB`;
/**
* 应用预设
*/
const applyPreset = (key: PresetKey) => {
if (!enabled.value) return;
currentPreset.value = key;
statusStore.setEqPreset(key);
// 自定义不覆盖当前频段
if (key !== "custom") {
const arr = presetList[key].bands;
bands.value = [...arr];
statusStore.setEqBands(bands.value);
if (enabled.value) player.updateEq({ bands: bands.value });
}
};
/**
* 根据当前开关状态应用/移除 EQ
*/
const applyEq = () => {
if (!isElectron) return;
statusStore.setEqEnabled(enabled.value);
statusStore.setEqBands(bands.value);
if (enabled.value) {
player.enableEq({ bands: bands.value, frequencies });
} else {
player.disableEq();
}
};
/**
* 单段变更处理:实时更新 EQ
*/
const onBandChange = (index: number, value: number) => {
bands.value[index] = value;
statusStore.setEqBands(bands.value);
// 任何手动拖动都切换为自定义
if (currentPreset.value !== "custom") {
currentPreset.value = "custom";
statusStore.setEqPreset("custom");
}
if (enabled.value) player.updateEq({ bands: bands.value });
};
watch(enabled, () => applyEq());
onMounted(() => {
// 初始状态:若持久化为开启,则直接应用
if (isElectron && enabled.value) player.enableEq({ bands: bands.value, frequencies });
});
</script>
<style scoped lang="scss">
.equalizer {
.eq-sliders {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 12px;
margin-top: 20px;
.eq-col {
display: flex;
flex-direction: column;
align-items: center;
.eq-freq {
height: 20px;
font-size: 12px;
opacity: 0.75;
margin-bottom: 6px;
}
:deep(.n-slider) {
height: 160px;
}
.eq-value {
margin-top: 6px;
font-size: 12px;
opacity: 0.8;
white-space: nowrap;
}
}
}
}
</style>

View File

@@ -64,7 +64,7 @@ import type { DropdownOption } from "naive-ui";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { isElectron, renderIcon } from "@/utils/helper";
import player from "@/utils/player";
import { openAutoClose, openChangeRate } from "@/utils/modal";
import { openAutoClose, openChangeRate, openEqualizer } from "@/utils/modal";
const dataStore = useDataStore();
const musicStore = useMusicStore();
@@ -92,6 +92,14 @@ const playModeOptions: DropdownOption[] = [
// 其他控制:播放速度下拉菜单
const controlsOptions = computed<DropdownOption[]>(() => [
{
label: "均衡器",
key: "equalizer",
icon: renderIcon("Eq"),
props: {
onClick: () => openEqualizer(),
},
},
{
label: "自动关闭",
key: "autoClose",

View File

@@ -72,6 +72,12 @@ interface StatusState {
personalFmMode: boolean;
/** 更新检查 */
updateCheck: boolean;
/** 均衡器是否开启 */
eqEnabled: boolean;
/** 均衡器 10 段增益dB */
eqBands: number[];
/** 均衡器当前预设 key */
eqPreset: string;
/** 自动关闭 */
autoClose: {
/** 自动关闭 */
@@ -118,6 +124,9 @@ export const useStatusStore = defineStore("status", {
showDesktopLyric: false,
showPlayerComment: false,
updateCheck: false,
eqEnabled: false,
eqBands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
eqPreset: "acoustic",
autoClose: {
enable: false,
time: 30,
@@ -196,6 +205,28 @@ export const useStatusStore = defineStore("status", {
delete this.currentTimeOffsetMap[songId];
}
},
/**
* 设置 EQ 开关
* @param enabled 是否开启
*/
setEqEnabled(enabled: boolean) {
this.eqEnabled = enabled;
},
/**
* 设置 EQ 10 段增益dB
* @param bands 长度 10 的 dB 数组
*/
setEqBands(bands: number[]) {
if (Array.isArray(bands) && bands.length === 10) {
this.eqBands = [...bands];
}
},
/**
* 设置 EQ 预设名
*/
setEqPreset(preset: string) {
this.eqPreset = preset;
},
},
// 持久化
persist: {
@@ -220,6 +251,9 @@ export const useStatusStore = defineStore("status", {
"playHeartbeatMode",
"personalFmMode",
"autoClose",
"eqEnabled",
"eqBands",
"eqPreset",
],
},
});

View File

@@ -19,6 +19,7 @@ import UpdateApp from "@/components/Modal/UpdateApp.vue";
import ExcludeKeywords from "@/components/Modal/ExcludeKeywords.vue";
import ChangeRate from "@/components/Modal/ChangeRate.vue";
import AutoClose from "@/components/Modal/AutoClose.vue";
import Equalizer from "@/components/Modal/Equalizer.vue";
// 用户协议
export const openUserAgreement = () => {
@@ -279,3 +280,17 @@ export const openAutoClose = () => {
},
});
};
/** 打开均衡器弹窗 */
export const openEqualizer = () => {
window.$modal.create({
preset: "card",
transformOrigin: "center",
autoFocus: false,
style: { width: "600px" },
title: "均衡器",
content: () => {
return h(Equalizer);
},
});
};

View File

@@ -0,0 +1,233 @@
import { isElectron } from "@/utils/helper";
/**
* 针对单个 HTMLMediaElement 的音频图节点集合
* - context: 全局共享的 AudioContext 实例
* - source: 由 mediaElement 创建的源节点
* - analyser: 频谱分析节点(频谱显示/后续处理的观察点)
* - preGain: 均衡器前级增益(可选,启用 EQ 时存在)
* - filters: 均衡器滤波器链(可选,启用 EQ 时存在,首尾为 shelf中间为 peaking
*/
export type AudioGraphNodes = {
context: AudioContext;
source: MediaElementAudioSourceNode;
analyser: AnalyserNode;
preGain?: GainNode;
filters?: BiquadFilterNode[];
};
/**
* AudioContextManager
* - 单例管理器:维护一个全局 AudioContext 与每个 mediaElement 的音频图
* - 提供:
* 1) 基础图创建与缓存source -> analyser
* 2) EQ 启用、更新、禁用(构建/修改 source -> preGain -> filters... -> analyser 链)
* 3) 图销毁与全局销毁
*/
class AudioContextManager {
private static instance: AudioContextManager | null = null;
private context: AudioContext | null = null;
private nodeMap: WeakMap<HTMLMediaElement, AudioGraphNodes> = new WeakMap();
// 默认 10 段均衡器中心频率 (Hz)
private readonly defaultFrequencies: number[] = [
31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000,
];
// 默认 Q 值(首尾 shelf 不使用 Q
private readonly defaultQ: number = 1.0;
/** 获取管理器单例 */
static getInstance() {
if (!AudioContextManager.instance) {
AudioContextManager.instance = new AudioContextManager();
}
return AudioContextManager.instance;
}
/**
* 获取或创建全局 AudioContext
* 说明:仅在 Electron 环境中生效;浏览器端维持与现有逻辑一致不启用
*/
getContext(): AudioContext | null {
if (!isElectron) return null;
if (!this.context) {
this.context = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return this.context;
}
/**
* 获取(或为该 mediaElement 创建基础音频图source -> analyser
* 不在此处连接 destination由调用方决定是否连接到输出以避免与 HTMLAudioElement 直出叠加
*/
getOrCreateBasicGraph(mediaElement: HTMLMediaElement): AudioGraphNodes | null {
if (!isElectron) return null;
const existing = this.nodeMap.get(mediaElement);
if (existing) return existing;
const context = this.getContext();
if (!context) return null;
const source = context.createMediaElementSource(mediaElement);
const analyser = context.createAnalyser();
analyser.fftSize = 512;
// 默认仅连接到 analyser是否接到 destination 由上层控制
source.connect(analyser);
const nodes: AudioGraphNodes = { context, source, analyser };
this.nodeMap.set(mediaElement, nodes);
return nodes;
}
// 计算 dB 到线性增益
private dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
/** 启用均衡器source -> preGain -> filters... -> analyser不连接 destination */
enableEq(
mediaElement: HTMLMediaElement,
options?: {
bands?: number[]; // dB 值,与频率一一对应
frequencies?: number[]; // 自定义中心频率
q?: number; // peaking Q
preamp?: number; // dB
},
) {
if (!isElectron) return null;
const nodes = this.getOrCreateBasicGraph(mediaElement);
if (!nodes) return null;
const { context, source, analyser } = nodes;
// 断开 source 直连 analyser改为走 EQ 链
// 如果已存在 EQ 链,先彻底移除,避免重复并联导致增益叠加
try {
if (nodes.filters && nodes.filters.length) nodes.filters.forEach((f) => f.disconnect());
if (nodes.preGain) nodes.preGain.disconnect();
} catch {
/* empty */
}
try {
source.disconnect();
} catch {
/* empty */
}
// 创建 preGain前级增益整体增益控制避免各段叠加导致失真
const preGain = context.createGain();
preGain.gain.value = this.dbToLinear(options?.preamp ?? 0);
// 创建滤波器10 段:首尾 shelf中间 peaking
const freqs = options?.frequencies ?? this.defaultFrequencies;
const gains = options?.bands ?? new Array(freqs.length).fill(0);
const q = options?.q ?? this.defaultQ;
const filters: BiquadFilterNode[] = freqs.map((f, i) => {
const filter = context.createBiquadFilter();
if (i === 0) filter.type = "lowshelf";
else if (i === freqs.length - 1) filter.type = "highshelf";
else filter.type = "peaking";
filter.frequency.value = f;
if (filter.type === "peaking") filter.Q.value = q;
filter.gain.value = gains[i] ?? 0; // dB
return filter;
});
// 连接链路source -> preGain -> f0 -> f1 ... -> fn -> analyser
// 注意:不在此处连接到 destination交由上层player决定防止与 HTML 元素直出叠加
source.connect(preGain);
let current: AudioNode = preGain;
for (const f of filters) {
current.connect(f);
current = f;
}
current.connect(analyser);
// 保存
nodes.preGain = preGain;
nodes.filters = filters;
this.nodeMap.set(mediaElement, nodes);
return nodes;
}
/**
* 更新均衡器参数(不重建链路)
* - bands: 各频段 dB 值(与 frequencies 对齐),直接写入 filter.gain
* - preamp: 前级增益 dB转换为线性增益写入 preGain.gain
* - q: peaking 类型的 Q 值统一更新shelf 不适用 Q
*/
updateEq(
mediaElement: HTMLMediaElement,
options: { bands?: number[]; preamp?: number; q?: number } = {},
) {
const nodes = this.nodeMap.get(mediaElement);
if (!nodes || !nodes.filters || !nodes.preGain) return;
const { filters, preGain } = nodes;
if (typeof options.preamp === "number") {
preGain.gain.value = this.dbToLinear(options.preamp);
}
if (Array.isArray(options.bands)) {
filters.forEach((f, idx) => {
if (typeof options.bands![idx] === "number") f.gain.value = options.bands![idx] as number;
});
}
if (typeof options.q === "number") {
filters.forEach((f) => {
if (f.type === "peaking") f.Q.value = options.q as number;
});
}
}
/**
* 禁用均衡器
* - 断开 preGain 与所有 filters并恢复为 source -> analyser
* - 不直接连接到 destination由调用方按需处理
*/
disableEq(mediaElement: HTMLMediaElement) {
const nodes = this.nodeMap.get(mediaElement);
if (!nodes) return;
const { source, analyser, preGain, filters } = nodes;
try {
if (filters && filters.length) filters.forEach((f) => f.disconnect());
if (preGain) preGain.disconnect();
} catch {
/* empty */
}
try {
source.disconnect();
} catch {
/* empty */
}
source.connect(analyser);
nodes.preGain = undefined;
nodes.filters = undefined;
this.nodeMap.set(mediaElement, nodes);
}
/**
* 断开并移除指定元素的图(不关闭全局 context
* - 用于元素销毁或完全停止可视化/处理时的清理
*/
disposeGraph(mediaElement: HTMLMediaElement) {
const nodes = this.nodeMap.get(mediaElement);
if (!nodes) return;
try {
nodes.source.disconnect();
nodes.analyser.disconnect();
} catch {
/* empty */
}
this.nodeMap.delete(mediaElement);
}
/**
* 销毁整个上下文(谨慎调用)
* - 关闭全局 AudioContext并清空所有节点缓存
* - 仅在应用退出或需要彻底重建时调用
*/
destroyAll() {
if (this.context) {
try {
this.context.close();
} catch {
/* empty */
}
this.context = null;
}
this.nodeMap = new WeakMap();
}
}
const audioContextManager = AudioContextManager.getInstance();
export default audioContextManager;

View File

@@ -19,6 +19,7 @@ import {
getUnlockSongUrl,
} from "./player-utils/song";
import { getLyricData } from "./player-utils/lyric";
import audioContextManager from "@/utils/player-utils/context";
import blob from "./blob";
// 播放器核心
@@ -38,7 +39,6 @@ class Player {
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private dataArray: Uint8Array<ArrayBuffer> | null = null;
private source: MediaElementAudioSourceNode | null = null;
/** 其他数据 */
private testNumber: number = 0;
private message: MessageReactive | null = null;
@@ -243,7 +243,15 @@ class Player {
// 允许跨域
if (settingStore.showSpectrums) {
const audioDom = this.getAudioDom();
audioDom.crossOrigin = "anonymous";
if (audioDom) audioDom.crossOrigin = "anonymous";
}
// 恢复均衡器:如持久化为开启,则在音频节点可用后立即构建 EQ 链
if (isElectron && statusStore.eqEnabled) {
try {
this.enableEq({ bands: statusStore.eqBands });
} catch {
/* empty */
}
}
// 恢复进度仅在明确指定且大于0时才恢复避免切换歌曲时意外恢复进度
if (seek && seek > 0) {
@@ -439,12 +447,14 @@ class Player {
/**
* 获取 Audio Dom
*/
private getAudioDom() {
const audioDom = (this.player as any)._sounds[0]._node;
if (!audioDom) {
throw new Error("Audio Dom is null");
private getAudioDom(): HTMLMediaElement | null {
try {
const sounds = (this.player as any)?._sounds;
const node = sounds && sounds.length ? sounds[0]?._node : null;
return node || null;
} catch {
return null;
}
return audioDom;
}
/**
* 获取本地歌曲元信息
@@ -1071,21 +1081,18 @@ class Player {
initSpectrumData() {
try {
if (this.audioContext || !isElectron) return;
// AudioContext
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// 获取音频元素
const audioDom = this.getAudioDom();
// 媒体元素源
this.source = this.audioContext.createMediaElementSource(audioDom);
// AnalyserNode
this.analyser = this.audioContext.createAnalyser();
// 频谱分析器 FFT
this.analyser.fftSize = 512;
// 连接源和分析节点
this.source.connect(this.analyser);
// 连接分析节点到 AudioContext
if (!audioDom) return;
// 通过统一管理器创建/获取基础图
const nodes = audioContextManager.getOrCreateBasicGraph(audioDom);
if (!nodes) return;
// 记录节点
this.audioContext = nodes.context;
this.analyser = nodes.analyser;
// 可视化保持与原有行为一致:连接到输出
this.analyser.connect(this.audioContext.destination);
// 配置 AnalyserNode
// 配置数据缓冲
const bufferLength = this.analyser.frequencyBinCount;
this.dataArray = new Uint8Array(bufferLength);
// 更新频谱数据
@@ -1095,6 +1102,61 @@ class Player {
console.error("🎼 Initialize music spectrum failed:", error);
}
}
/**
* 启用均衡器
* @param options 配置
* @param options.bands 各频段 dB 值(与 frequencies 对齐),直接写入 filter.gain
* @param options.preamp 前级增益 dB转换为线性增益写入 preGain.gain
* @param options.q peaking 类型的 Q 值统一更新shelf 不适用 Q
* @param options.frequencies 自定义中心频率
*/
enableEq(options?: { bands?: number[]; preamp?: number; q?: number; frequencies?: number[] }) {
if (!isElectron) return;
const audioDom = this.getAudioDom();
if (!audioDom) return;
const nodes = audioContextManager.enableEq(audioDom, options);
if (!nodes) return;
// 连接到输出,确保声音从 WebAudio 输出
try {
nodes.analyser.connect(nodes.context.destination);
} catch {
/* empty */
}
}
/**
* 更新均衡器参数
* @param options 配置
* @param options.bands 各频段 dB 值(与 frequencies 对齐),直接写入 filter.gain
* @param options.preamp 前级增益 dB转换为线性增益写入 preGain.gain
* @param options.q peaking 类型的 Q 值统一更新shelf 不适用 Q
*/
updateEq(options: { bands?: number[]; preamp?: number; q?: number }) {
if (!isElectron) return;
const audioDom = this.getAudioDom();
if (!audioDom) return;
audioContextManager.updateEq(audioDom, options);
}
/**
* 禁用均衡器并恢复直出(保持频谱可用)
*/
disableEq() {
if (!isElectron) return;
const audioDom = this.getAudioDom();
if (!audioDom) return;
audioContextManager.disableEq(audioDom);
// 恢复 analyser 输出
const nodes = audioContextManager.getOrCreateBasicGraph(audioDom);
if (nodes) {
try {
nodes.analyser.connect(nodes.context.destination);
} catch {
/* empty */
}
}
}
/**
* 切换桌面歌词
*/