添加音调升降调节

pull/1397/head
lyswhut 2023-05-31 21:40:25 +08:00
parent 677c3f16e4
commit 40997c34ff
21 changed files with 1435 additions and 20 deletions

View File

@ -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?$/,

View File

@ -1,6 +1,6 @@
### 新增
- 新增音效设置实验性功能支持10段均衡器设置、内置的一些环境混响音效、3D立体环绕音效
- 新增音效设置实验性功能支持10段均衡器设置、内置的一些环境混响音效、音调升降调节、3D立体环绕音效
- 播放速率设置面板新增是否音调补偿设置,在调整播放速率后,可以选择是否启用音调补偿,默认启用
### 修复

View File

@ -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,

View File

@ -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',

View File

@ -248,6 +248,11 @@ declare global {
*/
'player.soundEffect.panner.speed': number
/**
*
*/
'player.soundEffect.pitchShifter.playbackRate': number
/**
*
*/

View File

@ -21,5 +21,10 @@ declare namespace LX {
mainGain: number
sendGain: number
}
interface PitchShifterPreset {
id: string
name: string
playbackRate: number
}
}
}

View File

@ -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",

View File

@ -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": "静音",

View File

@ -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": "靜音",

View File

@ -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)
})
}

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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) => {

View File

@ -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))
}

View File

@ -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: [

View File

@ -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

View File

@ -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)