mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +00:00
feat: equalizer overhaul (#1573)
This commit is contained in:
parent
b40ffa358a
commit
e25430b3d8
18 changed files with 279 additions and 308 deletions
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<dialog ref="dialog" class="text-primary bg-primary">
|
||||
<dialog ref="dialog" class="text-primary bg-primary" @cancel.prevent>
|
||||
<Component :is="modalNameToComponentMap[activeModalName]" v-if="activeModalName" @close="close"/>
|
||||
</dialog>
|
||||
</template>
|
||||
|
@ -19,7 +19,8 @@ const modalNameToComponentMap: Record<string, ComponentPublicInstance> = {
|
|||
'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')),
|
||||
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
|
||||
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
|
||||
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue'))
|
||||
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
|
||||
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))
|
||||
}
|
||||
|
||||
type ModalName = keyof typeof modalNameToComponentMap
|
||||
|
@ -71,7 +72,9 @@ eventBus.on({
|
|||
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (folder: PlaylistFolder) => {
|
||||
playlistFolderToEdit.value = folder
|
||||
activeModalName.value = 'edit-playlist-folder-form'
|
||||
}
|
||||
},
|
||||
|
||||
MODAL_SHOW_EQUALIZER: () => (activeModalName.value = 'equalizer')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
<template>
|
||||
<div class="extra-controls" data-testid="other-controls">
|
||||
<div v-koel-clickaway="closeEqualizer" class="wrapper">
|
||||
<Equalizer v-if="useEqualizer" v-show="showEqualizer"/>
|
||||
|
||||
<div class="wrapper">
|
||||
<button
|
||||
v-if="song?.playback_state === 'Playing'"
|
||||
v-koel-tooltip.top
|
||||
|
@ -19,11 +17,10 @@
|
|||
v-if="useEqualizer"
|
||||
v-koel-tooltip.top
|
||||
:class="{ active: showEqualizer }"
|
||||
:title="`${ showEqualizer ? 'Hide' : 'Show'} equalizer`"
|
||||
class="equalizer"
|
||||
data-testid="toggle-equalizer-btn"
|
||||
title="Show equalizer"
|
||||
type="button"
|
||||
@click.prevent="toggleEqualizer"
|
||||
@click.prevent="showEqualizer"
|
||||
>
|
||||
<icon :icon="faSliders"/>
|
||||
</button>
|
||||
|
@ -39,15 +36,11 @@ import { ref } from 'vue'
|
|||
import { eventBus, isAudioContextSupported as useEqualizer, requireInjection } from '@/utils'
|
||||
import { CurrentSongKey } from '@/symbols'
|
||||
|
||||
import Equalizer from '@/components/ui/Equalizer.vue'
|
||||
import Volume from '@/components/ui/Volume.vue'
|
||||
|
||||
const song = requireInjection(CurrentSongKey, ref(null))
|
||||
|
||||
const showEqualizer = ref(false)
|
||||
|
||||
const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
|
||||
const closeEqualizer = () => (showEqualizer.value = false)
|
||||
const showEqualizer = () => eventBus.emit('MODAL_SHOW_EQUALIZER')
|
||||
const toggleVisualizer = () => eventBus.emit('TOGGLE_VISUALIZER')
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
exports[`renders 1`] = `
|
||||
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
||||
<div class="wrapper" data-v-8bf5fe81="">
|
||||
<!--v-if--><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
||||
<div class="wrapper" data-v-8bf5fe81=""><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
||||
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,6 @@ import { waitFor } from '@testing-library/vue'
|
|||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { playbackService, volumeManager } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import Component from './index.vue'
|
||||
|
||||
|
@ -11,7 +10,6 @@ new class extends UnitTestCase {
|
|||
it('initializes playback services', async () => {
|
||||
const initPlaybackMock = this.mock(playbackService, 'init')
|
||||
const initVolumeMock = this.mock(volumeManager, 'init')
|
||||
const emitMock = this.mock(eventBus, 'emit')
|
||||
|
||||
this.render(Component)
|
||||
preferenceStore.initialized.value = true
|
||||
|
@ -19,7 +17,6 @@ new class extends UnitTestCase {
|
|||
await waitFor(() => {
|
||||
expect(initPlaybackMock).toHaveBeenCalled()
|
||||
expect(initVolumeMock).toHaveBeenCalled()
|
||||
expect(emitMock).toHaveBeenCalledWith('INIT_EQUALIZER')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -41,8 +41,6 @@ const initPlaybackRelatedServices = async () => {
|
|||
playbackService.init(plyrWrapper)
|
||||
volumeManager.init(volumeInput)
|
||||
isAudioContextSupported && audioService.init(playbackService.player.media)
|
||||
|
||||
eventBus.emit('INIT_EQUALIZER')
|
||||
}
|
||||
|
||||
watch(preferenceStore.initialized, async initialized => {
|
||||
|
|
|
@ -1,72 +1,79 @@
|
|||
<template>
|
||||
<div id="equalizer" data-testid="equalizer" ref="root">
|
||||
<div class="presets">
|
||||
<form id="equalizer" ref="root" data-testid="equalizer" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<label class="select-wrapper">
|
||||
<select v-model="selectedPresetId" title="Select equalizer">
|
||||
<option disabled value="-1">Preset</option>
|
||||
<option v-for="preset in presets" :value="preset.id" :key="preset.id" v-once>{{ preset.name }}</option>
|
||||
<option v-for="preset in presets" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
</select>
|
||||
<icon :icon="faCaretDown" class="arrow text-highlight" size="sm"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bands">
|
||||
<span class="band preamp">
|
||||
<span class="slider"></span>
|
||||
<label>Preamp</label>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<span class="indicators">
|
||||
<span>+20</span>
|
||||
<span>0</span>
|
||||
<span>-20</span>
|
||||
</span>
|
||||
<main>
|
||||
<div class="bands">
|
||||
<span class="band">
|
||||
<span class="slider"/>
|
||||
<label>Preamp</label>
|
||||
</span>
|
||||
|
||||
<span class="band amp" v-for="band in bands" :key="band.label">
|
||||
<span class="slider"></span>
|
||||
<label>{{ band.label }}</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="indicators">
|
||||
<span>+20</span>
|
||||
<span>0</span>
|
||||
<span>-20</span>
|
||||
</span>
|
||||
|
||||
<span v-for="band in bands" :key="band.label" class="band">
|
||||
<span class="slider"/>
|
||||
<label>{{ band.label }}</label>
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Btn @click.prevent="close">Close</Btn>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noUiSlider from 'nouislider'
|
||||
import { faCaretDown } from '@fortawesome/free-solid-svg-icons'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { equalizerStore, preferenceStore as preferences } from '@/stores'
|
||||
import { audioService as audioService } from '@/services'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { equalizerStore } from '@/stores'
|
||||
import { audioService } from '@/services'
|
||||
import { equalizerPresets as presets } from '@/config'
|
||||
|
||||
interface Band {
|
||||
label: string
|
||||
filter: BiquadFilterNode
|
||||
}
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
|
||||
let context!: AudioContext
|
||||
let preampGainNode!: GainNode
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const bands = audioService.bands
|
||||
const root = ref<HTMLElement>()
|
||||
const bands = ref<Band[]>([])
|
||||
const preampGainValue = ref(0)
|
||||
const preampGain = ref(0)
|
||||
const selectedPresetId = ref(-1)
|
||||
|
||||
const presets: EqualizerPreset[] = Object.assign([], equalizerStore.presets)
|
||||
watch(preampGain, value => audioService.changePreampGain(value))
|
||||
|
||||
const changePreampGain = (dbValue: number) => {
|
||||
preampGainValue.value = dbValue
|
||||
preampGainNode.gain.setTargetAtTime(Math.pow(10, dbValue / 20), context.currentTime, 0.01)
|
||||
}
|
||||
watch(selectedPresetId, () => {
|
||||
if (selectedPresetId.value !== -1) {
|
||||
loadPreset(equalizerStore.getPresetById(selectedPresetId.value) || presets[0])
|
||||
}
|
||||
|
||||
const changeFilterGain = (filter: BiquadFilterNode, value: number) => {
|
||||
filter.gain.setTargetAtTime(value, context.currentTime, 0.01)
|
||||
}
|
||||
save()
|
||||
})
|
||||
|
||||
const createSliders = () => {
|
||||
const config = equalizerStore.get()!
|
||||
const config = equalizerStore.getConfig()
|
||||
|
||||
root.value?.querySelectorAll<SliderElement>('.slider').forEach((el, i) => {
|
||||
el.noUiSlider?.destroy()
|
||||
selectedPresetId.value = config.id
|
||||
preampGain.value = config.preamp
|
||||
|
||||
if (!root.value) {
|
||||
throw new Error('Equalizer config or root element not found')
|
||||
}
|
||||
|
||||
root.value.querySelectorAll<EqualizerBandElement>('.slider').forEach((el, i) => {
|
||||
noUiSlider.create(el, {
|
||||
connect: [false, true],
|
||||
// the first element is the preamp. The rest are gains.
|
||||
|
@ -76,116 +83,58 @@ const createSliders = () => {
|
|||
direction: 'rtl'
|
||||
})
|
||||
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error(`Failed to initialize slider on element ${i}`)
|
||||
}
|
||||
el.isPreamp = i === 0
|
||||
|
||||
el.noUiSlider.on('slide', (values, handle) => {
|
||||
if (el.parentElement!.matches('.preamp')) {
|
||||
changePreampGain(values[handle])
|
||||
} else {
|
||||
changeFilterGain(bands.value[i - 1].filter, values[handle])
|
||||
}
|
||||
})
|
||||
const value = parseFloat(values[handle])
|
||||
|
||||
if (el.isPreamp) {
|
||||
preampGain.value = value
|
||||
} else {
|
||||
audioService.changeFilterGain(bands[i - 1].filter, value)
|
||||
}
|
||||
|
||||
el.noUiSlider.on('change', () => {
|
||||
// User has customized the equalizer. No preset should be selected.
|
||||
selectedPresetId.value = -1
|
||||
|
||||
save()
|
||||
})
|
||||
})
|
||||
|
||||
// Now we set this value to trigger the audio processing.
|
||||
selectedPresetId.value = preferences.selectedPreset
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const config = equalizerStore.get()!
|
||||
|
||||
context = audioService.getContext()
|
||||
preampGainNode = context.createGain()
|
||||
changePreampGain(config.preamp)
|
||||
|
||||
const source = audioService.getSource()
|
||||
source.connect(preampGainNode)
|
||||
|
||||
let prevFilter: BiquadFilterNode
|
||||
|
||||
// Create 10 bands with the frequencies similar to those of Winamp and connect them together.
|
||||
const frequencies = [60, 170, 310, 600, 1_000, 3_000, 6_000, 12_000, 14_000, 16_000]
|
||||
|
||||
frequencies.forEach((frequency, i) => {
|
||||
const filter = context.createBiquadFilter()
|
||||
|
||||
if (i === 0) {
|
||||
filter.type = 'lowshelf'
|
||||
} else if (i === 9) {
|
||||
filter.type = 'highshelf'
|
||||
} else {
|
||||
filter.type = 'peaking'
|
||||
}
|
||||
|
||||
filter.gain.setTargetAtTime(0, context.currentTime, 0.01)
|
||||
filter.Q.setTargetAtTime(1, context.currentTime, 0.01)
|
||||
filter.frequency.setTargetAtTime(frequency, context.currentTime, 0.01)
|
||||
|
||||
prevFilter ? prevFilter.connect(filter) : preampGainNode.connect(filter)
|
||||
prevFilter = filter
|
||||
|
||||
bands.value.push({
|
||||
filter,
|
||||
label: String(frequency).replace('000', 'K')
|
||||
})
|
||||
})
|
||||
|
||||
prevFilter!.connect(context.destination)
|
||||
|
||||
await nextTick()
|
||||
createSliders()
|
||||
}
|
||||
|
||||
const save = () => equalizerStore.set(preampGainValue.value, bands.value.map(band => band.filter.gain.value))
|
||||
|
||||
const loadPreset = (preset: EqualizerPreset) => {
|
||||
root.value?.querySelectorAll<SliderElement>('.slider').forEach((el, i) => {
|
||||
preampGain.value = preset.preamp
|
||||
|
||||
root.value?.querySelectorAll<EqualizerBandElement>('.slider').forEach((el, i) => {
|
||||
if (!el.noUiSlider) {
|
||||
throw new Error('Preset can only be loaded after sliders have been set up')
|
||||
}
|
||||
|
||||
// We treat our preamp slider differently.
|
||||
if (el.parentElement!.matches('.preamp')) {
|
||||
changePreampGain(preset.preamp)
|
||||
// Update the slider values into GUI.
|
||||
if (el.isPreamp) {
|
||||
el.noUiSlider.set(preset.preamp)
|
||||
} else {
|
||||
changeFilterGain(bands.value[i - 1].filter, preset.gains[i - 1])
|
||||
// Update the slider values into GUI.
|
||||
audioService.changeFilterGain(bands[i - 1].filter, preset.gains[i - 1])
|
||||
el.noUiSlider.set(preset.gains[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
watch(selectedPresetId, () => {
|
||||
preferences.selectedPreset = selectedPresetId.value
|
||||
selectedPresetId.value !== -1 && loadPreset(equalizerStore.getPresetById(selectedPresetId.value)!)
|
||||
})
|
||||
const save = () => equalizerStore.saveConfig(selectedPresetId.value, preampGain.value, bands.map(band => band.db))
|
||||
const close = () => emit('close')
|
||||
|
||||
onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
||||
onMounted(() => createSliders())
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#equalizer {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
bottom: var(--footer-height);
|
||||
width: 100%;
|
||||
background: var(--color-bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
box-shadow: 0 0 50x 0 var(--color-bg-primary);
|
||||
|
||||
footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 8px;
|
||||
|
@ -193,22 +142,18 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.presets {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
|
||||
.select-wrapper {
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
padding: 0 8px;
|
||||
background: rgba(0, 0, 0, .2);
|
||||
border-radius: 5px;
|
||||
|
||||
.arrow {
|
||||
margin-left: -14px;
|
||||
margin-left: -6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
@ -227,12 +172,11 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
}
|
||||
|
||||
.bands {
|
||||
padding: 16px;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
padding: 14px 16px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, .2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
label, .indicators {
|
||||
font-size: .8rem;
|
||||
|
@ -242,6 +186,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
|
@ -256,8 +201,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-left: -16px;
|
||||
opacity: 0;
|
||||
transition: .4s;
|
||||
opacity: .5;
|
||||
|
||||
span:first-child {
|
||||
line-height: 8px;
|
||||
|
@ -267,10 +211,6 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
line-height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .indicators {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.noUi {
|
||||
|
@ -325,25 +265,12 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
|||
&-vertical {
|
||||
.noUi-handle {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
height: 6px;
|
||||
left: -16px;
|
||||
border-radius: 9999px;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
position: fixed;
|
||||
max-width: 414px;
|
||||
left: auto;
|
||||
right: 0;
|
||||
bottom: var(--footer-height);
|
||||
display: block;
|
||||
height: auto;
|
||||
|
||||
label {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,7 +8,7 @@ import Volume from './Volume.vue'
|
|||
new class extends UnitTestCase {
|
||||
protected beforeEach (cb?: Closure) {
|
||||
super.beforeEach(() => {
|
||||
preferenceStore.state.volume = 5
|
||||
preferenceStore.volume = 5
|
||||
volumeManager.init(document.createElement('input'))
|
||||
})
|
||||
}
|
||||
|
|
88
resources/assets/js/config/audio.ts
Normal file
88
resources/assets/js/config/audio.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
export const equalizerPresets: EqualizerPreset[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Default',
|
||||
preamp: 0,
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Classical',
|
||||
preamp: -1,
|
||||
gains: [-1, -1, -1, -1, -1, -1, -7, -7, -7, -9]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Club',
|
||||
preamp: -6.7,
|
||||
gains: [-1, -1, 8, 5, 5, 5, 3, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Dance',
|
||||
preamp: -4.3,
|
||||
gains: [9, 7, 2, -1, -1, -5, -7, -7, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Full Bass',
|
||||
preamp: -7.2,
|
||||
gains: [-8, 9, 9, 5, 1, -4, -8, -10, -11, -11]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Full Treble',
|
||||
preamp: -12,
|
||||
gains: [-9, -9, -9, -4, 2, 11, 16, 16, 16, 16]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Headphone',
|
||||
preamp: -8,
|
||||
gains: [4, 11, 5, -3, -2, 1, 4, 9, 12, 14]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Large Hall',
|
||||
preamp: -7.2,
|
||||
gains: [10, 10, 5, 5, -1, -4, -4, -4, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Live',
|
||||
preamp: -5.3,
|
||||
gains: [-4, -1, 4, 5, 5, 5, 4, 2, 2, 2]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Pop',
|
||||
preamp: -6.2,
|
||||
gains: [-1, 4, 7, 8, 5, -1, -2, -2, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Reggae',
|
||||
preamp: -8.2,
|
||||
gains: [-1, -1, -1, -5, -1, 6, 6, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Rock',
|
||||
preamp: -10,
|
||||
gains: [8, 4, -5, -8, -3, 4, 8, 11, 11, 11]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Soft Rock',
|
||||
preamp: -5.3,
|
||||
gains: [4, 4, 2, -1, -4, -5, -3, -1, 2, 8]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Techno',
|
||||
preamp: -7.7,
|
||||
gains: [8, 5, -1, -5, -4, -1, 8, 9, 9, 8]
|
||||
}
|
||||
]
|
||||
|
||||
export const frequencies = [60, 170, 310, 600, 1_000, 3_000, 6_000, 12_000, 14_000, 16_000]
|
|
@ -26,6 +26,7 @@ export type EventName =
|
|||
| 'MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM'
|
||||
| 'MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM'
|
||||
| 'MODAL_SHOW_ABOUT_KOEL'
|
||||
| 'MODAL_SHOW_EQUALIZER'
|
||||
|
||||
| 'PLAYLIST_DELETE'
|
||||
| 'PLAYLIST_FOLDER_DELETE'
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './events'
|
|||
export * from './acceptedMediaTypes'
|
||||
export * from './genres'
|
||||
export * from './routes'
|
||||
export * from './audio'
|
||||
|
|
|
@ -85,7 +85,7 @@ const DEFAULT_VOLUME = 7
|
|||
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/AlbumArtOverlay.vue'))
|
||||
const LoginForm = defineAsyncComponent(() => import('@/components/auth/LoginForm.vue'))
|
||||
|
||||
const volumeSlider = ref<SliderElement>()
|
||||
const volumeSlider = ref<EqualizerBandElement>()
|
||||
const authenticated = ref(false)
|
||||
const song = ref<Song>()
|
||||
const connected = ref(false)
|
||||
|
|
|
@ -1,27 +1,75 @@
|
|||
import { equalizerStore } from '@/stores'
|
||||
import { frequencies } from '@/config'
|
||||
import { dbToGain } from '@/utils'
|
||||
|
||||
interface Band {
|
||||
label: string
|
||||
filter: BiquadFilterNode
|
||||
db: number
|
||||
}
|
||||
|
||||
export const audioService = {
|
||||
unlocked: false,
|
||||
|
||||
context: null as unknown as AudioContext,
|
||||
source: null as unknown as MediaElementAudioSourceNode,
|
||||
element: null as unknown as HTMLMediaElement,
|
||||
preampGainNode: null as unknown as GainNode,
|
||||
|
||||
bands: [] as Band[],
|
||||
|
||||
init (mediaElement: HTMLMediaElement) {
|
||||
this.element = mediaElement
|
||||
|
||||
init (element: HTMLMediaElement) {
|
||||
this.context = new AudioContext()
|
||||
this.source = this.context.createMediaElementSource(element)
|
||||
this.element = element
|
||||
this.preampGainNode = this.context.createGain()
|
||||
this.source = this.context.createMediaElementSource(this.element)
|
||||
this.source.connect(this.preampGainNode)
|
||||
|
||||
const config = equalizerStore.getConfig()
|
||||
|
||||
this.changePreampGain(config.preamp)
|
||||
|
||||
let prevFilter: BiquadFilterNode
|
||||
|
||||
// Create 10 bands with the frequencies similar to those of Winamp and connect them together.
|
||||
frequencies.forEach((frequency, i) => {
|
||||
const filter = this.context.createBiquadFilter()
|
||||
|
||||
if (i === 0) {
|
||||
filter.type = 'lowshelf'
|
||||
} else if (i === frequencies.length - 1) {
|
||||
filter.type = 'highshelf'
|
||||
} else {
|
||||
filter.type = 'peaking'
|
||||
}
|
||||
|
||||
filter.Q.setTargetAtTime(1, this.context.currentTime, 0.01)
|
||||
filter.frequency.setTargetAtTime(frequency, this.context.currentTime, 0.01)
|
||||
filter.gain.value = dbToGain(config.gains[i])
|
||||
|
||||
prevFilter ? prevFilter.connect(filter) : this.preampGainNode.connect(filter)
|
||||
prevFilter = filter
|
||||
|
||||
this.bands.push({
|
||||
filter,
|
||||
label: String(frequency).replace('000', 'K'),
|
||||
db: config.gains[i]
|
||||
})
|
||||
})
|
||||
|
||||
prevFilter!.connect(this.context.destination)
|
||||
|
||||
this.unlockAudioContext()
|
||||
},
|
||||
|
||||
getContext () {
|
||||
return this.context
|
||||
changePreampGain (db: number) {
|
||||
this.preampGainNode.gain.value = dbToGain(db)
|
||||
},
|
||||
|
||||
getSource () {
|
||||
return this.source
|
||||
},
|
||||
|
||||
getElement () {
|
||||
return this.element
|
||||
changeFilterGain (node: BiquadFilterNode, db: number) {
|
||||
this.bands.find(band => band.filter === node)!.db = db
|
||||
node.gain.value = dbToGain(db)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -88,7 +88,7 @@ class PlaybackService {
|
|||
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
|
||||
// Fixes #898
|
||||
if (isAudioContextSupported) {
|
||||
await audioService.getContext().resume()
|
||||
await audioService.context.resume()
|
||||
}
|
||||
|
||||
await this.restart()
|
||||
|
|
|
@ -1,121 +1,34 @@
|
|||
import { preferenceStore } from '@/stores'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
import { equalizerPresets as presets } from '@/config'
|
||||
|
||||
export const equalizerStore = {
|
||||
presets: [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Default',
|
||||
preamp: 0,
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Classical',
|
||||
preamp: -1,
|
||||
gains: [-1, -1, -1, -1, -1, -1, -7, -7, -7, -9]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Club',
|
||||
preamp: -6.7,
|
||||
gains: [-1, -1, 8, 5, 5, 5, 3, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Dance',
|
||||
preamp: -4.3,
|
||||
gains: [9, 7, 2, -1, -1, -5, -7, -7, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Full Bass',
|
||||
preamp: -7.2,
|
||||
gains: [-8, 9, 9, 5, 1, -4, -8, -10, -11, -11]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Full Treble',
|
||||
preamp: -12,
|
||||
gains: [-9, -9, -9, -4, 2, 11, 16, 16, 16, 16]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Headphone',
|
||||
preamp: -8,
|
||||
gains: [4, 11, 5, -3, -2, 1, 4, 9, 12, 14]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Large Hall',
|
||||
preamp: -7.2,
|
||||
gains: [10, 10, 5, 5, -1, -4, -4, -4, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Live',
|
||||
preamp: -5.3,
|
||||
gains: [-4, -1, 4, 5, 5, 5, 4, 2, 2, 2]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Pop',
|
||||
preamp: -6.2,
|
||||
gains: [-1, 4, 7, 8, 5, -1, -2, -2, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Reggae',
|
||||
preamp: -8.2,
|
||||
gains: [-1, -1, -1, -5, -1, 6, 6, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Rock',
|
||||
preamp: -10,
|
||||
gains: [8, 4, -5, -8, -3, 4, 8, 11, 11, 11]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Soft Rock',
|
||||
preamp: -5.3,
|
||||
gains: [4, 4, 2, -1, -4, -5, -3, -1, 2, 8]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Techno',
|
||||
preamp: -7.7,
|
||||
gains: [8, 5, -1, -5, -4, -1, 8, 9, 9, 8]
|
||||
}
|
||||
] as EqualizerPreset[],
|
||||
|
||||
getPresetById (id: number) {
|
||||
return this.presets.find(preset => preset.id === id)
|
||||
return presets.find(preset => preset.id === id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current equalizer config.
|
||||
*/
|
||||
get () {
|
||||
if (!this.presets[preferenceStore.selectedPreset]) {
|
||||
return preferenceStore.equalizer
|
||||
getConfig () {
|
||||
if (preferences.equalizer.id === -1) {
|
||||
return preferences.equalizer
|
||||
}
|
||||
|
||||
// If the user chose a preset (instead of customizing one), just return it.
|
||||
return this.getPresetById(preferenceStore.selectedPreset)
|
||||
return this.getPresetById(preferences.equalizer.id) || presets[0]
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current equalizer config.
|
||||
*
|
||||
* @param {number} preamp The preamp value (dB)
|
||||
* @param {number[]} gains The band's gain value (dB)
|
||||
*/
|
||||
set: (preamp: number, gains: number[]) => {
|
||||
preferenceStore.equalizer = {
|
||||
id: -1,
|
||||
name: 'Custom',
|
||||
saveConfig (id: number, preamp: number, gains: number[]) {
|
||||
const preset = this.getPresetById(id)
|
||||
|
||||
preferences.equalizer = preset || {
|
||||
preamp,
|
||||
gains
|
||||
gains,
|
||||
id: -1,
|
||||
name: 'Custom'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { reactive, ref } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { localStorageService } from '@/services'
|
||||
|
||||
interface Preferences extends Record<string, any> {
|
||||
|
@ -10,7 +9,6 @@ interface Preferences extends Record<string, any> {
|
|||
equalizer: EqualizerPreset,
|
||||
artistsViewMode: ArtistAlbumViewMode | null,
|
||||
albumsViewMode: ArtistAlbumViewMode | null,
|
||||
selectedPreset: number
|
||||
transcodeOnMobile: boolean
|
||||
supportBarNoBugging: boolean
|
||||
showAlbumArtOverlay: boolean
|
||||
|
@ -28,12 +26,13 @@ const preferenceStore = {
|
|||
repeatMode: 'NO_REPEAT',
|
||||
confirmClosing: false,
|
||||
equalizer: {
|
||||
id: 0,
|
||||
name: 'Default',
|
||||
preamp: 0,
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
artistsViewMode: null,
|
||||
albumsViewMode: null,
|
||||
selectedPreset: -1,
|
||||
transcodeOnMobile: false,
|
||||
supportBarNoBugging: false,
|
||||
showAlbumArtOverlay: true,
|
||||
|
@ -41,9 +40,8 @@ const preferenceStore = {
|
|||
theme: null
|
||||
}),
|
||||
|
||||
init (user?: User): void {
|
||||
const initUser = user || userStore.current
|
||||
this.storeKey = `preferences_${initUser.id}`
|
||||
init (user: User): void {
|
||||
this.storeKey = `preferences_${user.id}`
|
||||
Object.assign(this.state, localStorageService.get(this.storeKey, this.state))
|
||||
this.setupProxy()
|
||||
|
||||
|
|
13
resources/assets/js/types.d.ts
vendored
13
resources/assets/js/types.d.ts
vendored
|
@ -47,6 +47,7 @@ declare module 'nouislider' {
|
|||
}
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
direction: 'ltr' | 'rtl'
|
||||
step?: number
|
||||
}): void
|
||||
}
|
||||
|
||||
|
@ -255,12 +256,14 @@ interface Interaction {
|
|||
play_count: number
|
||||
}
|
||||
|
||||
interface SliderElement extends HTMLElement {
|
||||
noUiSlider?: {
|
||||
interface EqualizerBandElement extends HTMLElement {
|
||||
noUiSlider: {
|
||||
destroy (): void
|
||||
on (eventName: 'change' | 'slide', handler: (value: number[], handle: number) => unknown): void
|
||||
on (eventName: 'change' | 'slide', handler: (value: string[], handle: number) => unknown): void
|
||||
set (options: number | any[]): void
|
||||
}
|
||||
|
||||
isPreamp: boolean
|
||||
}
|
||||
|
||||
type OverlayState = {
|
||||
|
@ -276,8 +279,8 @@ interface SongRow {
|
|||
}
|
||||
|
||||
interface EqualizerPreset {
|
||||
id?: number
|
||||
name?: string
|
||||
id: number
|
||||
name: string
|
||||
preamp: number
|
||||
gains: number[]
|
||||
}
|
||||
|
|
|
@ -39,3 +39,5 @@ export const requireInjection = <T> (key: InjectionKey<T>, defaultValue?: T) =>
|
|||
|
||||
return value
|
||||
}
|
||||
|
||||
export const dbToGain = (db: number) => Math.pow(10, db / 20) || 0
|
||||
|
|
|
@ -45,10 +45,10 @@ class AudioAnalyser {
|
|||
this.smoothing = smoothing
|
||||
this.onUpdate = onUpdate
|
||||
|
||||
this.audio = audioService.getElement()
|
||||
this.source = audioService.getSource()
|
||||
this.audio = audioService.element
|
||||
this.source = audioService.source
|
||||
|
||||
this.analyser = audioService.getContext().createAnalyser()
|
||||
this.analyser = audioService.context.createAnalyser()
|
||||
this.analyser.smoothingTimeConstant = this.smoothing
|
||||
this.analyser.fftSize = this.bandCount * 2
|
||||
|
||||
|
|
Loading…
Reference in a new issue