diff --git a/package.json b/package.json index 685e281..cf5d886 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "vue": "^3.5.13", "vue-router": "^4.5.0", "vue-tsc": "^2.0.22", - "vuex": "^4.1.0" + "vuex": "^4.1.0", + "tunajs": "^1.0.15" }, "build": { "appId": "com.alger.music", diff --git a/src/i18n/lang/en-US/player.ts b/src/i18n/lang/en-US/player.ts index 9eaeb73..1144eda 100644 --- a/src/i18n/lang/en-US/player.ts +++ b/src/i18n/lang/en-US/player.ts @@ -33,6 +33,7 @@ export default { collapse: 'Collapse Lyrics', like: 'Like', lyric: 'Lyric', + eq: 'Equalizer', playList: 'Play List', playMode: { sequence: 'Sequence', @@ -46,5 +47,29 @@ export default { volume: 'Volume', favorite: 'Favorite {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' + } } }; diff --git a/src/i18n/lang/zh-CN/player.ts b/src/i18n/lang/zh-CN/player.ts index 231eaaa..3e34636 100644 --- a/src/i18n/lang/zh-CN/player.ts +++ b/src/i18n/lang/zh-CN/player.ts @@ -33,6 +33,7 @@ export default { collapse: '收起歌词', like: '喜欢', lyric: '歌词', + eq: '均衡器', playList: '播放列表', playMode: { sequence: '顺序播放', @@ -46,5 +47,29 @@ export default { volume: '音量', favorite: '已收藏{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: '自定义' + } } }; diff --git a/src/main/modules/window.ts b/src/main/modules/window.ts index f9a1aa9..ce3f5b8 100644 --- a/src/main/modules/window.ts +++ b/src/main/modules/window.ts @@ -91,7 +91,8 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow { webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, - contextIsolation: true + contextIsolation: true, + webSecurity: false } }); diff --git a/src/renderer/components/EQControl.vue b/src/renderer/components/EQControl.vue new file mode 100644 index 0000000..28da198 --- /dev/null +++ b/src/renderer/components/EQControl.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/renderer/layout/components/PlayBar.vue b/src/renderer/layout/components/PlayBar.vue index e02c5ff..e5ceeac 100644 --- a/src/renderer/layout/components/PlayBar.vue +++ b/src/renderer/layout/components/PlayBar.vue @@ -119,6 +119,25 @@ {{ t('player.playBar.lyric') }} + + + + diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index a0aa90b..9531def 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -2,15 +2,57 @@ import { Howl } from 'howler'; import type { SongResult } from '@/type/music'; +interface Window { + webkitAudioContext: typeof AudioContext; +} + +interface HowlSound { + node: HTMLMediaElement & { + audioSource?: MediaElementAudioSourceNode; + }; +} + class AudioService { private currentSound: Howl | 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() { if ('mediaSession' in navigator) { this.initMediaSession(); } + // 从本地存储加载 EQ 开关状态 + const bypassState = localStorage.getItem('eqBypass'); + this.bypass = bypassState ? JSON.parse(bypassState) : false; } 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 { // 如果没有提供新的 URL 和 track,且当前有音频实例,则继续播放 @@ -137,14 +371,31 @@ class AudioService { let retryCount = 0; const maxRetries = 1; - const tryPlay = () => { - // 清理现有的音频实例 - if (this.currentSound) { - this.currentSound.unload(); - this.currentSound = null; - } - + const tryPlay = async () => { 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.currentSound = new Howl({ src: [url], @@ -174,13 +425,20 @@ class AudioService { reject(new Error('音频播放失败,请尝试切换其他歌曲')); } }, - onload: () => { - // 音频加载成功后更新媒体会话 - if (track && this.currentSound) { - this.updateMediaSessionMetadata(track); - this.updateMediaSessionPositionState(); - this.emit('load'); - resolve(this.currentSound); + onload: async () => { + // 音频加载成功后设置 EQ 和更新媒体会话 + if (this.currentSound) { + try { + await this.setupEQ(this.currentSound); + this.updateMediaSessionMetadata(track); + 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) { navigator.mediaSession.playbackState = 'none'; } + this.disposeEQ(); } setVolume(volume: number) { @@ -267,6 +526,14 @@ class AudioService { clearAllListeners() { this.callbacks = {}; } + + public getCurrentPreset(): string | null { + return localStorage.getItem('currentPreset'); + } + + public setCurrentPreset(preset: string): void { + localStorage.setItem('currentPreset', preset); + } } export const audioService = new AudioService(); diff --git a/src/renderer/services/eqService.ts b/src/renderer/services/eqService.ts new file mode 100644 index 0000000..30914b3 --- /dev/null +++ b/src/renderer/services/eqService.ts @@ -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();