音频处理中的音频工作线程:功能与应用
1. 声道平移与音频工作线程节点选项
在音频处理中,声道平移是一项重要的操作。可以将平移值
p
转换为扬声器之间的位置。对于声道
i
,以下代码行:
output[i][j]=input[0][j]*Math.cos((position - i)*Math.PI/2)
仅应用于代表最接近扬声器的两个声道。这与
StereoPannerNode
对单声道输入的等功率平移效果相同,其中这两个声道代表左右扬声器。
在全局作用域中,需要指定音频工作线程节点的一些选项,这些选项继承自音频节点类。例如:
-
channelCount
设置为 5,表示有五个扬声器。
-
channelCountMode
设置为
'explicit'
,确保实际声道数设置为
channelCount
。
-
channelInterpretation
设置为
'discrete'
,避免应用上混和下混规则,强制输出为五声道。
使用
channelSplitter
可以只监听单个扬声器的输出。而且,这些选项可以动态更改,无需像音频参数那样使用
get
方法。例如,将扬声器数量重置为 2 以实现立体声输出,可使用
panX.channelCount = 2
。
2. 支持音频参数:增益工作线程
AudioWorkletNode
支持音频参数,但访问这些参数的语法略有不同。需要在处理器类的静态 getter
parameterDescriptors
中定义
AudioWorkletNode
的参数。
get
将对象属性绑定到一个函数,该函数在查找该属性时被调用,并返回一个
AudioParam
对象数组。这些参数作为
parameters
对象传递给
process()
方法。
音频参数具有名称、自动化速率、默认值、最小值和最大值。自动化速率有两种取值:
-
'a-rate'
参数每个样本都可以更改,作为一个值数组访问,每个样本对应一个值,这是默认值。
-
'k-rate'
参数每个块只更改一次,参数数组只有一个条目,用于块中的每个样本。
以下是处理增益参数的
process()
函数示例:
if (gain.length === 1) {
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = inputs[0][i][j] * parameters.gain[0];
}
}
} else {
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = inputs[0][i][j] * parameters.gain[j];
}
}
}
在
gain.html
和
gainworklet.js
中,展示了如何为噪声源提供增益控制:
<button onclick=context.resume()>Start</button>
<p>Gain</p>
<input type=range min=0 max=1 value=0.1 step=0.01 id=Gain>
<span id=GainLabel></span>
<script>
let context = new AudioContext()
GainLabel.innerHTML = Gain.value
context.audioWorklet.addModule('gainworklet.js').then(() => {
let myNoise = new AudioWorkletNode(context, 'noise-generator')
let myGain = new AudioWorkletNode(context, 'gain-processor', { parameterData: { gain: 0.1 } })
Gain.oninput = function () {
myGain.parameters.get('gain').value = this.value
GainLabel.innerHTML = this.value
}
myNoise.connect(myGain)
myGain.connect(context.destination)
})
</script>
registerProcessor('noise-generator', class extends AudioWorkletProcessor {
process(inputs, outputs) {
let output = outputs[0][0]
for (let i = 0; i < output.length; ++i) output[i] = 2 * Math.random() - 1
return true
}
})
registerProcessor('gain-processor', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'gain', defaultValue: 0.1 }]
}
process(inputs, outputs, parameters) {
const input = inputs[0], output = outputs[0]
if (parameters.gain.length === 1) {
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = inputs[0][i][j] * parameters.gain[0]
}
}
} else {
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = inputs[0][i][j] * parameters.gain[j]
}
}
}
return true
}
})
3. 存储内部变量:平滑滤波器工作线程
音频工作线程需要能够在运行时存储内部变量值。以指数移动平均滤波器(也称为平滑滤波器或单极点移动平均滤波器)为例,其输出公式为:
[y[n] = \alpha y[n - 1] + (1 - \alpha)x[n]]
其中,(\alpha) 是相邻样本之间的衰减量。例如,当 (\alpha = 0.75) 时,输出信号中每个样本的值是前一个输出的四分之三加上新输入的四分之一。(\alpha) 值越高,衰减越慢。
在
smoothing.html
和
smoothingWorklet.js
中,展示了如何对噪声源应用指数平滑:
<button onclick='context.resume()'>Start</button>
<p>Time constant (ms)</p>
<input type='range' min=0 max=10 value=1 step='any' id='Filter'>
<span id='FilterLabel'></span>
<script>
let context = new AudioContext()
FilterLabel.innerHTML = Filter.value
Promise.all([
context.audioWorklet.addModule('noiseWorklet.js'),
context.audioWorklet.addModule('smoothingWorklet.js')
]).then(() => {
let myNoise = new AudioWorkletNode(context, 'noise-generator')
let myFilter = new AudioWorkletNode(context, 'smoothing-filter')
Filter.oninput = function () {
myFilter.parameters.get('timeConstant').value = this.value
FilterLabel.innerHTML = this.value
}
myNoise.connect(myFilter).connect(context.destination)
})
</script>
registerProcessor('smoothing-filter', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'timeConstant', defaultValue: 1, minValue: 0 }]
}
constructor() {
super()
this.lastOut = 0
}
process(inputs, outputs, parameters) {
let alpha = Math.exp(-1 / (parameters.timeConstant[0] * sampleRate / 1000))
for (let i = 0; i < outputs[0].length; ++i) {
for (let j = 0; j < outputs[0][i].length; ++j) {
outputs[0][i][j] = inputs[0][i][j] * (1 - alpha) + alpha * this.lastOut
this.lastOut = outputs[0][i][j]
}
}
return true
}
})
在这个例子中,
noise-generator
和
smoothing-filter
两个音频工作线程处理器分别位于不同的文件中,使用
Promise.all()
方法处理多个模块的加载。在平滑滤波器工作线程的处理器中,引入了构造函数,用于创建和初始化对象。使用
super
关键字访问和调用对象父类的函数,以便使用
this
来存储最后一个输出样本,并将其初始值设为 0。
以下是一个简单的流程图,展示了平滑滤波器的处理过程:
graph TD;
A[输入信号] --> B[计算alpha];
B --> C[计算输出信号];
C --> D[更新lastOut];
D --> E[输出结果];
4.
processorOptions
:一阶滤波器工作线程
对于一些音频节点,如
BiquadFilter
的
'type'
属性、
WaveShaperNode
的
'curve'
Float32Array
、
ConvolverNode
的
'normalise'
布尔值和
'buffer'
AudioBuffer
等,这些不是音频参数的属性可以通过两种方式指定。
第一种方式是使用
processorOptions
,在创建节点时在全局作用域中定义,类似于参数的定义方式。例如,创建一个类似于
BiquadFilterNode
的音频工作线程节点,它接受音频参数来指定滤波器规格,同时还有一个
'type'
字符串参数来定义滤波器类型(低通或高通)。该滤波器是一个基本的一阶巴特沃斯滤波器,有一个音频参数
'frequency'
表示滤波器的截止频率 (f_0)。首先定义归一化频率 (\omega_0 = 2\pi f_0 / f_s),其中 (f_s) 是采样频率。
在
filterOptionsWorklet.html
和
filterOptionsWorklet.js
中,展示了如何使用
processorOptions
选择滤波器类型:
<button onclick=context.resume()>Start</button>
<select id=Type>
<option value=lowpass>Lowpass</option>
<option value=highpass>Highpass</option>
</select>
<p>Cut-off frequency</p>
<input type=range min=0 max=5000 value=1 step=any id=Cutoff>
<script>
let context = new AudioContext()
Promise.all([
context.audioWorklet.addModule('noiseWorklet.js'),
context.audioWorklet.addModule('filterOptionsWorklet.js')
]).then(() => {
let Noise = new AudioWorkletNode(context, 'noise-generator')
let Filter = new AudioWorkletNode(context, 'first-order-filter', { processorOptions: { type: 'lowpass' } })
Noise.connect(Filter).connect(context.destination)
Cutoff.oninput = function () {
Filter.parameters.get('frequency').value = this.value
}
Type.onchange = function () {
Noise.disconnect()
Filter.disconnect()
Filter = new AudioWorkletNode(context, 'first-order-filter', { processorOptions: { type: Type.value } })
Noise.connect(Filter).connect(context.destination)
}
})
</script>
registerProcessor('first-order-filter', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'frequency', defaultValue: 100, minValue: 0 }]
}
constructor(options) {
super()
this.lastOut = 0
this.lastIn = 0
this.type = options.processorOptions.type
}
process(inputs, outputs, parameters) {
let omega_0 = 2 * Math.PI * parameters.frequency[0] / sampleRate
let a, b
if (this.type == 'lowpass') {
a = [Math.tan(omega_0 / 2) + 1, Math.tan(omega_0 / 2) - 1]
b = [Math.tan(omega_0 / 2), Math.tan(omega_0 / 2)]
} else {
a = [Math.tan(omega_0 / 2) + 1, Math.tan(omega_0 / 2) - 1]
b = [1, -1]
}
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = (b[0] * inputs[0][i][j] + b[1] * this.lastIn - a[1] * this.lastOut) / a[0]
this.lastIn = inputs[0][i][j]
this.lastOut = outputs[0][i][j]
}
}
return true
}
})
然而,在音频工作线程处理器中,没有与
parameterDescriptors
对应的
processorOptions
方法。
processorOptions
仅作为构造函数的参数可用,只能用于初始化节点的某些方面,这限制了其用途。例如,不能仅通过更改
processorOptions
的值来从低通滤波器切换到高通滤波器,需要创建一个新的音频工作线程节点。
5. 消息传递:一阶滤波器工作线程
为了克服
processorOptions
的局限性,可以使用消息传递。音频工作线程处理器和对应的节点有配对的消息端口,节点可以向处理器发送消息,反之亦然。例如,
'type'
参数可以作为消息发送到处理器的消息端口,使用
Filter.port.postMessage(this.value)
。在音频工作线程处理器的构造函数中,使用
this.port.onmessage = (event) => this.type = event.data
来处理接收到的消息。
在
filterMessaging.html
和
filterMessagingWorklet.js
中,展示了如何使用消息端口选择滤波器类型:
<button onclick=context.resume()>Start</button>
<select id=Type>
<option value=lowpass>Lowpass</option>
<option value=highpass>Highpass</option>
</select>
<p>Cut-off frequency</p>
<input type=range min=0 max=5000 value=1 step=any id=Cutoff>
<script>
let context = new AudioContext()
Promise.all([
context.audioWorklet.addModule('noiseWorklet.js'),
context.audioWorklet.addModule('filterMessagingWorklet.js')
]).then(() => {
let Noise = new AudioWorkletNode(context, 'noise-generator')
let Filter = new AudioWorkletNode(context, 'first-order-filter')
Cutoff.oninput = () => {
Filter.parameters.get('frequency').value = Cutoff.value
}
Type.onchange = () => Filter.port.postMessage(Type.value)
Noise.connect(Filter).connect(context.destination)
})
</script>
registerProcessor('first-order-filter', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'frequency', defaultValue: 100, minValue: 0 }]
}
constructor() {
super()
this.lastOut = 0
this.lastIn = 0
this.type = 'lowpass'
this.port.onmessage = (event) => this.type = event.data
}
process(inputs, outputs, parameters) {
let omega_0 = 2 * Math.PI * parameters.frequency[0] / sampleRate
let a, b
if (this.type == 'lowpass') {
a = [Math.tan(omega_0 / 2) + 1, Math.tan(omega_0 / 2) - 1]
b = [Math.tan(omega_0 / 2), Math.tan(omega_0 / 2)]
} else {
a = [Math.tan(omega_0 / 2) + 1, Math.tan(omega_0 / 2) - 1]
b = [1, -1]
}
for (let i = 0; i < inputs[0].length; ++i) {
for (let j = 0; j < inputs[0][i].length; ++j) {
outputs[0][i][j] = (b[0] * inputs[0][i][j] + b[1] * this.lastIn - a[1] * this.lastOut) / a[0]
this.lastIn = inputs[0][i][j]
this.lastOut = outputs[0][i][j]
}
}
return true
}
})
消息端口是双向的,消息可以是任何类型的数据,如
ConvolverNode
的脉冲响应、
WaveshaperNode
的曲线数组、
OscillatorNode
的傅里叶级数值等。通过消息端口,可以创建能够复制、修改和扩展 Web Audio API 所有内置节点功能的音频工作线程。
以下是一个表格,总结了不同方法的特点:
| 方法 | 优点 | 缺点 |
| ---- | ---- | ---- |
|
processorOptions
| 简单直接,用于初始化节点 | 只能用于初始化,不能动态更改 |
| 消息传递 | 可动态更改参数,支持多种数据类型 | 实现相对复杂 |
6. 脉冲波和方波的重新实现
方波在音频处理中用于引入很多概念。在之前的实现中,方波可以通过
OscillatorNode
的默认类型、
PeriodicWave
设置傅里叶系数、处理
AudioBufferSourceNode
的输出或设置多个
OscillatorNode
的频率参数来创建。同时,还引入了脉冲波,它类似于方波,但有一个额外的参数——占空比,通过设置占空比为 0.5 可以得到方波。
然而,这些方法大多涉及使用傅里叶级数来近似方波,虽然可以避免混叠,但在某些应用中,可能更需要实际的方波,如作为测试信号、开关声音或警报声音。而且,默认的振荡器类型无法控制相位,虽然有一些解决方法,但最好能方便地设置初始相位。
使用音频工作线程可以解决这些问题。在
Pulse.html
和
PulseWorklet.js
中,展示了如何使用音频工作线程生成脉冲波振荡器:
<button onclick=context.resume()>Start</button><br>
<input type=range min=0 max=2000 value=440 step='any' id='Freq'>
Freq<br>
<input type=range min=0 max=1 value=0.5 step='any' id='Duty'>
Duty cycle<br>
<input type=number min=0 max=360 value=0 step='any' id='Phase'>
Phase
<script>
let context = new AudioContext()
context.audioWorklet.addModule('PulseWorklet.js').then(() => {
let pulse = new AudioWorkletNode(context, 'pulse-generator', { processorOptions: { phase: Phase.value } })
pulse.connect(context.destination)
Freq.oninput = () => {
pulse.parameters.get('frequency').value = Freq.value
}
Duty.oninput = () => {
pulse.parameters.get('duty').value = Duty.value
}
Phase.oninput = () => {
pulse.parameters.get('phase').value = Phase.value
}
})
</script>
registerProcessor('pulse-generator', class extends AudioWorkletProcessor {
constructor(options) {
super()
if (typeof options.processorOptions!== 'undefined') {
this.phase = 0
} else {
this.phase = options.processorOptions.phase / 360
}
}
static get parameterDescriptors() {
return [
{ name: 'frequency', defaultValue: 440 },
{ name: 'duty', defaultValue: 0.5, max: 1, min: 0 },
{ name: 'phase', defaultValue: 0, max: 360, min: 0 }
]
}
process(inputs, outputs, params) {
for (let channel = 0; channel < outputs[0].length; ++channel) {
if (params.frequency.length === 1) {
for (let i = 0; i < outputs[0][channel].length; ++i) {
if (this.phase > params.duty[0]) {
outputs[0][channel][i] = -1
} else {
outputs[0][channel][i] = 1
}
this.phase += params.frequency[0] / sampleRate
this.phase = this.phase - Math.floor(this.phase)
}
} else {
for (let i = 0; i < outputs[0][channel].length; ++i) {
if (this.phase > params.duty[i]) {
outputs[0][channel][i] = -1
} else {
outputs[0][channel][i] = 1
}
this.phase += params.frequency[i] / sampleRate
this.phase = this.phase - Math.floor(this.phase)
}
}
}
return true
}
})
在这个实现中,初始相位作为
processorOption
提供,并通过数字控件访问。在生成信号时跟踪相位,根据占空比确定信号的高低状态。如果占空比设置为 0.5,得到的波形是理想的方波;如果占空比接近 0,得到的波形是在恒定音频流中带有短周期脉冲的脉冲序列。
以下是一个流程图,展示了脉冲波生成的过程:
graph TD;
A[初始化相位] --> B[获取参数];
B --> C[判断频率参数长度];
C -- 长度为1 --> D[固定频率处理];
C -- 长度不为1 --> E[可变频率处理];
D --> F[更新相位];
E --> F;
F --> G[输出结果];
通过音频工作线程,可以实现更多复杂的音频处理功能,避免一些默认音频节点的局限性,为音频开发提供了更多的可能性。
音频处理中的音频工作线程:功能与应用
7. 位压碎器的改进设计
在传统的位压碎器设计中,通常需要存储一个波整形曲线来实现对音频信号的处理。然而,这种方法存在一些局限性,例如需要额外的存储空间来存储曲线,并且在处理不同的位深度时可能需要重新计算曲线。
使用音频工作线程可以避免这些问题。通过音频工作线程,可以直接对音频信号进行位操作,而无需存储波整形曲线。以下是一个简单的位压碎器的实现示例:
registerProcessor('bit-crusher', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'bitDepth', defaultValue: 8, minValue: 1, maxValue: 16 }
];
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const bitDepth = parameters.bitDepth[0];
const scale = Math.pow(2, bitDepth - 1);
for (let channel = 0; channel < input.length; ++channel) {
const inputChannel = input[channel];
const outputChannel = output[channel];
for (let i = 0; i < inputChannel.length; ++i) {
const sample = inputChannel[i];
const quantizedSample = Math.round(sample * scale) / scale;
outputChannel[i] = quantizedSample;
}
}
return true;
}
});
在这个示例中,
bitDepth
参数控制了位压碎器的位深度。通过对输入样本进行四舍五入操作,将其量化到指定的位深度。
8. 动态范围压缩器的快速响应实现
动态范围压缩器用于压缩音频信号的动态范围,使得音频的音量更加均衡。在传统的实现中,动态范围压缩器的响应速度可能较慢,无法满足一些对实时性要求较高的应用场景。
使用音频工作线程可以实现快速响应的动态范围压缩器。以下是一个简单的动态范围压缩器的实现示例:
registerProcessor('dynamic-compressor', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'threshold', defaultValue: -20, minValue: -100, maxValue: 0 },
{ name: 'ratio', defaultValue: 4, minValue: 1, maxValue: 20 },
{ name: 'attack', defaultValue: 0.001, minValue: 0.001, maxValue: 1 },
{ name: 'release', defaultValue: 0.1, minValue: 0.01, maxValue: 1 }
];
}
constructor() {
super();
this.envelope = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const threshold = parameters.threshold[0];
const ratio = parameters.ratio[0];
const attack = parameters.attack[0];
const release = parameters.release[0];
for (let channel = 0; channel < input.length; ++channel) {
const inputChannel = input[channel];
const outputChannel = output[channel];
for (let i = 0; i < inputChannel.length; ++i) {
const sample = inputChannel[i];
const absSample = Math.abs(sample);
if (absSample > this.envelope) {
this.envelope += (absSample - this.envelope) * attack;
} else {
this.envelope += (absSample - this.envelope) * release;
}
let gain = 1;
if (this.envelope > Math.pow(10, threshold / 20)) {
gain = Math.pow(10, ((threshold - this.envelope * 20) / (20 * ratio)));
}
outputChannel[i] = sample * gain;
}
}
return true;
}
});
在这个示例中,
threshold
参数控制了压缩器的阈值,
ratio
参数控制了压缩比,
attack
参数控制了攻击时间,
release
参数控制了释放时间。通过实时计算音频信号的包络,并根据包络调整增益,可以实现快速响应的动态范围压缩。
9. 立体声增强器的信号功率保持
立体声增强器用于增强音频信号的立体声效果,使得声音更加宽广和立体。在传统的实现中,立体声增强器可能会导致信号功率的损失。
使用音频工作线程可以实现信号功率保持的立体声增强器。以下是一个简单的立体声增强器的实现示例:
registerProcessor('stereo-enhancer', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'intensity', defaultValue: 0.5, minValue: 0, maxValue: 1 }
];
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const intensity = parameters.intensity[0];
for (let i = 0; i < input[0].length; ++i) {
const leftInput = input[0][i];
const rightInput = input[1][i];
const center = (leftInput + rightInput) / 2;
const side = (rightInput - leftInput) * intensity;
output[0][i] = center - side;
output[1][i] = center + side;
}
return true;
}
});
在这个示例中,
intensity
参数控制了立体声增强的强度。通过将音频信号分解为中心信号和侧边信号,并根据强度调整侧边信号的幅度,可以实现立体声增强,同时保持信号的功率。
10. 卡尔普斯 - 强算法的实现
卡尔普斯 - 强算法用于模拟弦乐器的声音,通过循环播放一个随机噪声序列来生成声音。在传统的实现中,卡尔普斯 - 强算法对音高范围有较强的限制。
使用音频工作线程和循环缓冲区可以克服这些限制。以下是一个简单的卡尔普斯 - 强算法的实现示例:
registerProcessor('karplus-strong', class extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'frequency', defaultValue: 440, minValue: 20, maxValue: 20000 }
];
}
constructor() {
super();
this.bufferSize = 1024;
this.buffer = new Float32Array(this.bufferSize);
this.index = 0;
this.fillBuffer();
}
fillBuffer() {
for (let i = 0; i < this.bufferSize; ++i) {
this.buffer[i] = Math.random() * 2 - 1;
}
}
process(inputs, outputs, parameters) {
const output = outputs[0];
const frequency = parameters.frequency[0];
const delay = sampleRate / frequency;
for (let channel = 0; channel < output.length; ++channel) {
const outputChannel = output[channel];
for (let i = 0; i < outputChannel.length; ++i) {
const nextIndex = Math.floor((this.index + delay) % this.bufferSize);
const sample = (this.buffer[this.index] + this.buffer[nextIndex]) / 2;
outputChannel[i] = sample;
this.buffer[this.index] = sample;
this.index = (this.index + 1) % this.bufferSize;
}
}
return true;
}
});
在这个示例中,
frequency
参数控制了生成声音的频率。通过使用循环缓冲区存储随机噪声序列,并根据频率计算延迟,实现了卡尔普斯 - 强算法。
总结
通过音频工作线程,可以实现各种复杂的音频处理功能,避免了一些默认音频节点的局限性。以下是音频工作线程在不同应用场景中的优势总结:
| 应用场景 | 传统方法的局限性 | 音频工作线程的优势 |
| ---- | ---- | ---- |
| 脉冲波和方波生成 | 依赖傅里叶级数,无法控制相位 | 可直接生成,方便控制相位 |
| 位压碎器设计 | 需要存储波整形曲线 | 直接进行位操作,无需存储曲线 |
| 动态范围压缩器 | 响应速度慢 | 实现快速响应 |
| 立体声增强器 | 可能导致信号功率损失 | 保持信号功率 |
| 卡尔普斯 - 强算法 | 音高范围受限 | 克服音高范围限制 |
音频工作线程为音频开发提供了更多的灵活性和可能性,使得开发者能够实现更加复杂和高效的音频处理算法。在实际应用中,可以根据具体需求选择合适的方法和技术,以达到最佳的音频处理效果。
以下是一个整体的流程图,展示了音频工作线程在不同应用场景中的处理流程:
graph LR;
A[音频输入] --> B[音频工作线程处理];
B -- 脉冲波和方波 --> C[生成脉冲波和方波];
B -- 位压碎器 --> D[位压碎处理];
B -- 动态范围压缩器 --> E[动态范围压缩];
B -- 立体声增强器 --> F[立体声增强];
B -- 卡尔普斯 - 强算法 --> G[模拟弦乐器声音];
C --> H[音频输出];
D --> H;
E --> H;
F --> H;
G --> H;
通过合理使用音频工作线程,可以充分发挥其优势,为音频处理带来更多的创新和可能性。
超级会员免费看

被折叠的 条评论
为什么被折叠?



