添加音调升降调节
parent
677c3f16e4
commit
40997c34ff
|
@ -39,6 +39,14 @@ module.exports = {
|
|||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/,
|
||||
// parser: {
|
||||
// worker: [
|
||||
// '*context.audioWorklet.addModule()',
|
||||
// '*audioWorklet.addModule()',
|
||||
// // *addModule() is not valid syntax
|
||||
// // '...',
|
||||
// ],
|
||||
// },
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
### 新增
|
||||
|
||||
- 新增音效设置(实验性功能),支持10段均衡器设置、内置的一些环境混响音效、3D立体环绕音效
|
||||
- 新增音效设置(实验性功能),支持10段均衡器设置、内置的一些环境混响音效、音调升降调节、3D立体环绕音效
|
||||
- 播放速率设置面板新增是否音调补偿设置,在调整播放速率后,可以选择是否启用音调补偿,默认启用
|
||||
|
||||
### 修复
|
||||
|
|
|
@ -57,6 +57,7 @@ const defaultSetting: LX.AppSetting = {
|
|||
'player.soundEffect.panner.enable': false,
|
||||
'player.soundEffect.panner.soundR': 5,
|
||||
'player.soundEffect.panner.speed': 25,
|
||||
'player.soundEffect.pitchShifter.playbackRate': 1,
|
||||
|
||||
'playDetail.isZoomActiveLrc': false,
|
||||
'playDetail.isShowLyricProgressSetting': false,
|
||||
|
|
|
@ -94,6 +94,8 @@ const modules = {
|
|||
save_sound_effect_eq_preset: 'save_sound_effect_eq_preset',
|
||||
get_sound_effect_convolution_preset: 'get_sound_effect_convolution_preset',
|
||||
save_sound_effect_convolution_preset: 'save_sound_effect_convolution_preset',
|
||||
get_sound_effect_pitch_shifter_preset: 'get_sound_effect_pitch_shifter_preset',
|
||||
save_sound_effect_pitch_shifter_preset: 'save_sound_effect_pitch_shifter_preset',
|
||||
get_hot_key: 'get_hot_key',
|
||||
|
||||
import_user_api: 'import_user_api',
|
||||
|
|
|
@ -248,6 +248,11 @@ declare global {
|
|||
*/
|
||||
'player.soundEffect.panner.speed': number
|
||||
|
||||
/**
|
||||
* 升降声调
|
||||
*/
|
||||
'player.soundEffect.pitchShifter.playbackRate': number
|
||||
|
||||
/**
|
||||
* 是否启用音频加载失败时自动切歌
|
||||
*/
|
||||
|
|
|
@ -21,5 +21,10 @@ declare namespace LX {
|
|||
mainGain: number
|
||||
sendGain: number
|
||||
}
|
||||
interface PitchShifterPreset {
|
||||
id: string
|
||||
name: string
|
||||
playbackRate: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -264,6 +264,9 @@
|
|||
"player__sound_effect_panner_enabled": "enable",
|
||||
"player__sound_effect_panner_sound_r": "Sound distance",
|
||||
"player__sound_effect_panner_sound_speed": "Surround speed",
|
||||
"player__sound_effect_pitch_shifter": "Pitch adjustment",
|
||||
"player__sound_effect_pitch_shifter_preset_semitones": "{num} semitones",
|
||||
"player__sound_effect_pitch_shifter_reset_btn": "Reset",
|
||||
"player__stop": "Paused",
|
||||
"player__volume": "Volume: ",
|
||||
"player__volume_mute_label": "Mute",
|
||||
|
|
|
@ -263,6 +263,9 @@
|
|||
"player__sound_effect_panner_enabled": "启用",
|
||||
"player__sound_effect_panner_sound_r": "声音距离",
|
||||
"player__sound_effect_panner_sound_speed": "环绕速度",
|
||||
"player__sound_effect_pitch_shifter": "音调升降调节",
|
||||
"player__sound_effect_pitch_shifter_preset_semitones": "{num} 半音",
|
||||
"player__sound_effect_pitch_shifter_reset_btn": "重置",
|
||||
"player__stop": "暂停播放",
|
||||
"player__volume": "当前音量:",
|
||||
"player__volume_mute_label": "静音",
|
||||
|
|
|
@ -264,6 +264,9 @@
|
|||
"player__sound_effect_panner_enabled": "啟用",
|
||||
"player__sound_effect_panner_sound_r": "聲音距離",
|
||||
"player__sound_effect_panner_sound_speed": "環繞速度",
|
||||
"player__sound_effect_pitch_shifter": "音調升降調節",
|
||||
"player__sound_effect_pitch_shifter_preset_semitones": "{num} 半音",
|
||||
"player__sound_effect_pitch_shifter_reset_btn": "重置",
|
||||
"player__stop": "暫停播放",
|
||||
"player__volume": "當前音量:",
|
||||
"player__volume_mute_label": "靜音",
|
||||
|
|
|
@ -17,4 +17,11 @@ export default () => {
|
|||
mainOn<LX.SoundEffect.ConvolutionPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_convolution_preset, ({ params }) => {
|
||||
getStore(STORE_NAMES.SOUND_EFFECT).set('convolutionPreset', params)
|
||||
})
|
||||
|
||||
mainHandle<LX.SoundEffect.PitchShifterPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_pitch_shifter_preset, async() => {
|
||||
return getStore(STORE_NAMES.SOUND_EFFECT).get('pitchShifterPreset') as LX.SoundEffect.PitchShifterPreset[] | null ?? []
|
||||
})
|
||||
mainOn<LX.SoundEffect.PitchShifterPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_pitch_shifter_preset, ({ params }) => {
|
||||
getStore(STORE_NAMES.SOUND_EFFECT).set('pitchShifterPreset', params)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<base-btn min :class="[$style.newPreset, {[$style.editing]: isEditing}]" :aria-label="$t('player__sound_effect_biquad_filter_save_btn')" @click="handleEditing($event)">
|
||||
<svg-icon name="plus" />
|
||||
<base-input ref="input" :class="$style.newPresetInput" :value="newPresetName" :placeholder="$t('player__sound_effect_biquad_filter_save_input')" @keyup.enter="handleSave($event)" @blur="handleSave($event)" />
|
||||
</base-btn>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from '@common/utils/vueTools'
|
||||
import { appSetting } from '@renderer/store/setting'
|
||||
import { saveUserPitchShifterPreset } from '@renderer/store/soundEffect'
|
||||
|
||||
const isEditing = ref(false)
|
||||
const input = ref(false)
|
||||
const newPresetName = ref('')
|
||||
|
||||
const handleEditing = () => {
|
||||
if (isEditing.value) return
|
||||
// if (!this.newPresetName) this.newPresetName = this.listName
|
||||
isEditing.value = true
|
||||
nextTick(() => {
|
||||
input.value.$el.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = (event) => {
|
||||
let name = event.target.value.trim()
|
||||
newPresetName.value = event.target.value = ''
|
||||
isEditing.value = false
|
||||
if (!name) return
|
||||
if (name.length > 20) name = name.substring(0, 20)
|
||||
saveUserPitchShifterPreset({
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
playbackRate: appSetting['player.soundEffect.pitchShifter.playbackRate'],
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" module>
|
||||
@import '@renderer/assets/styles/layout.less';
|
||||
|
||||
.newPreset {
|
||||
position: relative;
|
||||
border: 1px dashed var(--color-primary-font-hover);
|
||||
// background-color: var(--color-main-background);
|
||||
color: var(--color-primary-font-hover);
|
||||
opacity: .7;
|
||||
height: 22px;
|
||||
|
||||
&.editing {
|
||||
opacity: 1;
|
||||
width: 90px;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
.newPresetInput {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.svg-icon {
|
||||
vertical-align: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.newPresetInput {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// line-height: 16px;
|
||||
background: none !important;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
padding: 0 3px;
|
||||
border-radius: 0;
|
||||
display: none;
|
||||
&::placeholder {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -27,7 +27,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['scroll', $style.saveList]">
|
||||
<div :class="$style.saveList">
|
||||
<base-btn v-for="item in userPresetList" :key="item.id" min @click="handleSetPreset(item)" @contextmenu="handleRemovePreset(item.id)">{{ item.name }}</base-btn>
|
||||
<AddConvolutionPresetBtn v-if="userPresetList.length < 31" />
|
||||
</div>
|
||||
|
@ -88,6 +88,7 @@ onMounted(() => {
|
|||
flex-flow: column nowrap;
|
||||
gap: 3px;
|
||||
min-height: 0;
|
||||
flex: none;
|
||||
}
|
||||
.convolution {
|
||||
display: flex;
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<span :class="$style.value">{{ appSetting[`player.soundEffect.biquadFilter.hz${v}`] }}db</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['scroll', $style.saveList]">
|
||||
<div :class="$style.saveList">
|
||||
<!-- <base-btn min @click="handleSetPreset(item)">{{ $t(`player__sound_effect_biquad_filter_preset_slow`) }}</base-btn> -->
|
||||
<base-btn v-for="item in freqsPreset" :key="item.name" min @click="handleSetPreset(item)">{{ $t(`player__sound_effect_biquad_filter_preset_${item.name}`) }}</base-btn>
|
||||
<base-btn v-for="item in userPresetList" :key="item.id" min @click="handleSetPreset(item)" @contextmenu="handleRemovePreset(item.id)">{{ item.name }}</base-btn>
|
||||
|
@ -83,6 +83,7 @@ onMounted(() => {
|
|||
flex-flow: column nowrap;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
flex: none;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<div :class="$style.contnet">
|
||||
<div class="player__sound_effect_title" :class="$style.header">
|
||||
<h3>{{ $t('player__sound_effect_pitch_shifter') }}</h3>
|
||||
<base-btn min @click="handleSetPreset(1)">{{ $t('player__sound_effect_pitch_shifter_reset_btn') }}</base-btn>
|
||||
</div>
|
||||
<div :class="$style.eqList">
|
||||
<div :class="$style.eqItem">
|
||||
<span :class="$style.label">{{ playbackRate.toFixed(2) }}x</span>
|
||||
<base-slider-bar :class="$style.slider" :value="playbackRate * 100" :min="50" :max="150" @change="handleUpdatePlaybackRate" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.saveList">
|
||||
<base-btn v-for="num in semitones" :key="num" min @click="handleSetSemitones(num)">{{ $t(`player__sound_effect_pitch_shifter_preset_semitones`, { num: num > 0 ? `+${num}` : num }) }}</base-btn>
|
||||
<base-btn v-for="item in userPresetList" :key="item.id" min @click="handleSetPreset(item.playbackRate)" @contextmenu="handleRemovePreset(item.id)">{{ item.name }}</base-btn>
|
||||
<AddPitchShifterPresetBtn v-if="userPresetList.length < 31" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from '@common/utils/vueTools'
|
||||
import { appSetting, updateSetting } from '@renderer/store/setting'
|
||||
import AddPitchShifterPresetBtn from './AddPitchShifterPresetBtn.vue'
|
||||
import { getUserPitchShifterPresetList, removeUserPitchShifterPreset } from '@renderer/store/soundEffect'
|
||||
import { semitones } from '@renderer/plugins/player'
|
||||
|
||||
// const setting = reactive({
|
||||
// enabled: false,
|
||||
// soundR: 5,
|
||||
// speed: 25,
|
||||
// })
|
||||
|
||||
|
||||
const playbackRate = computed(() => appSetting['player.soundEffect.pitchShifter.playbackRate'])
|
||||
|
||||
const handleSetPreset = (value) => {
|
||||
updateSetting({ 'player.soundEffect.pitchShifter.playbackRate': value })
|
||||
}
|
||||
|
||||
const handleSetSemitones = (value) => {
|
||||
// https://zpl.fi/pitch-shifting-in-web-audio-api/
|
||||
handleSetPreset(2 ** (value / 12))
|
||||
}
|
||||
|
||||
const handleUpdatePlaybackRate = (value) => {
|
||||
value = parseFloat((Math.round(value) / 100).toFixed(2))
|
||||
handleSetPreset(value)
|
||||
}
|
||||
|
||||
|
||||
const userPresetList = ref([])
|
||||
|
||||
const handleRemovePreset = id => {
|
||||
removeUserPitchShifterPreset(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUserPitchShifterPresetList().then(list => {
|
||||
userPresetList.value = list
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" module>
|
||||
@import '@renderer/assets/styles/layout.less';
|
||||
.contnet {
|
||||
padding-top: 15px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
flex: none;
|
||||
&:before {
|
||||
.mixin-after;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
border-top: 1px dashed var(--color-primary-light-100-alpha-700);
|
||||
}
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 5px;
|
||||
// padding-top: 5px;
|
||||
}
|
||||
.eqList {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.eqItem {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.label {
|
||||
flex: none;
|
||||
// width: 50px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.value {
|
||||
flex: none;
|
||||
width: 40px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary-font);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
// justify-content: space-between;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// font-size: 13px;
|
||||
span {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-right: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.saveList {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -8,12 +8,13 @@
|
|||
<!-- <main :class="$style.main"> -->
|
||||
<!-- <h2 :class="$style.title">{{ $t('theme_edit_modal__title') }}</h2> -->
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.row">
|
||||
<div :class="['scroll', $style.row]">
|
||||
<AudioConvolution />
|
||||
<AudioPanner />
|
||||
<PitchShifter />
|
||||
</div>
|
||||
<div :class="$style.row">
|
||||
<div :class="['scroll', $style.row]">
|
||||
<BiquadFilter />
|
||||
<AudioPanner />
|
||||
</div>
|
||||
</div>
|
||||
<!-- </main> -->
|
||||
|
@ -31,6 +32,7 @@ import { ref } from '@common/utils/vueTools'
|
|||
import BiquadFilter from './BiquadFilter'
|
||||
import AudioPanner from './AudioPanner'
|
||||
import AudioConvolution from './AudioConvolution'
|
||||
import PitchShifter from './PitchShifter.vue'
|
||||
|
||||
defineProps({
|
||||
teleport: {
|
||||
|
@ -97,9 +99,9 @@ const visible = ref(false)
|
|||
.content {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
padding: 0 15px;
|
||||
padding: 0 5px;
|
||||
margin: 15px 0;
|
||||
gap: 30px;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
|
||||
|
@ -129,6 +131,7 @@ const visible = ref(false)
|
|||
display: flex;
|
||||
gap: 15px;
|
||||
flex-flow: column nowrap;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
stopPanner,
|
||||
setConvolverMainGain,
|
||||
setConvolverSendGain,
|
||||
setPitchShifter,
|
||||
} from '@renderer/plugins/player'
|
||||
|
||||
import { appSetting } from '@renderer/store/setting'
|
||||
|
@ -66,6 +67,9 @@ export default () => {
|
|||
setConvolver(buffer, appSetting['player.soundEffect.convolution.mainGain'] / 10, appSetting['player.soundEffect.convolution.sendGain'] / 10)
|
||||
})
|
||||
}
|
||||
if (appSetting['player.soundEffect.pitchShifter.playbackRate'] != 1) {
|
||||
setPitchShifter(appSetting['player.soundEffect.pitchShifter.playbackRate'])
|
||||
}
|
||||
|
||||
|
||||
watch(() => appSetting['player.soundEffect.panner.enable'], (enable) => {
|
||||
|
@ -139,6 +143,10 @@ export default () => {
|
|||
bfs.get('hz16000')!.gain.value = hz16000
|
||||
})
|
||||
|
||||
watch(() => appSetting['player.soundEffect.pitchShifter.playbackRate'], (playbackRate) => {
|
||||
setPitchShifter(playbackRate)
|
||||
})
|
||||
|
||||
|
||||
// window.key_event.on(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp)
|
||||
// window.key_event.on(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown)
|
||||
|
|
|
@ -38,12 +38,19 @@ export const convolutions = [
|
|||
{ name: 'feedback_spring', mainGain: 1.8, sendGain: 0.8, source: 'feedback-spring.wav' },
|
||||
// { name: 'tim_omni_rear_blend', mainGain: 1.8, sendGain: 0.8, source: 'tim-omni-rear-blend.wav' },
|
||||
] as const
|
||||
// 半音
|
||||
export const semitones = [-1.5, -1, -0.5, 0.5, 1, 1.5, 2, 2.5, 3, 3.5] as const
|
||||
|
||||
let convolver: ConvolverNode
|
||||
let convolverSourceGainNode: GainNode
|
||||
let convolverOutputGainNode: GainNode
|
||||
let convolverDynamicsCompressor: DynamicsCompressorNode
|
||||
let gainNode: GainNode
|
||||
let panner: PannerNode
|
||||
let pitchShifterNode: AudioWorkletNode
|
||||
let pitchShifterNodeAudioParam: AudioParam
|
||||
let pitchShifterNodeLoadStatus: 'none' | 'loading' | 'unconnect' | 'connected' = 'none'
|
||||
let pitchShifterNodeTempValue = 1
|
||||
export const soundR = 0.5
|
||||
|
||||
|
||||
|
@ -101,26 +108,21 @@ const initAdvancedAudioFeatures = () => {
|
|||
if (audioContext) return
|
||||
if (!audio) throw new Error('audio not defined')
|
||||
audioContext = new window.AudioContext()
|
||||
mediaSource = audioContext.createMediaElementSource(audio)
|
||||
|
||||
initAnalyser()
|
||||
mediaSource.connect(analyser)
|
||||
// analyser.connect(audioContext.destination)
|
||||
|
||||
initBiquadFilter()
|
||||
analyser.connect(biquads.get(`hz${freqs[0]}`) as BiquadFilterNode)
|
||||
|
||||
initConvolver()
|
||||
initPanner()
|
||||
initGain()
|
||||
// source -> analyser -> biquadFilter -> [(convolver & convolverSource)->convolverDynamicsCompressor] -> panner -> gain
|
||||
mediaSource = audioContext.createMediaElementSource(audio)
|
||||
mediaSource.connect(analyser)
|
||||
analyser.connect(biquads.get(`hz${freqs[0]}`) as BiquadFilterNode)
|
||||
const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1) as Freqs}`) as BiquadFilterNode)
|
||||
lastBiquadFilter.connect(convolverSourceGainNode)
|
||||
lastBiquadFilter.connect(convolver)
|
||||
|
||||
initPanner()
|
||||
convolverDynamicsCompressor.connect(panner)
|
||||
|
||||
initGain()
|
||||
panner.connect(gainNode)
|
||||
|
||||
gainNode.connect(audioContext.destination)
|
||||
}
|
||||
|
||||
|
@ -217,6 +219,70 @@ export const startPanner = () => {
|
|||
}, pannerInfo.speed * 10)
|
||||
}
|
||||
|
||||
const loadPitchShifterNode = () => {
|
||||
pitchShifterNodeLoadStatus = 'loading'
|
||||
initAdvancedAudioFeatures()
|
||||
// source -> analyser -> biquadFilter -> audioWorklet(pitch shifter) -> [(convolver & convolverSource)->convolverDynamicsCompressor] -> panner -> gain
|
||||
void audioContext.audioWorklet.addModule(new URL(
|
||||
/* webpackChunkName: 'pitch_shifter.audioWorklet' */
|
||||
'@renderer/utils/pitch-shifter/phase-vocoder.js',
|
||||
import.meta.url,
|
||||
)).then(() => {
|
||||
console.log('pitch shifter audio worklet loaded')
|
||||
pitchShifterNode = new AudioWorkletNode(audioContext, 'phase-vocoder-processor')
|
||||
let audioParam = pitchShifterNode.parameters.get('pitchFactor')
|
||||
if (!audioParam) return
|
||||
pitchShifterNodeAudioParam = audioParam
|
||||
pitchShifterNodeLoadStatus = 'unconnect'
|
||||
if (pitchShifterNodeTempValue == 1) return
|
||||
|
||||
connectPitchShifterNode()
|
||||
})
|
||||
}
|
||||
const connectPitchShifterNode = () => {
|
||||
const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1) as Freqs}`) as BiquadFilterNode)
|
||||
lastBiquadFilter.disconnect()
|
||||
lastBiquadFilter.connect(pitchShifterNode)
|
||||
|
||||
pitchShifterNode.connect(convolver)
|
||||
pitchShifterNode.connect(convolverSourceGainNode)
|
||||
// convolverDynamicsCompressor.disconnect(panner)
|
||||
// convolverDynamicsCompressor.connect(pitchShifterNode)
|
||||
// pitchShifterNode.connect(panner)
|
||||
pitchShifterNodeLoadStatus = 'connected'
|
||||
pitchShifterNodeAudioParam.value = pitchShifterNodeTempValue
|
||||
}
|
||||
// const disconnectPitchShifterNode = () => {
|
||||
// const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1) as Freqs}`) as BiquadFilterNode)
|
||||
// lastBiquadFilter.disconnect()
|
||||
// lastBiquadFilter.connect(convolver)
|
||||
// lastBiquadFilter.connect(convolverSourceGainNode)
|
||||
// pitchShifterNodeLoadStatus = 'unconnect'
|
||||
// }
|
||||
export const setPitchShifter = (val: number) => {
|
||||
// console.log('setPitchShifter', val)
|
||||
pitchShifterNodeTempValue = val
|
||||
// if (val == 1 && pitchShifterNodeLoadStatus == 'connected') {
|
||||
// disconnectPitchShifterNode()
|
||||
// return
|
||||
// }
|
||||
switch (pitchShifterNodeLoadStatus) {
|
||||
case 'loading':
|
||||
break
|
||||
case 'none':
|
||||
loadPitchShifterNode()
|
||||
break
|
||||
case 'connected':
|
||||
// a: 1 = 半音
|
||||
// value = 2 ** (a / 12)
|
||||
pitchShifterNodeAudioParam.value = val
|
||||
break
|
||||
case 'unconnect':
|
||||
connectPitchShifterNode()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export const hasInitedAdvancedAudioFeatures = (): boolean => audioContext != null
|
||||
|
||||
export const setResource = (src: string) => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { reactive, toRaw } from '@common/utils/vueTools'
|
||||
import { getUserSoundEffectConvolutionPresetList, getUserSoundEffectEQPresetList, saveUserSoundEffectConvolutionPresetList, saveUserSoundEffectEQPresetList } from '@renderer/utils/ipc'
|
||||
import { getUserSoundEffectConvolutionPresetList, getUserSoundEffectEQPresetList, getUserSoundEffectPitchShifterPresetList, saveUserSoundEffectConvolutionPresetList, saveUserSoundEffectEQPresetList, saveUserSoundEffectPitchShifterPresetList } from '@renderer/utils/ipc'
|
||||
|
||||
let userEqPresetList: LX.SoundEffect.EQPreset[] | null = null
|
||||
|
||||
|
@ -54,3 +54,30 @@ export const removeUserConvolutionPreset = async(id: string) => {
|
|||
userConvolutionPresetList.splice(index, 1)
|
||||
saveUserSoundEffectConvolutionPresetList(toRaw(userConvolutionPresetList))
|
||||
}
|
||||
|
||||
|
||||
let userPitchShifterPresetList: LX.SoundEffect.PitchShifterPreset[] | null = null
|
||||
export const getUserPitchShifterPresetList = async() => {
|
||||
if (userEqPresetList == null) {
|
||||
userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList())
|
||||
}
|
||||
return userPitchShifterPresetList
|
||||
}
|
||||
export const saveUserPitchShifterPreset = async(preset: LX.SoundEffect.PitchShifterPreset) => {
|
||||
if (userPitchShifterPresetList == null) {
|
||||
userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList())
|
||||
}
|
||||
const target = userPitchShifterPresetList.find(p => p.id == preset.id)
|
||||
if (target) Object.assign(target, preset)
|
||||
else userPitchShifterPresetList.push(preset)
|
||||
saveUserSoundEffectPitchShifterPresetList(toRaw(userPitchShifterPresetList))
|
||||
}
|
||||
export const removeUserPitchShifterPreset = async(id: string) => {
|
||||
if (userPitchShifterPresetList == null) {
|
||||
userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList())
|
||||
}
|
||||
const index = userPitchShifterPresetList.findIndex(p => p.id == id)
|
||||
if (index < 0) return
|
||||
userPitchShifterPresetList.splice(index, 1)
|
||||
saveUserSoundEffectPitchShifterPresetList(toRaw(userPitchShifterPresetList))
|
||||
}
|
||||
|
|
|
@ -307,6 +307,13 @@ export const saveUserSoundEffectConvolutionPresetList = (list: LX.SoundEffect.Co
|
|||
rendererSend<LX.SoundEffect.ConvolutionPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_convolution_preset, list)
|
||||
}
|
||||
|
||||
export const getUserSoundEffectPitchShifterPresetList = async() => {
|
||||
return await rendererInvoke<LX.SoundEffect.PitchShifterPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_pitch_shifter_preset)
|
||||
}
|
||||
|
||||
export const saveUserSoundEffectPitchShifterPresetList = (list: LX.SoundEffect.PitchShifterPreset[]) => {
|
||||
rendererSend<LX.SoundEffect.PitchShifterPreset[]>(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_pitch_shifter_preset, list)
|
||||
}
|
||||
|
||||
export const allHotKeys = markRaw({
|
||||
local: [
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
const WEBAUDIO_BLOCK_SIZE = 128
|
||||
|
||||
/** Overlap-Add Node */
|
||||
class OLAProcessor extends window.AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
|
||||
this.nbInputs = options.numberOfInputs
|
||||
this.nbOutputs = options.numberOfOutputs
|
||||
|
||||
this.blockSize = options.processorOptions.blockSize
|
||||
// TODO for now, the only support hop size is the size of a web audio block
|
||||
this.hopSize = WEBAUDIO_BLOCK_SIZE
|
||||
|
||||
this.nbOverlaps = this.blockSize / this.hopSize
|
||||
|
||||
// pre-allocate input buffers (will be reallocated if needed)
|
||||
this.inputBuffers = new Array(this.nbInputs)
|
||||
this.inputBuffersHead = new Array(this.nbInputs)
|
||||
this.inputBuffersToSend = new Array(this.nbInputs)
|
||||
// default to 1 channel per input until we know more
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
this.allocateInputChannels(i, 1)
|
||||
}
|
||||
// pre-allocate input buffers (will be reallocated if needed)
|
||||
this.outputBuffers = new Array(this.nbOutputs)
|
||||
this.outputBuffersToRetrieve = new Array(this.nbOutputs)
|
||||
// default to 1 channel per output until we know more
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
this.allocateOutputChannels(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles dynamic reallocation of input/output channels buffer
|
||||
(channel numbers may vary during lifecycle) **/
|
||||
reallocateChannelsIfNeeded(inputs, outputs) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
let nbChannels = inputs[i].length
|
||||
if (nbChannels != this.inputBuffers[i].length) {
|
||||
this.allocateInputChannels(i, nbChannels)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
let nbChannels = outputs[i].length
|
||||
if (nbChannels != this.outputBuffers[i].length) {
|
||||
this.allocateOutputChannels(i, nbChannels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allocateInputChannels(inputIndex, nbChannels) {
|
||||
// allocate input buffers
|
||||
|
||||
this.inputBuffers[inputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE)
|
||||
this.inputBuffers[inputIndex][i].fill(0)
|
||||
}
|
||||
|
||||
// allocate input buffers to send and head pointers to copy from
|
||||
// (cannot directly send a pointer/subarray because input may be modified)
|
||||
this.inputBuffersHead[inputIndex] = new Array(nbChannels)
|
||||
this.inputBuffersToSend[inputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.inputBuffersHead[inputIndex][i] = this.inputBuffers[inputIndex][i].subarray(0, this.blockSize)
|
||||
this.inputBuffersToSend[inputIndex][i] = new Float32Array(this.blockSize)
|
||||
}
|
||||
}
|
||||
|
||||
allocateOutputChannels(outputIndex, nbChannels) {
|
||||
// allocate output buffers
|
||||
this.outputBuffers[outputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.outputBuffers[outputIndex][i] = new Float32Array(this.blockSize)
|
||||
this.outputBuffers[outputIndex][i].fill(0)
|
||||
}
|
||||
|
||||
// allocate output buffers to retrieve
|
||||
// (cannot send a pointer/subarray because new output has to be add to exising output)
|
||||
this.outputBuffersToRetrieve[outputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.outputBuffersToRetrieve[outputIndex][i] = new Float32Array(this.blockSize)
|
||||
this.outputBuffersToRetrieve[outputIndex][i].fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read next web audio block to input buffers **/
|
||||
readInputs(inputs) {
|
||||
// when playback is paused, we may stop receiving new samples
|
||||
if (inputs[0].length && inputs[0][0].length == 0) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffers[i][j].fill(0, this.blockSize)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
let webAudioBlock = inputs[i][j]
|
||||
this.inputBuffers[i][j].set(webAudioBlock, this.blockSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write next web audio block from output buffers **/
|
||||
writeOutputs(outputs) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
let webAudioBlock = this.outputBuffers[i][j].subarray(0, WEBAUDIO_BLOCK_SIZE)
|
||||
outputs[i][j].set(webAudioBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift left content of input buffers to receive new web audio block **/
|
||||
shiftInputBuffers() {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift left content of output buffers to receive new web audio block **/
|
||||
shiftOutputBuffers() {
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
for (let j = 0; j < this.outputBuffers[i].length; j++) {
|
||||
this.outputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE)
|
||||
this.outputBuffers[i][j].subarray(this.blockSize - WEBAUDIO_BLOCK_SIZE).fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy contents of input buffers to buffer actually sent to process **/
|
||||
prepareInputBuffersToSend() {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffersToSend[i][j].set(this.inputBuffersHead[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add contents of output buffers just processed to output buffers **/
|
||||
handleOutputBuffersToRetrieve() {
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
for (let j = 0; j < this.outputBuffers[i].length; j++) {
|
||||
for (let k = 0; k < this.blockSize; k++) {
|
||||
this.outputBuffers[i][j][k] += this.outputBuffersToRetrieve[i][j][k] / this.nbOverlaps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs, params) {
|
||||
this.reallocateChannelsIfNeeded(inputs, outputs)
|
||||
|
||||
this.readInputs(inputs)
|
||||
this.shiftInputBuffers()
|
||||
this.prepareInputBuffersToSend()
|
||||
this.processOLA(this.inputBuffersToSend, this.outputBuffersToRetrieve, params)
|
||||
this.handleOutputBuffersToRetrieve()
|
||||
this.writeOutputs(outputs)
|
||||
this.shiftOutputBuffers()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
processOLA(inputs, outputs, params) {
|
||||
console.assert(false, 'Not overriden')
|
||||
}
|
||||
}
|
||||
|
||||
export default OLAProcessor
|
|
@ -0,0 +1,850 @@
|
|||
// https://github.com/olvb/phaze
|
||||
// https://github.com/indutny/fft.js
|
||||
// import OLAProcessor from './ola-processor'
|
||||
|
||||
function FFT(size) {
|
||||
this.size = size | 0
|
||||
if (this.size <= 1 || (this.size & (this.size - 1)) !== 0) { throw new Error('FFT size must be a power of two and bigger than 1') }
|
||||
|
||||
this._csize = size << 1
|
||||
|
||||
// NOTE: Use of `var` is intentional for old V8 versions
|
||||
let table = new Array(this.size * 2)
|
||||
for (let i = 0; i < table.length; i += 2) {
|
||||
const angle = Math.PI * i / this.size
|
||||
table[i] = Math.cos(angle)
|
||||
table[i + 1] = -Math.sin(angle)
|
||||
}
|
||||
this.table = table
|
||||
|
||||
// Find size's power of two
|
||||
let power = 0
|
||||
for (let t = 1; this.size > t; t <<= 1) { power++ }
|
||||
|
||||
// Calculate initial step's width:
|
||||
// * If we are full radix-4 - it is 2x smaller to give inital len=8
|
||||
// * Otherwise it is the same as `power` to give len=4
|
||||
this._width = power % 2 === 0 ? power - 1 : power
|
||||
|
||||
// Pre-compute bit-reversal patterns
|
||||
this._bitrev = new Array(1 << this._width)
|
||||
for (let j = 0; j < this._bitrev.length; j++) {
|
||||
this._bitrev[j] = 0
|
||||
for (let shift = 0; shift < this._width; shift += 2) {
|
||||
let revShift = this._width - shift - 2
|
||||
this._bitrev[j] |= ((j >>> shift) & 3) << revShift
|
||||
}
|
||||
}
|
||||
|
||||
this._out = null
|
||||
this._data = null
|
||||
this._inv = 0
|
||||
}
|
||||
|
||||
FFT.prototype.fromComplexArray = function fromComplexArray(complex, storage) {
|
||||
let res = storage || new Array(complex.length >>> 1)
|
||||
for (let i = 0; i < complex.length; i += 2) { res[i >>> 1] = complex[i] }
|
||||
return res
|
||||
}
|
||||
|
||||
FFT.prototype.createComplexArray = function createComplexArray() {
|
||||
const res = new Array(this._csize)
|
||||
for (let i = 0; i < res.length; i++) { res[i] = 0 }
|
||||
return res
|
||||
}
|
||||
|
||||
FFT.prototype.toComplexArray = function toComplexArray(input, storage) {
|
||||
let res = storage || this.createComplexArray()
|
||||
for (let i = 0; i < res.length; i += 2) {
|
||||
res[i] = input[i >>> 1]
|
||||
res[i + 1] = 0
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
FFT.prototype.completeSpectrum = function completeSpectrum(spectrum) {
|
||||
let size = this._csize
|
||||
let half = size >>> 1
|
||||
for (let i = 2; i < half; i += 2) {
|
||||
spectrum[size - i] = spectrum[i]
|
||||
spectrum[size - i + 1] = -spectrum[i + 1]
|
||||
}
|
||||
}
|
||||
|
||||
FFT.prototype.transform = function transform(out, data) {
|
||||
if (out === data) { throw new Error('Input and output buffers must be different') }
|
||||
|
||||
this._out = out
|
||||
this._data = data
|
||||
this._inv = 0
|
||||
this._transform4()
|
||||
this._out = null
|
||||
this._data = null
|
||||
}
|
||||
|
||||
FFT.prototype.realTransform = function realTransform(out, data) {
|
||||
if (out === data) { throw new Error('Input and output buffers must be different') }
|
||||
|
||||
this._out = out
|
||||
this._data = data
|
||||
this._inv = 0
|
||||
this._realTransform4()
|
||||
this._out = null
|
||||
this._data = null
|
||||
}
|
||||
|
||||
FFT.prototype.inverseTransform = function inverseTransform(out, data) {
|
||||
if (out === data) { throw new Error('Input and output buffers must be different') }
|
||||
|
||||
this._out = out
|
||||
this._data = data
|
||||
this._inv = 1
|
||||
this._transform4()
|
||||
for (let i = 0; i < out.length; i++) { out[i] /= this.size }
|
||||
this._out = null
|
||||
this._data = null
|
||||
}
|
||||
|
||||
// radix-4 implementation
|
||||
//
|
||||
// NOTE: Uses of `var` are intentional for older V8 version that do not
|
||||
// support both `let compound assignments` and `const phi`
|
||||
FFT.prototype._transform4 = function _transform4() {
|
||||
let out = this._out
|
||||
let size = this._csize
|
||||
|
||||
// Initial step (permute and transform)
|
||||
let width = this._width
|
||||
let step = 1 << width
|
||||
let len = (size / step) << 1
|
||||
|
||||
let outOff
|
||||
let t
|
||||
let bitrev = this._bitrev
|
||||
if (len === 4) {
|
||||
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
|
||||
const off = bitrev[t]
|
||||
this._singleTransform2(outOff, off, step)
|
||||
}
|
||||
} else {
|
||||
// len === 8
|
||||
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
|
||||
const off = bitrev[t]
|
||||
this._singleTransform4(outOff, off, step)
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through steps in decreasing order
|
||||
let inv = this._inv ? -1 : 1
|
||||
let table = this.table
|
||||
for (step >>= 2; step >= 2; step >>= 2) {
|
||||
len = (size / step) << 1
|
||||
let quarterLen = len >>> 2
|
||||
|
||||
// Loop through offsets in the data
|
||||
for (outOff = 0; outOff < size; outOff += len) {
|
||||
// Full case
|
||||
let limit = outOff + quarterLen
|
||||
for (let i = outOff, k = 0; i < limit; i += 2, k += step) {
|
||||
const A = i
|
||||
const B = A + quarterLen
|
||||
const C = B + quarterLen
|
||||
const D = C + quarterLen
|
||||
|
||||
// Original values
|
||||
const Ar = out[A]
|
||||
const Ai = out[A + 1]
|
||||
const Br = out[B]
|
||||
const Bi = out[B + 1]
|
||||
const Cr = out[C]
|
||||
const Ci = out[C + 1]
|
||||
const Dr = out[D]
|
||||
const Di = out[D + 1]
|
||||
|
||||
// Middle values
|
||||
const MAr = Ar
|
||||
const MAi = Ai
|
||||
|
||||
const tableBr = table[k]
|
||||
const tableBi = inv * table[k + 1]
|
||||
const MBr = Br * tableBr - Bi * tableBi
|
||||
const MBi = Br * tableBi + Bi * tableBr
|
||||
|
||||
const tableCr = table[2 * k]
|
||||
const tableCi = inv * table[2 * k + 1]
|
||||
const MCr = Cr * tableCr - Ci * tableCi
|
||||
const MCi = Cr * tableCi + Ci * tableCr
|
||||
|
||||
const tableDr = table[3 * k]
|
||||
const tableDi = inv * table[3 * k + 1]
|
||||
const MDr = Dr * tableDr - Di * tableDi
|
||||
const MDi = Dr * tableDi + Di * tableDr
|
||||
|
||||
// Pre-Final values
|
||||
const T0r = MAr + MCr
|
||||
const T0i = MAi + MCi
|
||||
const T1r = MAr - MCr
|
||||
const T1i = MAi - MCi
|
||||
const T2r = MBr + MDr
|
||||
const T2i = MBi + MDi
|
||||
const T3r = inv * (MBr - MDr)
|
||||
const T3i = inv * (MBi - MDi)
|
||||
|
||||
// Final values
|
||||
const FAr = T0r + T2r
|
||||
const FAi = T0i + T2i
|
||||
|
||||
const FCr = T0r - T2r
|
||||
const FCi = T0i - T2i
|
||||
|
||||
const FBr = T1r + T3i
|
||||
const FBi = T1i - T3r
|
||||
|
||||
const FDr = T1r - T3i
|
||||
const FDi = T1i + T3r
|
||||
|
||||
out[A] = FAr
|
||||
out[A + 1] = FAi
|
||||
out[B] = FBr
|
||||
out[B + 1] = FBi
|
||||
out[C] = FCr
|
||||
out[C + 1] = FCi
|
||||
out[D] = FDr
|
||||
out[D + 1] = FDi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// radix-2 implementation
|
||||
//
|
||||
// NOTE: Only called for len=4
|
||||
FFT.prototype._singleTransform2 = function _singleTransform2(outOff, off,
|
||||
step) {
|
||||
const out = this._out
|
||||
const data = this._data
|
||||
|
||||
const evenR = data[off]
|
||||
const evenI = data[off + 1]
|
||||
const oddR = data[off + step]
|
||||
const oddI = data[off + step + 1]
|
||||
|
||||
const leftR = evenR + oddR
|
||||
const leftI = evenI + oddI
|
||||
const rightR = evenR - oddR
|
||||
const rightI = evenI - oddI
|
||||
|
||||
out[outOff] = leftR
|
||||
out[outOff + 1] = leftI
|
||||
out[outOff + 2] = rightR
|
||||
out[outOff + 3] = rightI
|
||||
}
|
||||
|
||||
// radix-4
|
||||
//
|
||||
// NOTE: Only called for len=8
|
||||
FFT.prototype._singleTransform4 = function _singleTransform4(outOff, off,
|
||||
step) {
|
||||
const out = this._out
|
||||
const data = this._data
|
||||
const inv = this._inv ? -1 : 1
|
||||
const step2 = step * 2
|
||||
const step3 = step * 3
|
||||
|
||||
// Original values
|
||||
const Ar = data[off]
|
||||
const Ai = data[off + 1]
|
||||
const Br = data[off + step]
|
||||
const Bi = data[off + step + 1]
|
||||
const Cr = data[off + step2]
|
||||
const Ci = data[off + step2 + 1]
|
||||
const Dr = data[off + step3]
|
||||
const Di = data[off + step3 + 1]
|
||||
|
||||
// Pre-Final values
|
||||
const T0r = Ar + Cr
|
||||
const T0i = Ai + Ci
|
||||
const T1r = Ar - Cr
|
||||
const T1i = Ai - Ci
|
||||
const T2r = Br + Dr
|
||||
const T2i = Bi + Di
|
||||
const T3r = inv * (Br - Dr)
|
||||
const T3i = inv * (Bi - Di)
|
||||
|
||||
// Final values
|
||||
const FAr = T0r + T2r
|
||||
const FAi = T0i + T2i
|
||||
|
||||
const FBr = T1r + T3i
|
||||
const FBi = T1i - T3r
|
||||
|
||||
const FCr = T0r - T2r
|
||||
const FCi = T0i - T2i
|
||||
|
||||
const FDr = T1r - T3i
|
||||
const FDi = T1i + T3r
|
||||
|
||||
out[outOff] = FAr
|
||||
out[outOff + 1] = FAi
|
||||
out[outOff + 2] = FBr
|
||||
out[outOff + 3] = FBi
|
||||
out[outOff + 4] = FCr
|
||||
out[outOff + 5] = FCi
|
||||
out[outOff + 6] = FDr
|
||||
out[outOff + 7] = FDi
|
||||
}
|
||||
|
||||
// Real input radix-4 implementation
|
||||
FFT.prototype._realTransform4 = function _realTransform4() {
|
||||
let out = this._out
|
||||
let size = this._csize
|
||||
|
||||
// Initial step (permute and transform)
|
||||
let width = this._width
|
||||
let step = 1 << width
|
||||
let len = (size / step) << 1
|
||||
|
||||
let outOff
|
||||
let t
|
||||
let bitrev = this._bitrev
|
||||
if (len === 4) {
|
||||
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
|
||||
const off = bitrev[t]
|
||||
this._singleRealTransform2(outOff, off >>> 1, step >>> 1)
|
||||
}
|
||||
} else {
|
||||
// len === 8
|
||||
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
|
||||
const off = bitrev[t]
|
||||
this._singleRealTransform4(outOff, off >>> 1, step >>> 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through steps in decreasing order
|
||||
let inv = this._inv ? -1 : 1
|
||||
let table = this.table
|
||||
for (step >>= 2; step >= 2; step >>= 2) {
|
||||
len = (size / step) << 1
|
||||
let halfLen = len >>> 1
|
||||
let quarterLen = halfLen >>> 1
|
||||
let hquarterLen = quarterLen >>> 1
|
||||
|
||||
// Loop through offsets in the data
|
||||
for (outOff = 0; outOff < size; outOff += len) {
|
||||
for (let i = 0, k = 0; i <= hquarterLen; i += 2, k += step) {
|
||||
let A = outOff + i
|
||||
let B = A + quarterLen
|
||||
let C = B + quarterLen
|
||||
let D = C + quarterLen
|
||||
|
||||
// Original values
|
||||
let Ar = out[A]
|
||||
let Ai = out[A + 1]
|
||||
let Br = out[B]
|
||||
let Bi = out[B + 1]
|
||||
let Cr = out[C]
|
||||
let Ci = out[C + 1]
|
||||
let Dr = out[D]
|
||||
let Di = out[D + 1]
|
||||
|
||||
// Middle values
|
||||
let MAr = Ar
|
||||
let MAi = Ai
|
||||
|
||||
let tableBr = table[k]
|
||||
let tableBi = inv * table[k + 1]
|
||||
let MBr = Br * tableBr - Bi * tableBi
|
||||
let MBi = Br * tableBi + Bi * tableBr
|
||||
|
||||
let tableCr = table[2 * k]
|
||||
let tableCi = inv * table[2 * k + 1]
|
||||
let MCr = Cr * tableCr - Ci * tableCi
|
||||
let MCi = Cr * tableCi + Ci * tableCr
|
||||
|
||||
let tableDr = table[3 * k]
|
||||
let tableDi = inv * table[3 * k + 1]
|
||||
let MDr = Dr * tableDr - Di * tableDi
|
||||
let MDi = Dr * tableDi + Di * tableDr
|
||||
|
||||
// Pre-Final values
|
||||
let T0r = MAr + MCr
|
||||
let T0i = MAi + MCi
|
||||
let T1r = MAr - MCr
|
||||
let T1i = MAi - MCi
|
||||
let T2r = MBr + MDr
|
||||
let T2i = MBi + MDi
|
||||
let T3r = inv * (MBr - MDr)
|
||||
let T3i = inv * (MBi - MDi)
|
||||
|
||||
// Final values
|
||||
let FAr = T0r + T2r
|
||||
let FAi = T0i + T2i
|
||||
|
||||
let FBr = T1r + T3i
|
||||
let FBi = T1i - T3r
|
||||
|
||||
out[A] = FAr
|
||||
out[A + 1] = FAi
|
||||
out[B] = FBr
|
||||
out[B + 1] = FBi
|
||||
|
||||
// Output final middle point
|
||||
if (i === 0) {
|
||||
let FCr = T0r - T2r
|
||||
let FCi = T0i - T2i
|
||||
out[C] = FCr
|
||||
out[C + 1] = FCi
|
||||
continue
|
||||
}
|
||||
|
||||
// Do not overwrite ourselves
|
||||
if (i === hquarterLen) { continue }
|
||||
|
||||
// In the flipped case:
|
||||
// MAi = -MAi
|
||||
// MBr=-MBi, MBi=-MBr
|
||||
// MCr=-MCr
|
||||
// MDr=MDi, MDi=MDr
|
||||
let ST0r = T1r
|
||||
let ST0i = -T1i
|
||||
let ST1r = T0r
|
||||
let ST1i = -T0i
|
||||
let ST2r = -inv * T3i
|
||||
let ST2i = -inv * T3r
|
||||
let ST3r = -inv * T2i
|
||||
let ST3i = -inv * T2r
|
||||
|
||||
let SFAr = ST0r + ST2r
|
||||
let SFAi = ST0i + ST2i
|
||||
|
||||
let SFBr = ST1r + ST3i
|
||||
let SFBi = ST1i - ST3r
|
||||
|
||||
let SA = outOff + quarterLen - i
|
||||
let SB = outOff + halfLen - i
|
||||
|
||||
out[SA] = SFAr
|
||||
out[SA + 1] = SFAi
|
||||
out[SB] = SFBr
|
||||
out[SB + 1] = SFBi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// radix-2 implementation
|
||||
//
|
||||
// NOTE: Only called for len=4
|
||||
FFT.prototype._singleRealTransform2 = function _singleRealTransform2(outOff,
|
||||
off,
|
||||
step) {
|
||||
const out = this._out
|
||||
const data = this._data
|
||||
|
||||
const evenR = data[off]
|
||||
const oddR = data[off + step]
|
||||
|
||||
const leftR = evenR + oddR
|
||||
const rightR = evenR - oddR
|
||||
|
||||
out[outOff] = leftR
|
||||
out[outOff + 1] = 0
|
||||
out[outOff + 2] = rightR
|
||||
out[outOff + 3] = 0
|
||||
}
|
||||
|
||||
// radix-4
|
||||
//
|
||||
// NOTE: Only called for len=8
|
||||
FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff,
|
||||
off,
|
||||
step) {
|
||||
const out = this._out
|
||||
const data = this._data
|
||||
const inv = this._inv ? -1 : 1
|
||||
const step2 = step * 2
|
||||
const step3 = step * 3
|
||||
|
||||
// Original values
|
||||
const Ar = data[off]
|
||||
const Br = data[off + step]
|
||||
const Cr = data[off + step2]
|
||||
const Dr = data[off + step3]
|
||||
|
||||
// Pre-Final values
|
||||
const T0r = Ar + Cr
|
||||
const T1r = Ar - Cr
|
||||
const T2r = Br + Dr
|
||||
const T3r = inv * (Br - Dr)
|
||||
|
||||
// Final values
|
||||
const FAr = T0r + T2r
|
||||
|
||||
const FBr = T1r
|
||||
const FBi = -T3r
|
||||
|
||||
const FCr = T0r - T2r
|
||||
|
||||
const FDr = T1r
|
||||
const FDi = T3r
|
||||
|
||||
out[outOff] = FAr
|
||||
out[outOff + 1] = 0
|
||||
out[outOff + 2] = FBr
|
||||
out[outOff + 3] = FBi
|
||||
out[outOff + 4] = FCr
|
||||
out[outOff + 5] = 0
|
||||
out[outOff + 6] = FDr
|
||||
out[outOff + 7] = FDi
|
||||
}
|
||||
|
||||
|
||||
const WEBAUDIO_BLOCK_SIZE = 128
|
||||
|
||||
/** Overlap-Add Node */
|
||||
// eslint-disable-next-line no-undef
|
||||
class OLAProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
|
||||
this.nbInputs = options.numberOfInputs
|
||||
this.nbOutputs = options.numberOfOutputs
|
||||
|
||||
this.blockSize = options.processorOptions.blockSize
|
||||
// TODO for now, the only support hop size is the size of a web audio block
|
||||
this.hopSize = WEBAUDIO_BLOCK_SIZE
|
||||
|
||||
this.nbOverlaps = this.blockSize / this.hopSize
|
||||
|
||||
// pre-allocate input buffers (will be reallocated if needed)
|
||||
this.inputBuffers = new Array(this.nbInputs)
|
||||
this.inputBuffersHead = new Array(this.nbInputs)
|
||||
this.inputBuffersToSend = new Array(this.nbInputs)
|
||||
// default to 1 channel per input until we know more
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
this.allocateInputChannels(i, 1)
|
||||
}
|
||||
// pre-allocate input buffers (will be reallocated if needed)
|
||||
this.outputBuffers = new Array(this.nbOutputs)
|
||||
this.outputBuffersToRetrieve = new Array(this.nbOutputs)
|
||||
// default to 1 channel per output until we know more
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
this.allocateOutputChannels(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles dynamic reallocation of input/output channels buffer
|
||||
(channel numbers may vary during lifecycle) **/
|
||||
reallocateChannelsIfNeeded(inputs, outputs) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
let nbChannels = inputs[i].length
|
||||
if (nbChannels != this.inputBuffers[i].length) {
|
||||
this.allocateInputChannels(i, nbChannels)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
let nbChannels = outputs[i].length
|
||||
if (nbChannels != this.outputBuffers[i].length) {
|
||||
this.allocateOutputChannels(i, nbChannels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allocateInputChannels(inputIndex, nbChannels) {
|
||||
// allocate input buffers
|
||||
|
||||
this.inputBuffers[inputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE)
|
||||
this.inputBuffers[inputIndex][i].fill(0)
|
||||
}
|
||||
|
||||
// allocate input buffers to send and head pointers to copy from
|
||||
// (cannot directly send a pointer/subarray because input may be modified)
|
||||
this.inputBuffersHead[inputIndex] = new Array(nbChannels)
|
||||
this.inputBuffersToSend[inputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.inputBuffersHead[inputIndex][i] = this.inputBuffers[inputIndex][i].subarray(0, this.blockSize)
|
||||
this.inputBuffersToSend[inputIndex][i] = new Float32Array(this.blockSize)
|
||||
}
|
||||
}
|
||||
|
||||
allocateOutputChannels(outputIndex, nbChannels) {
|
||||
// allocate output buffers
|
||||
this.outputBuffers[outputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.outputBuffers[outputIndex][i] = new Float32Array(this.blockSize)
|
||||
this.outputBuffers[outputIndex][i].fill(0)
|
||||
}
|
||||
|
||||
// allocate output buffers to retrieve
|
||||
// (cannot send a pointer/subarray because new output has to be add to exising output)
|
||||
this.outputBuffersToRetrieve[outputIndex] = new Array(nbChannels)
|
||||
for (let i = 0; i < nbChannels; i++) {
|
||||
this.outputBuffersToRetrieve[outputIndex][i] = new Float32Array(this.blockSize)
|
||||
this.outputBuffersToRetrieve[outputIndex][i].fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read next web audio block to input buffers **/
|
||||
readInputs(inputs) {
|
||||
// when playback is paused, we may stop receiving new samples
|
||||
if (inputs[0].length && inputs[0][0].length == 0) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffers[i][j].fill(0, this.blockSize)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
let webAudioBlock = inputs[i][j]
|
||||
this.inputBuffers[i][j].set(webAudioBlock, this.blockSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write next web audio block from output buffers **/
|
||||
writeOutputs(outputs) {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
let webAudioBlock = this.outputBuffers[i][j].subarray(0, WEBAUDIO_BLOCK_SIZE)
|
||||
outputs[i][j].set(webAudioBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift left content of input buffers to receive new web audio block **/
|
||||
shiftInputBuffers() {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift left content of output buffers to receive new web audio block **/
|
||||
shiftOutputBuffers() {
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
for (let j = 0; j < this.outputBuffers[i].length; j++) {
|
||||
this.outputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE)
|
||||
this.outputBuffers[i][j].subarray(this.blockSize - WEBAUDIO_BLOCK_SIZE).fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy contents of input buffers to buffer actually sent to process **/
|
||||
prepareInputBuffersToSend() {
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < this.inputBuffers[i].length; j++) {
|
||||
this.inputBuffersToSend[i][j].set(this.inputBuffersHead[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add contents of output buffers just processed to output buffers **/
|
||||
handleOutputBuffersToRetrieve() {
|
||||
for (let i = 0; i < this.nbOutputs; i++) {
|
||||
for (let j = 0; j < this.outputBuffers[i].length; j++) {
|
||||
for (let k = 0; k < this.blockSize; k++) {
|
||||
this.outputBuffers[i][j][k] += this.outputBuffersToRetrieve[i][j][k] / this.nbOverlaps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs, params) {
|
||||
this.reallocateChannelsIfNeeded(inputs, outputs)
|
||||
|
||||
this.readInputs(inputs)
|
||||
this.shiftInputBuffers()
|
||||
this.prepareInputBuffersToSend()
|
||||
this.processOLA(this.inputBuffersToSend, this.outputBuffersToRetrieve, params)
|
||||
this.handleOutputBuffersToRetrieve()
|
||||
this.writeOutputs(outputs)
|
||||
this.shiftOutputBuffers()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
processOLA(inputs, outputs, params) {
|
||||
console.assert(false, 'Not overriden')
|
||||
}
|
||||
}
|
||||
|
||||
const BUFFERED_BLOCK_SIZE = 4096
|
||||
|
||||
function genHannWindow(length) {
|
||||
let win = new Float32Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
win[i] = 0.8 * (1 - Math.cos(2 * Math.PI * i / length))
|
||||
}
|
||||
return win
|
||||
}
|
||||
|
||||
class PhaseVocoderProcessor extends OLAProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [{
|
||||
name: 'pitchFactor',
|
||||
defaultValue: 1.0,
|
||||
}]
|
||||
}
|
||||
|
||||
constructor(options) {
|
||||
options.processorOptions = {
|
||||
blockSize: BUFFERED_BLOCK_SIZE,
|
||||
}
|
||||
super(options)
|
||||
|
||||
this.fftSize = this.blockSize
|
||||
this.timeCursor = 0
|
||||
|
||||
this.hannWindow = genHannWindow(this.blockSize)
|
||||
|
||||
// prepare FFT and pre-allocate buffers
|
||||
this.fft = new FFT(this.fftSize)
|
||||
this.freqComplexBuffer = this.fft.createComplexArray()
|
||||
this.freqComplexBufferShifted = this.fft.createComplexArray()
|
||||
this.timeComplexBuffer = this.fft.createComplexArray()
|
||||
this.magnitudes = new Float32Array(this.fftSize / 2 + 1)
|
||||
this.peakIndexes = new Int32Array(this.magnitudes.length)
|
||||
this.nbPeaks = 0
|
||||
}
|
||||
|
||||
processOLA(inputs, outputs, parameters) {
|
||||
// no automation, take last value
|
||||
const pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1]
|
||||
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < inputs[i].length; j++) {
|
||||
// big assumption here: output is symetric to input
|
||||
let input = inputs[i][j]
|
||||
let output = outputs[i][j]
|
||||
|
||||
this.applyHannWindow(input)
|
||||
|
||||
this.fft.realTransform(this.freqComplexBuffer, input)
|
||||
|
||||
this.computeMagnitudes()
|
||||
this.findPeaks()
|
||||
this.shiftPeaks(pitchFactor)
|
||||
|
||||
this.fft.completeSpectrum(this.freqComplexBufferShifted)
|
||||
this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted)
|
||||
this.fft.fromComplexArray(this.timeComplexBuffer, output)
|
||||
|
||||
this.applyHannWindow(output)
|
||||
}
|
||||
}
|
||||
|
||||
this.timeCursor += this.hopSize
|
||||
}
|
||||
|
||||
/** Apply Hann window in-place */
|
||||
applyHannWindow(input) {
|
||||
for (let i = 0; i < this.blockSize; i++) {
|
||||
input[i] = input[i] * this.hannWindow[i]
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute squared magnitudes for peak finding **/
|
||||
computeMagnitudes() {
|
||||
let i = 0; let j = 0
|
||||
while (i < this.magnitudes.length) {
|
||||
let real = this.freqComplexBuffer[j]
|
||||
let imag = this.freqComplexBuffer[j + 1]
|
||||
// no need to sqrt for peak finding
|
||||
this.magnitudes[i] = real ** 2 + imag ** 2
|
||||
i += 1
|
||||
j += 2
|
||||
}
|
||||
}
|
||||
|
||||
/** Find peaks in spectrum magnitudes **/
|
||||
findPeaks() {
|
||||
this.nbPeaks = 0
|
||||
let i = 2
|
||||
let end = this.magnitudes.length - 2
|
||||
|
||||
while (i < end) {
|
||||
let mag = this.magnitudes[i]
|
||||
|
||||
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (this.magnitudes[i + 1] >= mag || this.magnitudes[i + 2] >= mag) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
this.peakIndexes[this.nbPeaks] = i
|
||||
this.nbPeaks++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift peaks and regions of influence by pitchFactor into new specturm */
|
||||
shiftPeaks(pitchFactor) {
|
||||
// zero-fill new spectrum
|
||||
this.freqComplexBufferShifted.fill(0)
|
||||
|
||||
for (let i = 0; i < this.nbPeaks; i++) {
|
||||
let peakIndex = this.peakIndexes[i]
|
||||
let peakIndexShifted = Math.round(peakIndex * pitchFactor)
|
||||
|
||||
if (peakIndexShifted > this.magnitudes.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// find region of influence
|
||||
let startIndex = 0
|
||||
let endIndex = this.fftSize
|
||||
if (i > 0) {
|
||||
let peakIndexBefore = this.peakIndexes[i - 1]
|
||||
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2)
|
||||
}
|
||||
if (i < this.nbPeaks - 1) {
|
||||
let peakIndexAfter = this.peakIndexes[i + 1]
|
||||
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2)
|
||||
}
|
||||
|
||||
// shift whole region of influence around peak to shifted peak
|
||||
let startOffset = startIndex - peakIndex
|
||||
let endOffset = endIndex - peakIndex
|
||||
for (let j = startOffset; j < endOffset; j++) {
|
||||
let binIndex = peakIndex + j
|
||||
let binIndexShifted = peakIndexShifted + j
|
||||
|
||||
if (binIndexShifted >= this.magnitudes.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// apply phase correction
|
||||
let omegaDelta = 2 * Math.PI * (binIndexShifted - binIndex) / this.fftSize
|
||||
let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor)
|
||||
let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor)
|
||||
|
||||
let indexReal = binIndex * 2
|
||||
let indexImag = indexReal + 1
|
||||
let valueReal = this.freqComplexBuffer[indexReal]
|
||||
let valueImag = this.freqComplexBuffer[indexImag]
|
||||
|
||||
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag
|
||||
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal
|
||||
|
||||
let indexShiftedReal = binIndexShifted * 2
|
||||
let indexShiftedImag = indexShiftedReal + 1
|
||||
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal
|
||||
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor)
|
||||
|
Loading…
Reference in New Issue