feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component

This commit is contained in:
2025-08-10 02:57:55 +00:00
committed by GitHub
parent 192148adf2
commit cfd4f88b5e
14 changed files with 974 additions and 125 deletions

169
lib/audio-effects.ts Normal file
View File

@@ -0,0 +1,169 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export interface AudioEffectPreset {
name: string;
gains: number[]; // Gains for different frequency bands
frequencies: number[]; // Center frequencies for each band
}
export const presets: { [key: string]: AudioEffectPreset } = {
normal: {
name: "Normal",
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
bassBoost: {
name: "Bass Boost",
gains: [7, 5, 3, 2, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
trebleBoost: {
name: "Treble Boost",
gains: [0, 0, 0, 0, 0, 0, 2, 3, 5, 7],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
vocalBoost: {
name: "Vocal Boost",
gains: [0, 0, 0, 2, 4, 4, 2, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
}
};
export class AudioEffects {
private context: AudioContext;
private source: MediaElementAudioSourceNode | null = null;
private destination: AudioDestinationNode;
private filters: BiquadFilterNode[] = [];
private gainNode: GainNode;
private crossfadeGainNode: GainNode;
private analyser: AnalyserNode;
private replayGainNode: GainNode;
private currentPreset: string = 'normal';
constructor(audioElement: HTMLAudioElement) {
// Properly type the AudioContext initialization
this.context = new (window.AudioContext || window.webkitAudioContext || AudioContext)();
this.destination = this.context.destination;
this.gainNode = this.context.createGain();
this.crossfadeGainNode = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.replayGainNode = this.context.createGain();
// Initialize ReplayGain node
this.replayGainNode.gain.value = 1.0;
// Create the audio processing chain
this.setupAudioChain(audioElement);
// Initialize EQ filters
this.setupEqualizer();
}
private setupAudioChain(audioElement: HTMLAudioElement) {
// Disconnect any existing source
if (this.source) {
this.source.disconnect();
}
// Create new source from audio element
this.source = this.context.createMediaElementSource(audioElement);
// Connect the audio processing chain
this.source
.connect(this.replayGainNode)
.connect(this.gainNode)
.connect(this.crossfadeGainNode);
// Connect filters in series
let lastNode: AudioNode = this.crossfadeGainNode;
this.filters.forEach(filter => {
lastNode.connect(filter);
lastNode = filter;
});
// Connect to analyser and destination
lastNode.connect(this.analyser);
this.analyser.connect(this.destination);
}
private setupEqualizer() {
// Create 10-band EQ
presets.normal.frequencies.forEach((freq, index) => {
const filter = this.context.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1.0;
filter.gain.value = 0;
this.filters.push(filter);
});
}
public setPreset(presetName: string) {
if (presets[presetName]) {
this.currentPreset = presetName;
presets[presetName].gains.forEach((gain, index) => {
if (this.filters[index]) {
this.filters[index].gain.setValueAtTime(gain, this.context.currentTime);
}
});
}
}
public getCurrentPreset(): string {
return this.currentPreset;
}
public setVolume(volume: number) {
if (this.gainNode) {
this.gainNode.gain.setValueAtTime(volume, this.context.currentTime);
}
}
public setCrossfadeTime(seconds: number) {
if (this.crossfadeGainNode) {
const now = this.context.currentTime;
this.crossfadeGainNode.gain.setValueAtTime(1, now);
this.crossfadeGainNode.gain.linearRampToValueAtTime(0, now + seconds);
}
}
public startCrossfade() {
if (this.crossfadeGainNode) {
this.crossfadeGainNode.gain.value = 1;
}
}
public setReplayGain(gain: number) {
if (this.replayGainNode) {
// Clamp gain between -12dB and +12dB for safety
const clampedGain = Math.max(-12, Math.min(12, gain));
const gainValue = Math.pow(10, clampedGain / 20); // Convert dB to linear gain
this.replayGainNode.gain.setValueAtTime(gainValue, this.context.currentTime);
}
}
public getAnalyserNode(): AnalyserNode {
return this.analyser;
}
public async resume() {
if (this.context.state === 'suspended') {
await this.context.resume();
}
}
public disconnect() {
if (this.source) {
this.source.disconnect();
}
this.filters.forEach(filter => filter.disconnect());
this.gainNode.disconnect();
this.crossfadeGainNode.disconnect();
this.analyser.disconnect();
this.replayGainNode.disconnect();
}
}

View File

@@ -68,6 +68,7 @@ export interface Song {
artistId: string;
type: string;
starred?: string;
replayGain?: number;
}
export interface Playlist {

View File

@@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function constrain(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatBytes(bytes: number, decimals: number = 2): string {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}