mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-25 00:37:24 +08:00
✨ feat: 添加EQ音效调节功能 实时调节以及多个预设提供
This commit is contained in:
+2
-1
@@ -83,7 +83,8 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vue-tsc": "^2.0.22",
|
"vue-tsc": "^2.0.22",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0",
|
||||||
|
"tunajs": "^1.0.15"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.alger.music",
|
"appId": "com.alger.music",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default {
|
|||||||
collapse: 'Collapse Lyrics',
|
collapse: 'Collapse Lyrics',
|
||||||
like: 'Like',
|
like: 'Like',
|
||||||
lyric: 'Lyric',
|
lyric: 'Lyric',
|
||||||
|
eq: 'Equalizer',
|
||||||
playList: 'Play List',
|
playList: 'Play List',
|
||||||
playMode: {
|
playMode: {
|
||||||
sequence: 'Sequence',
|
sequence: 'Sequence',
|
||||||
@@ -46,5 +47,29 @@ export default {
|
|||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
favorite: 'Favorite {name}',
|
favorite: 'Favorite {name}',
|
||||||
unFavorite: 'Unfavorite {name}'
|
unFavorite: 'Unfavorite {name}'
|
||||||
|
},
|
||||||
|
eq: {
|
||||||
|
title: 'Equalizer',
|
||||||
|
reset: 'Reset',
|
||||||
|
on: 'On',
|
||||||
|
off: 'Off',
|
||||||
|
bass: 'Bass',
|
||||||
|
midrange: 'Midrange',
|
||||||
|
treble: 'Treble',
|
||||||
|
presets: {
|
||||||
|
flat: 'Flat',
|
||||||
|
pop: 'Pop',
|
||||||
|
rock: 'Rock',
|
||||||
|
classical: 'Classical',
|
||||||
|
jazz: 'Jazz',
|
||||||
|
electronic: 'Electronic',
|
||||||
|
hiphop: 'Hip-Hop',
|
||||||
|
rb: 'R&B',
|
||||||
|
metal: 'Metal',
|
||||||
|
vocal: 'Vocal',
|
||||||
|
dance: 'Dance',
|
||||||
|
acoustic: 'Acoustic',
|
||||||
|
custom: 'Custom'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default {
|
|||||||
collapse: '收起歌词',
|
collapse: '收起歌词',
|
||||||
like: '喜欢',
|
like: '喜欢',
|
||||||
lyric: '歌词',
|
lyric: '歌词',
|
||||||
|
eq: '均衡器',
|
||||||
playList: '播放列表',
|
playList: '播放列表',
|
||||||
playMode: {
|
playMode: {
|
||||||
sequence: '顺序播放',
|
sequence: '顺序播放',
|
||||||
@@ -46,5 +47,29 @@ export default {
|
|||||||
volume: '音量',
|
volume: '音量',
|
||||||
favorite: '已收藏{name}',
|
favorite: '已收藏{name}',
|
||||||
unFavorite: '已取消收藏{name}'
|
unFavorite: '已取消收藏{name}'
|
||||||
|
},
|
||||||
|
eq: {
|
||||||
|
title: '均衡器',
|
||||||
|
reset: '重置',
|
||||||
|
on: '开启',
|
||||||
|
off: '关闭',
|
||||||
|
bass: '低音',
|
||||||
|
midrange: '中音',
|
||||||
|
treble: '高音',
|
||||||
|
presets: {
|
||||||
|
flat: '平坦',
|
||||||
|
pop: '流行',
|
||||||
|
rock: '摇滚',
|
||||||
|
classical: '古典',
|
||||||
|
jazz: '爵士',
|
||||||
|
electronic: '电子',
|
||||||
|
hiphop: '嘻哈',
|
||||||
|
rb: 'R&B',
|
||||||
|
metal: '金属',
|
||||||
|
vocal: '人声',
|
||||||
|
dance: '舞曲',
|
||||||
|
acoustic: '原声',
|
||||||
|
custom: '自定义'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
contextIsolation: true
|
contextIsolation: true,
|
||||||
|
webSecurity: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<div class="eq-control">
|
||||||
|
<div class="eq-header">
|
||||||
|
<h3>{{ t('player.eq.title') }}</h3>
|
||||||
|
<div class="eq-controls">
|
||||||
|
<n-switch v-model:value="isEnabled" @update:value="toggleEQ">
|
||||||
|
<template #checked>{{ t('player.eq.on') }}</template>
|
||||||
|
<template #unchecked>{{ t('player.eq.off') }}</template>
|
||||||
|
</n-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="eq-presets">
|
||||||
|
<n-scrollbar x-scrollable>
|
||||||
|
<n-space :size="6" :wrap="false">
|
||||||
|
<n-tag
|
||||||
|
v-for="preset in presetOptions"
|
||||||
|
:key="preset.value"
|
||||||
|
:type="currentPreset === preset.value ? 'success' : 'default'"
|
||||||
|
:bordered="false"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
clickable
|
||||||
|
@click="applyPreset(preset.value)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</n-scrollbar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="eq-sliders">
|
||||||
|
<div v-for="freq in frequencies" :key="freq" class="eq-slider">
|
||||||
|
<div class="freq-label">{{ formatFreq(freq) }}</div>
|
||||||
|
<n-slider
|
||||||
|
v-model:value="eqValues[freq.toString()]"
|
||||||
|
:min="-12"
|
||||||
|
:max="12"
|
||||||
|
:step="0.1"
|
||||||
|
vertical
|
||||||
|
:disabled="!isEnabled"
|
||||||
|
@update:value="updateEQ(freq.toString(), $event)"
|
||||||
|
/>
|
||||||
|
<div class="gain-value">{{ eqValues[freq.toString()] }}dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { audioService } from '@/services/audioService';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||||
|
const eqValues = ref<{ [key: string]: number }>({});
|
||||||
|
const isEnabled = ref(audioService.isEQEnabled());
|
||||||
|
const currentPreset = ref(audioService.getCurrentPreset() || 'flat');
|
||||||
|
|
||||||
|
// 预设配置
|
||||||
|
const presets = {
|
||||||
|
flat: {
|
||||||
|
label: t('player.eq.presets.flat'),
|
||||||
|
values: Object.fromEntries(frequencies.map((f) => [f, 0]))
|
||||||
|
},
|
||||||
|
pop: {
|
||||||
|
label: t('player.eq.presets.pop'),
|
||||||
|
values: {
|
||||||
|
31: -1.5,
|
||||||
|
62: 3.5,
|
||||||
|
125: 5.5,
|
||||||
|
250: 3.5,
|
||||||
|
500: -0.5,
|
||||||
|
1000: -1.5,
|
||||||
|
2000: 1.5,
|
||||||
|
4000: 2.5,
|
||||||
|
8000: 2.5,
|
||||||
|
16000: 2.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rock: {
|
||||||
|
label: t('player.eq.presets.rock'),
|
||||||
|
values: {
|
||||||
|
31: 4.5,
|
||||||
|
62: 3.5,
|
||||||
|
125: 2,
|
||||||
|
250: 0.5,
|
||||||
|
500: -0.5,
|
||||||
|
1000: -1,
|
||||||
|
2000: 0.5,
|
||||||
|
4000: 2,
|
||||||
|
8000: 2.5,
|
||||||
|
16000: 3.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
classical: {
|
||||||
|
label: t('player.eq.presets.classical'),
|
||||||
|
values: {
|
||||||
|
31: 3.5,
|
||||||
|
62: 3,
|
||||||
|
125: 2.5,
|
||||||
|
250: 1.5,
|
||||||
|
500: -0.5,
|
||||||
|
1000: -1.5,
|
||||||
|
2000: -1.5,
|
||||||
|
4000: 0.5,
|
||||||
|
8000: 2,
|
||||||
|
16000: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
jazz: {
|
||||||
|
label: t('player.eq.presets.jazz'),
|
||||||
|
values: {
|
||||||
|
31: 3,
|
||||||
|
62: 2,
|
||||||
|
125: 1.5,
|
||||||
|
250: 2,
|
||||||
|
500: -1,
|
||||||
|
1000: -1.5,
|
||||||
|
2000: -0.5,
|
||||||
|
4000: 1,
|
||||||
|
8000: 2.5,
|
||||||
|
16000: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hiphop: {
|
||||||
|
label: t('player.eq.presets.hiphop'),
|
||||||
|
values: {
|
||||||
|
31: 5,
|
||||||
|
62: 4.5,
|
||||||
|
125: 3,
|
||||||
|
250: 1.5,
|
||||||
|
500: -0.5,
|
||||||
|
1000: -1,
|
||||||
|
2000: 0.5,
|
||||||
|
4000: 1.5,
|
||||||
|
8000: 2,
|
||||||
|
16000: 2.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
vocal: {
|
||||||
|
label: t('player.eq.presets.vocal'),
|
||||||
|
values: {
|
||||||
|
31: -2,
|
||||||
|
62: -1.5,
|
||||||
|
125: -1,
|
||||||
|
250: 0.5,
|
||||||
|
500: 2,
|
||||||
|
1000: 3.5,
|
||||||
|
2000: 3,
|
||||||
|
4000: 1.5,
|
||||||
|
8000: 0.5,
|
||||||
|
16000: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dance: {
|
||||||
|
label: t('player.eq.presets.dance'),
|
||||||
|
values: {
|
||||||
|
31: 4,
|
||||||
|
62: 3.5,
|
||||||
|
125: 2.5,
|
||||||
|
250: 1,
|
||||||
|
500: 0,
|
||||||
|
1000: -0.5,
|
||||||
|
2000: 1.5,
|
||||||
|
4000: 2.5,
|
||||||
|
8000: 3,
|
||||||
|
16000: 2.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
acoustic: {
|
||||||
|
label: t('player.eq.presets.acoustic'),
|
||||||
|
values: {
|
||||||
|
31: 2,
|
||||||
|
62: 1.5,
|
||||||
|
125: 1,
|
||||||
|
250: 1.5,
|
||||||
|
500: 2,
|
||||||
|
1000: 1.5,
|
||||||
|
2000: 2,
|
||||||
|
4000: 2.5,
|
||||||
|
8000: 2,
|
||||||
|
16000: 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetOptions = Object.entries(presets).map(([value, preset]) => ({
|
||||||
|
label: preset.label,
|
||||||
|
value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toggleEQ = (enabled: boolean) => {
|
||||||
|
audioService.setEQEnabled(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (presetName: string) => {
|
||||||
|
currentPreset.value = presetName;
|
||||||
|
audioService.setCurrentPreset(presetName);
|
||||||
|
const preset = presets[presetName as keyof typeof presets];
|
||||||
|
if (preset) {
|
||||||
|
Object.entries(preset.values).forEach(([freq, gain]) => {
|
||||||
|
updateEQ(freq, gain);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 恢复 EQ 设置
|
||||||
|
const settings = audioService.getAllEQSettings();
|
||||||
|
eqValues.value = settings;
|
||||||
|
|
||||||
|
// 如果有保存的预设,应用该预设
|
||||||
|
const savedPreset = audioService.getCurrentPreset();
|
||||||
|
if (savedPreset && presets[savedPreset as keyof typeof presets]) {
|
||||||
|
currentPreset.value = savedPreset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEQ = (frequency: string, gain: number) => {
|
||||||
|
audioService.setEQFrequencyGain(frequency, gain);
|
||||||
|
eqValues.value = {
|
||||||
|
...eqValues.value,
|
||||||
|
[frequency]: gain
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前值是否与任何预设匹配
|
||||||
|
const currentValues = eqValues.value;
|
||||||
|
let matchedPreset: string | null = null;
|
||||||
|
|
||||||
|
// 检查是否与任何预设完全匹配
|
||||||
|
Object.entries(presets).forEach(([presetName, preset]) => {
|
||||||
|
const isMatch = Object.entries(preset.values).every(
|
||||||
|
([freq, value]) => Math.abs(currentValues[freq] - value) < 0.1
|
||||||
|
);
|
||||||
|
if (isMatch) {
|
||||||
|
matchedPreset = presetName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新当前预设状态
|
||||||
|
if (matchedPreset !== null) {
|
||||||
|
currentPreset.value = matchedPreset;
|
||||||
|
audioService.setCurrentPreset(matchedPreset);
|
||||||
|
} else if (currentPreset.value !== 'custom') {
|
||||||
|
// 如果与任何预设都不匹配,将状态设置为自定义
|
||||||
|
currentPreset.value = 'custom';
|
||||||
|
audioService.setCurrentPreset('custom');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFreq = (freq: number) => {
|
||||||
|
if (freq >= 1000) {
|
||||||
|
return `${freq / 1000}kHz`;
|
||||||
|
}
|
||||||
|
return `${freq}Hz`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.eq-control {
|
||||||
|
@apply p-6 rounded-lg;
|
||||||
|
@apply bg-light dark:bg-dark;
|
||||||
|
@apply shadow-lg dark:shadow-none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
|
||||||
|
.eq-header {
|
||||||
|
@apply flex justify-between items-center mb-4;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply text-xl font-semibold;
|
||||||
|
@apply text-gray-800 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-presets {
|
||||||
|
@apply mb-2 relative;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
:deep(.n-scrollbar) {
|
||||||
|
@apply -mx-2 px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tag) {
|
||||||
|
@apply cursor-pointer transition-all duration-200;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-space) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-sliders {
|
||||||
|
@apply flex justify-between items-end;
|
||||||
|
@apply bg-gray-50 dark:bg-gray-800 gap-1;
|
||||||
|
@apply rounded-lg p-2;
|
||||||
|
height: 300px;
|
||||||
|
|
||||||
|
.eq-slider {
|
||||||
|
@apply flex flex-col items-center;
|
||||||
|
width: 45px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.n-slider {
|
||||||
|
flex: 1;
|
||||||
|
margin: 12px 0;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-label {
|
||||||
|
@apply text-xs font-medium text-center;
|
||||||
|
@apply text-gray-600 dark:text-gray-400;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 8px 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gain-value {
|
||||||
|
@apply text-xs font-medium text-center;
|
||||||
|
@apply text-gray-600 dark:text-gray-400;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 4px 0;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-slider) {
|
||||||
|
--n-rail-height: 4px;
|
||||||
|
--n-rail-color: theme('colors.gray.200');
|
||||||
|
--n-rail-color-hover: theme('colors.gray.300');
|
||||||
|
--n-fill-color: theme('colors.green.500');
|
||||||
|
--n-fill-color-hover: theme('colors.green.600');
|
||||||
|
--n-handle-color: theme('colors.green.500');
|
||||||
|
--n-handle-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.n-slider-handle {
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -119,6 +119,25 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ t('player.playBar.lyric') }}
|
{{ t('player.playBar.lyric') }}
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
|
<n-popover
|
||||||
|
trigger="click"
|
||||||
|
:z-index="99999999"
|
||||||
|
content-class="music-eq"
|
||||||
|
raw
|
||||||
|
:show-arrow="false"
|
||||||
|
:delay="200"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<n-tooltip trigger="hover" :z-index="9999999">
|
||||||
|
<template #trigger>
|
||||||
|
<i class="iconfont ri-equalizer-line" :class="{ 'text-green-500': isEQVisible }"></i>
|
||||||
|
</template>
|
||||||
|
{{ t('player.playBar.eq') }}
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
|
<eq-control />
|
||||||
|
</n-popover>
|
||||||
<n-popover
|
<n-popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
:z-index="99999999"
|
:z-index="99999999"
|
||||||
@@ -161,6 +180,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
|
import EqControl from '@/components/EQControl.vue';
|
||||||
import {
|
import {
|
||||||
allTime,
|
allTime,
|
||||||
artistList,
|
artistList,
|
||||||
@@ -426,6 +446,8 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isEQVisible = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -667,4 +689,10 @@ watch(
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-eq {
|
||||||
|
@apply p-4 rounded-3xl;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
@apply bg-light dark:bg-black bg-opacity-75;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,15 +2,57 @@ import { Howl } from 'howler';
|
|||||||
|
|
||||||
import type { SongResult } from '@/type/music';
|
import type { SongResult } from '@/type/music';
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
webkitAudioContext: typeof AudioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HowlSound {
|
||||||
|
node: HTMLMediaElement & {
|
||||||
|
audioSource?: MediaElementAudioSourceNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class AudioService {
|
class AudioService {
|
||||||
private currentSound: Howl | null = null;
|
private currentSound: Howl | null = null;
|
||||||
|
|
||||||
private currentTrack: SongResult | null = null;
|
private currentTrack: SongResult | null = null;
|
||||||
|
|
||||||
|
private context: AudioContext | null = null;
|
||||||
|
|
||||||
|
private filters: BiquadFilterNode[] = [];
|
||||||
|
|
||||||
|
private source: MediaElementAudioSourceNode | null = null;
|
||||||
|
|
||||||
|
private gainNode: GainNode | null = null;
|
||||||
|
|
||||||
|
private bypass = false;
|
||||||
|
|
||||||
|
// 预设的 EQ 频段
|
||||||
|
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||||
|
|
||||||
|
// 默认的 EQ 设置
|
||||||
|
private defaultEQSettings: { [key: string]: number } = {
|
||||||
|
'31': 0,
|
||||||
|
'62': 0,
|
||||||
|
'125': 0,
|
||||||
|
'250': 0,
|
||||||
|
'500': 0,
|
||||||
|
'1000': 0,
|
||||||
|
'2000': 0,
|
||||||
|
'4000': 0,
|
||||||
|
'8000': 0,
|
||||||
|
'16000': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
private retryCount = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
this.initMediaSession();
|
this.initMediaSession();
|
||||||
}
|
}
|
||||||
|
// 从本地存储加载 EQ 开关状态
|
||||||
|
const bypassState = localStorage.getItem('eqBypass');
|
||||||
|
this.bypass = bypassState ? JSON.parse(bypassState) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMediaSession() {
|
private initMediaSession() {
|
||||||
@@ -120,6 +162,198 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EQ 相关方法
|
||||||
|
public isEQEnabled(): boolean {
|
||||||
|
return !this.bypass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEQEnabled(enabled: boolean) {
|
||||||
|
this.bypass = !enabled;
|
||||||
|
localStorage.setItem('eqBypass', JSON.stringify(this.bypass));
|
||||||
|
|
||||||
|
if (this.source && this.gainNode && this.context) {
|
||||||
|
this.applyBypassState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEQFrequencyGain(frequency: string, gain: number) {
|
||||||
|
const filterIndex = this.frequencies.findIndex((f) => f.toString() === frequency);
|
||||||
|
if (filterIndex !== -1 && this.filters[filterIndex]) {
|
||||||
|
this.filters[filterIndex].gain.setValueAtTime(gain, this.context?.currentTime || 0);
|
||||||
|
this.saveEQSettings(frequency, gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetEQ() {
|
||||||
|
this.filters.forEach((filter) => {
|
||||||
|
filter.gain.setValueAtTime(0, this.context?.currentTime || 0);
|
||||||
|
});
|
||||||
|
localStorage.removeItem('eqSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllEQSettings(): { [key: string]: number } {
|
||||||
|
return this.loadEQSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveEQSettings(frequency: string, gain: number) {
|
||||||
|
const settings = this.loadEQSettings();
|
||||||
|
settings[frequency] = gain;
|
||||||
|
localStorage.setItem('eqSettings', JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadEQSettings(): { [key: string]: number } {
|
||||||
|
const savedSettings = localStorage.getItem('eqSettings');
|
||||||
|
return savedSettings ? JSON.parse(savedSettings) : { ...this.defaultEQSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disposeEQ(keepContext = false) {
|
||||||
|
try {
|
||||||
|
// 清理音频节点连接
|
||||||
|
if (this.source) {
|
||||||
|
this.source.disconnect();
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理滤波器
|
||||||
|
this.filters.forEach((filter) => {
|
||||||
|
try {
|
||||||
|
filter.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('清理滤波器时出错:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.filters = [];
|
||||||
|
|
||||||
|
// 清理增益节点
|
||||||
|
if (this.gainNode) {
|
||||||
|
this.gainNode.disconnect();
|
||||||
|
this.gainNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不需要保持上下文,则关闭它
|
||||||
|
if (!keepContext && this.context) {
|
||||||
|
try {
|
||||||
|
await this.context.close();
|
||||||
|
this.context = null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('关闭音频上下文时出错:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理EQ资源时出错:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupEQ(sound: Howl) {
|
||||||
|
try {
|
||||||
|
const howl = sound as any;
|
||||||
|
const audioNode = howl._sounds?.[0]?._node;
|
||||||
|
|
||||||
|
if (!audioNode || !(audioNode instanceof HTMLMediaElement)) {
|
||||||
|
if (this.retryCount < 3) {
|
||||||
|
console.warn('等待音频节点初始化,重试次数:', this.retryCount + 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
this.retryCount++;
|
||||||
|
return await this.setupEQ(sound);
|
||||||
|
}
|
||||||
|
throw new Error('无法获取音频节点,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.retryCount = 0;
|
||||||
|
|
||||||
|
// 确保使用 Howler 的音频上下文
|
||||||
|
this.context = Howler.ctx as AudioContext;
|
||||||
|
|
||||||
|
if (!this.context || this.context.state === 'closed') {
|
||||||
|
Howler.ctx = new AudioContext();
|
||||||
|
this.context = Howler.ctx;
|
||||||
|
Howler.masterGain = this.context.createGain();
|
||||||
|
Howler.masterGain.connect(this.context.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.context.state === 'suspended') {
|
||||||
|
await this.context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理现有连接
|
||||||
|
await this.disposeEQ(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查节点是否已经有源
|
||||||
|
const existingSource = (audioNode as any).source as MediaElementAudioSourceNode;
|
||||||
|
if (existingSource?.context === this.context) {
|
||||||
|
console.log('复用现有音频源节点');
|
||||||
|
this.source = existingSource;
|
||||||
|
} else {
|
||||||
|
// 创建新的源节点
|
||||||
|
console.log('创建新的音频源节点');
|
||||||
|
this.source = this.context.createMediaElementSource(audioNode);
|
||||||
|
(audioNode as any).source = this.source;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建音频源节点失败:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建增益节点
|
||||||
|
this.gainNode = this.context.createGain();
|
||||||
|
|
||||||
|
// 创建滤波器
|
||||||
|
this.filters = this.frequencies.map((freq) => {
|
||||||
|
const filter = this.context!.createBiquadFilter();
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = 1;
|
||||||
|
filter.gain.value = this.loadEQSettings()[freq.toString()] || 0;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用EQ状态
|
||||||
|
this.applyBypassState();
|
||||||
|
|
||||||
|
// 设置音量
|
||||||
|
const volume = localStorage.getItem('volume');
|
||||||
|
if (this.gainNode) {
|
||||||
|
this.gainNode.gain.value = volume ? parseFloat(volume) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('EQ初始化成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EQ初始化失败:', error);
|
||||||
|
await this.disposeEQ();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBypassState() {
|
||||||
|
if (!this.source || !this.gainNode || !this.context) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 断开所有现有连接
|
||||||
|
this.source.disconnect();
|
||||||
|
this.filters.forEach((filter) => filter.disconnect());
|
||||||
|
this.gainNode.disconnect();
|
||||||
|
|
||||||
|
if (this.bypass) {
|
||||||
|
// EQ被禁用时,直接连接到输出
|
||||||
|
this.source.connect(this.gainNode);
|
||||||
|
this.gainNode.connect(this.context.destination);
|
||||||
|
} else {
|
||||||
|
// EQ启用时,通过滤波器链连接
|
||||||
|
this.source.connect(this.filters[0]);
|
||||||
|
this.filters.forEach((filter, index) => {
|
||||||
|
if (index < this.filters.length - 1) {
|
||||||
|
filter.connect(this.filters[index + 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.filters[this.filters.length - 1].connect(this.gainNode);
|
||||||
|
this.gainNode.connect(this.context.destination);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('应用EQ状态时出错:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 播放控制相关
|
// 播放控制相关
|
||||||
play(url?: string, track?: SongResult): Promise<Howl> {
|
play(url?: string, track?: SongResult): Promise<Howl> {
|
||||||
// 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放
|
// 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放
|
||||||
@@ -137,14 +371,31 @@ class AudioService {
|
|||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const maxRetries = 1;
|
const maxRetries = 1;
|
||||||
|
|
||||||
const tryPlay = () => {
|
const tryPlay = async () => {
|
||||||
// 清理现有的音频实例
|
|
||||||
if (this.currentSound) {
|
|
||||||
this.currentSound.unload();
|
|
||||||
this.currentSound = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 确保使用同一个音频上下文
|
||||||
|
if (!Howler.ctx || Howler.ctx.state === 'closed') {
|
||||||
|
Howler.ctx = new AudioContext();
|
||||||
|
this.context = Howler.ctx;
|
||||||
|
Howler.masterGain = this.context.createGain();
|
||||||
|
Howler.masterGain.connect(this.context.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复上下文状态
|
||||||
|
if (Howler.ctx.state === 'suspended') {
|
||||||
|
await Howler.ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先停止并清理现有的音频实例
|
||||||
|
if (this.currentSound) {
|
||||||
|
this.currentSound.stop();
|
||||||
|
this.currentSound.unload();
|
||||||
|
this.currentSound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 EQ 但保持上下文
|
||||||
|
await this.disposeEQ(true);
|
||||||
|
|
||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
this.currentSound = new Howl({
|
this.currentSound = new Howl({
|
||||||
src: [url],
|
src: [url],
|
||||||
@@ -174,13 +425,20 @@ class AudioService {
|
|||||||
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
|
reject(new Error('音频播放失败,请尝试切换其他歌曲'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onload: () => {
|
onload: async () => {
|
||||||
// 音频加载成功后更新媒体会话
|
// 音频加载成功后设置 EQ 和更新媒体会话
|
||||||
if (track && this.currentSound) {
|
if (this.currentSound) {
|
||||||
this.updateMediaSessionMetadata(track);
|
try {
|
||||||
this.updateMediaSessionPositionState();
|
await this.setupEQ(this.currentSound);
|
||||||
this.emit('load');
|
this.updateMediaSessionMetadata(track);
|
||||||
resolve(this.currentSound);
|
this.updateMediaSessionPositionState();
|
||||||
|
this.emit('load');
|
||||||
|
resolve(this.currentSound);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置 EQ 失败:', error);
|
||||||
|
// 即使 EQ 设置失败,也继续播放
|
||||||
|
resolve(this.currentSound);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -238,6 +496,7 @@ class AudioService {
|
|||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.playbackState = 'none';
|
navigator.mediaSession.playbackState = 'none';
|
||||||
}
|
}
|
||||||
|
this.disposeEQ();
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(volume: number) {
|
setVolume(volume: number) {
|
||||||
@@ -267,6 +526,14 @@ class AudioService {
|
|||||||
clearAllListeners() {
|
clearAllListeners() {
|
||||||
this.callbacks = {};
|
this.callbacks = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCurrentPreset(): string | null {
|
||||||
|
return localStorage.getItem('currentPreset');
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCurrentPreset(preset: string): void {
|
||||||
|
localStorage.setItem('currentPreset', preset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const audioService = new AudioService();
|
export const audioService = new AudioService();
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Howl, Howler } from 'howler';
|
||||||
|
import Tuna from 'tunajs';
|
||||||
|
|
||||||
|
// 类型定义扩展
|
||||||
|
interface HowlSound {
|
||||||
|
_sounds: Array<{
|
||||||
|
_node: HTMLMediaElement & {
|
||||||
|
destination?: MediaElementAudioSourceNode;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EQSettings {
|
||||||
|
[key: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EQService {
|
||||||
|
private context: AudioContext | null = null;
|
||||||
|
|
||||||
|
private tuna: any = null;
|
||||||
|
|
||||||
|
private equalizer: any = null;
|
||||||
|
|
||||||
|
private source: MediaElementAudioSourceNode | null = null;
|
||||||
|
|
||||||
|
private gainNode: GainNode | null = null;
|
||||||
|
|
||||||
|
private howlInstance: Howl | null = null;
|
||||||
|
|
||||||
|
private bypass = false;
|
||||||
|
|
||||||
|
// 预设频率
|
||||||
|
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||||
|
|
||||||
|
// 默认EQ设置
|
||||||
|
private defaultEQSettings: EQSettings = Object.fromEntries(
|
||||||
|
this.frequencies.map((f) => [f.toString(), 0])
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadSavedSettings();
|
||||||
|
this.bypass = localStorage.getItem('eqBypass') === 'true';
|
||||||
|
this.initializeUserGestureHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化用户手势处理
|
||||||
|
private initializeUserGestureHandler() {
|
||||||
|
const handler = async () => {
|
||||||
|
if (this.context?.state === 'suspended') {
|
||||||
|
await this.context.resume();
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化音频上下文
|
||||||
|
public async setupAudioContext(howl: Howl) {
|
||||||
|
try {
|
||||||
|
// 使用Howler的现有上下文
|
||||||
|
this.context = (Howler.ctx as AudioContext) || new AudioContext();
|
||||||
|
|
||||||
|
// 初始化Howler的音频系统(如果需要)
|
||||||
|
if (!Howler.ctx) {
|
||||||
|
Howler.ctx = this.context;
|
||||||
|
Howler.masterGain = this.context.createGain();
|
||||||
|
Howler.masterGain.connect(this.context.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保上下文处于运行状态
|
||||||
|
if (this.context.state === 'suspended') {
|
||||||
|
await this.context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sound = (howl as unknown as HowlSound)._sounds[0];
|
||||||
|
if (!sound?._node) throw new Error('无法获取音频节点');
|
||||||
|
|
||||||
|
// 清理现有资源
|
||||||
|
await this.dispose();
|
||||||
|
|
||||||
|
// 创建新的处理链
|
||||||
|
this.tuna = new Tuna(this.context);
|
||||||
|
this.howlInstance = howl;
|
||||||
|
|
||||||
|
// 创建/复用源节点
|
||||||
|
if (!sound._node.destination) {
|
||||||
|
this.source = this.context.createMediaElementSource(sound._node);
|
||||||
|
sound._node.destination = this.source;
|
||||||
|
} else {
|
||||||
|
this.source = sound._node.destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建效果节点
|
||||||
|
this.gainNode = this.context.createGain();
|
||||||
|
this.equalizer = new this.tuna.Equalizer({
|
||||||
|
frequencies: this.frequencies,
|
||||||
|
gains: this.frequencies.map((f) => this.getSavedGain(f.toString())),
|
||||||
|
bypass: this.bypass
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接节点链
|
||||||
|
this.source!.connect(this.equalizer.input).connect(this.gainNode).connect(Howler.masterGain);
|
||||||
|
|
||||||
|
// 恢复音量设置
|
||||||
|
const volume = localStorage.getItem('volume');
|
||||||
|
this.gainNode.gain.value = volume ? parseFloat(volume) : 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('音频上下文初始化失败:', error);
|
||||||
|
await this.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQ功能开关
|
||||||
|
public setEnabled(enabled: boolean) {
|
||||||
|
this.bypass = !enabled;
|
||||||
|
localStorage.setItem('eqBypass', JSON.stringify(this.bypass));
|
||||||
|
if (this.equalizer) this.equalizer.bypass = this.bypass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEnabled(): boolean {
|
||||||
|
return !this.bypass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整频率增益
|
||||||
|
public setFrequencyGain(frequency: string, gain: number) {
|
||||||
|
const index = this.frequencies.findIndex((f) => f.toString() === frequency);
|
||||||
|
if (index !== -1 && this.equalizer) {
|
||||||
|
this.equalizer.setGain(index, gain);
|
||||||
|
this.saveSettings(frequency, gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置EQ设置
|
||||||
|
public resetEQ() {
|
||||||
|
this.frequencies.forEach((f) => {
|
||||||
|
this.setFrequencyGain(f.toString(), 0);
|
||||||
|
});
|
||||||
|
localStorage.removeItem('eqSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前设置
|
||||||
|
public getAllSettings(): EQSettings {
|
||||||
|
return this.loadSavedSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存/加载设置
|
||||||
|
private saveSettings(frequency: string, gain: number) {
|
||||||
|
const settings = this.loadSavedSettings();
|
||||||
|
settings[frequency] = gain;
|
||||||
|
localStorage.setItem('eqSettings', JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSavedSettings(): EQSettings {
|
||||||
|
const saved = localStorage.getItem('eqSettings');
|
||||||
|
return saved ? JSON.parse(saved) : { ...this.defaultEQSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSavedGain(frequency: string): number {
|
||||||
|
return this.loadSavedSettings()[frequency] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
public async dispose() {
|
||||||
|
try {
|
||||||
|
[this.source, this.equalizer, this.gainNode].forEach((node) => {
|
||||||
|
if (node) {
|
||||||
|
node.disconnect();
|
||||||
|
// 特殊清理Tuna节点
|
||||||
|
if (node instanceof Tuna.Equalizer) node.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.context && this.context !== Howler.ctx) {
|
||||||
|
await this.context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context = null;
|
||||||
|
this.tuna = null;
|
||||||
|
this.source = null;
|
||||||
|
this.equalizer = null;
|
||||||
|
this.gainNode = null;
|
||||||
|
this.howlInstance = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('资源清理失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eqService = new EQService();
|
||||||
Reference in New Issue
Block a user