外观
第83章—ReactFlow振荡器调音:合成声音
2859字约10分钟
2024-09-19
这节来写音频部分,通过流程图设置参数,然后生成声音。
我们先用一下 AudioContext 的 api。
创建 audio.ts
const context = new AudioContext();
const osc = context.createOscillator();
osc.frequency.value = 220;
osc.type = 'square';
osc.start();
const volume = context.createGain();
volume.gain.value = 0.5;
const out = context.destination;
osc.connect(volume);
volume.connect(out);
创建一个 Oscillator 节点,一个 Gain 节点,和 destination 节点连接起来:
Oscillator 振荡器节点产生不同波形、频率的声音,Gain 节点调节音量,然后 destination 节点播放声音。
在 main.ts 里引入下:
这时候你在页面上就能听到声音了。
有 connect 当然也有 disconnect:
断开节点的连接就没声音了。
connect、disconnect 在流程图上就是 edge 的创建和删除。
所以很容易把两者结合起来。
而且你可以用两个振荡器节点 connect 到一个 destination
对应的代码就是这样:
const context = new AudioContext();
const osc = context.createOscillator();
osc.frequency.value = 220;
osc.type = 'square';
osc.start();
const volume = context.createGain();
volume.gain.value = 0.5;
const out = context.destination;
osc.connect(volume);
volume.connect(out);
const osc2 = context.createOscillator();
osc2.frequency.value = 800;
osc2.type = 'sine';
osc2.start();
const volume2 = context.createGain();
volume2.gain.value = 0.5;
osc2.connect(volume2);
volume2.connect(out);
两个振荡器分别设置不同的波形、频率,产生不同的声音。
你可以听一下,声音是不是两者的合并:
对比听下之前的:
对应到流程图就是这样的:
接下来我们就来实现流程图操作到 audio 的对应。
改下 Audio.tsx
const context = new AudioContext();
const osc = context.createOscillator();
osc.frequency.value = 220;
osc.type = 'square';
osc.start();
const volume = context.createGain();
volume.gain.value = 0.5;
const out = context.destination;
const nodes = new Map();
nodes.set('a', osc);
nodes.set('b', volume);
nodes.set('c', out);
export function isRunning() {
return context.state === 'running';
}
export function toggleAudio() {
return isRunning() ? context.suspend() : context.resume();
}
export function updateAudioNode(id: string, data: Record<string, any>) {
const node = nodes.get(id);
for (const [key, val] of Object.entries(data)) {
if (node[key] instanceof AudioParam) {
node[key].value = val;
} else {
node[key] = val;
}
}
}
export function removeAudioNode(id: string) {
const node = nodes.get(id);
node.disconnect();
node.stop?.();
nodes.delete(id);
}
export function connect(sourceId: string, targetId: string) {
const source = nodes.get(sourceId);
const target = nodes.get(targetId);
source.connect(target);
}
export function disconnect(sourceId: string, targetId: string) {
const source = nodes.get(sourceId);
const target = nodes.get(targetId);
source.disconnect(target);
}
export function createAudioNode(id: string, type: string, data: Record<string, any>) {
switch (type) {
case 'osc': {
const node = context.createOscillator();
node.frequency.value = data.frequency;
node.type = data.type;
node.start();
nodes.set(id, node);
break;
}
case 'volume': {
const node = context.createGain();
node.gain.value = data.gain;
nodes.set(id, node);
break;
}
}
}
从上往下看:
因为可能有多个振荡器节点、音量节点,所以用一个 Map 来存储,key 是流程图节点 id:
首先,内置 3 个节点:
然后暴露了一个 createAudioNode 的方法来创建两种节点(destination 节点只有一个):
创建完加到 Map 里。
然后提供两个 Audio 节点的连接和断开连接的方法:
这就是我们用流程图节点 id 来作为 Map 的 key 的好处,可以直接把流程图节点的操作对应到 Audio 节点。
然后暴露一个删除 Audio 节点的方法:
首先 disconnect 所有的连接,然后 stop 这个 Audio 节点,之后从 map 中删除它。
然后是更新参数的方法:
两种流程图节点中的参数修改,就通过这个方法更新到 Audio 节点
最后暴露一个暂停、修复声音播放的方法:
总结一下,就是用一个 Map 保存所有的 Audio 节点,key 为对应流程图节点的 id,然后暴露创建节点、节点连接、删除节点、更新节点参数,暂停、恢复播放的方法。
之后就可以把节点的 onNodeChanges、onEdgeChanges、onConnect 事件对应到这些 更新 audio 节点的方法了。
改下 App.tsx
初始有 a、b、c 三个节点:
没有边。
流程图节点 connect 的时候,顺便也把对应的 Audio 节点 connect:
const initialNodes: Node[] = [
{
id: 'a',
type: 'osc',
data: { frequency: 220, type: 'square' },
position: { x: 200, y: 0 }
},
{
id: 'b',
type: 'volume',
data: { gain: 0.5 },
position: { x: 150, y: 250 }
},
{
id: 'c',
type: 'out',
data: {},
position: { x: 350, y: 400 }
}
];
const initialEdges:Edge[] = [];
connect(params.source, params.target);
然后你再在界面上连下线:
连完 3 个节点,你会发现还是没声音。
因为默认是在 suspend 状态,需要 resume 一下:
我们在 OutputNode 点击喇叭的时候调用下 toogleAudio 来切换状态:
这样点击喇叭就有声音了:
再点击一次就会暂停。
然后再支持下参数的调整:
在 onChange 的时候,修改 audio 节点的参数:
import { Handle, Position } from '@xyflow/react';
import { updateAudioNode } from '../Audio';
import { ChangeEvent, ChangeEventHandler, useState } from 'react';
export interface VolumeNodeProps {
id: string
data: {
gain: number
}
}
export function VolumeNode({ id, data }: VolumeNodeProps) {
const [gain, setGain] = useState(data.gain);
const changeGain: ChangeEventHandler<HTMLInputElement> = (e) => {
setGain(+e.target.value);
updateAudioNode(id, { gain: +e.target.value })
}
return (
<div className={'rounded-md bg-white shadow-xl'}>
<Handle type="target" position={Position.Top} />
<p className={'rounded-t-md p-[4px] bg-blue-500 text-white'}>音量节点</p>
<div className={'flex flex-col p-[4px]'}>
<p>Gain</p>
<input
className="nodrag"
type="range"
min="0"
max="1"
step="0.01"
value={gain}
onChange={changeGain}
/>
<p className={'text-right'}>{gain.toFixed(2)}</p>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
试一下:
拖动调整音量,你能听到声音大小的变化。
注意,这里加上了 nodrag:
不加的话拖动进度条就变成了拖动节点:
这个是 react flow 提供的用于禁止拖动的 className:
同样的方式处理下 OscillatorNode
import { Handle, Position, useReactFlow } from '@xyflow/react';
import { updateAudioNode } from '../Audio';
import { ChangeEvent, ChangeEventHandler, useState } from 'react';
export interface OscillatorNodeProps {
id: string
data: {
frequency: number
type: string
}
}
export function OscillatorNode({ id, data }: OscillatorNodeProps) {
const [frequency, setFrequency] = useState(data.frequency);
const [type, setType] = useState(data.type);
const changeFrequency: ChangeEventHandler<HTMLInputElement> = (e) => {
setFrequency(+e.target.value);
updateAudioNode(id, { frequency: +e.target.value })
}
const changeType: ChangeEventHandler<HTMLSelectElement> = (e) => {
setType(e.target.value);
updateAudioNode(id, { type: e.target.value })
}
return (
<div className={'bg-white shadow-xl'}>
<p className={'rounded-t-md p-[8px] bg-pink-500 text-white'}>振荡器节点</p>
<div className={'flex flex-col p-[8px]'}>
<span>频率</span>
<input
className='nodrag'
type="range"
min="10"
max="1000"
value={frequency}
onChange={changeFrequency}
/>
<span className={'text-right'}>{frequency}赫兹</span>
</div>
<hr className={'mx-[4px]'} />
<div className={'flex flex-col p-[8px]'}>
<p>波形</p>
<select value={type} onChange={changeType}>
<option value="sine">正弦波</option>
<option value="triangle">三角波</option>
<option value="sawtooth">锯齿波</option>
<option value="square">方波</option>
</select>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
};
现在就能听到不同频率、波形的声音了。
然后我们再支持下添加振荡器节点和音量节点:
import { addEdge, Background, BackgroundVariant, Connection, Controls, Edge, EdgeTypes, MiniMap, Node, OnConnect, Panel, ReactFlow, useEdgesState, useNodesState } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { OscillatorNode } from './components/OscillatorNode';
import { VolumeNode } from './components/VolumeNode';
import { OutputNode } from './components/OutputNode';
import { connect, createAudioNode } from './Audio';
const initialNodes: Node[] = [
{
id: 'a',
type: 'osc',
data: { frequency: 220, type: 'square' },
position: { x: 200, y: 0 }
},
{
id: 'b',
type: 'volume',
data: { gain: 0.5 },
position: { x: 150, y: 250 }
},
{
id: 'c',
type: 'out',
data: {},
position: { x: 350, y: 400 }
}
];
const initialEdges:Edge[] = [];
const nodeTypes = {
'osc': OscillatorNode,
'volume': VolumeNode,
'out': OutputNode
}
export default function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = (params: Connection) => {
connect(params.source, params.target);
setEdges((eds) => addEdge(params, eds))
}
function addOscNode() {
const id = Math.random().toString().slice(2, 8);
const position = { x: 0, y: 0 };
const type = 'osc';
const data = {frequency: 400, type: 'sine' };
setNodes([...nodes, {id, type, data, position}])
createAudioNode(id, type, data);
}
function addVolumeNode() {
const id = Math.random().toString().slice(2, 8);
const data = { gain: 0.5 };
const position = { x: 0, y: 0 };
const type = 'volume';
setNodes([...nodes, {id, type, data, position}])
createAudioNode(id, type, data);
}
return (
<div style={{ width: '100vw', height: '100vh'}}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Controls/>
<MiniMap/>
<Background variant={BackgroundVariant.Lines}/>
<Panel className={'space-x-4'} position="top-right">
<button className={'p-[4px] rounded bg-white shadow'} onClick={addOscNode}>添加振荡器节点</button>
<button className={'p-[4px] rounded bg-white shadow'} onClick={addVolumeNode}>添加音量节点</button>
</Panel>
</ReactFlow>
</div>
);
}
试一下:
这样,添加节点就完成了。
多个节点的时候,声音是它们的合成音。
我们还没处理流程节点删除的时候,去掉 Audio Node,也做一下:
onNodesDelete={(nodes) => {
for (const { id } of nodes) {
removeAudioNode(id)
}
}}
onEdgesDelete={(edges) => {
for (const item of edges) {
const { source, target} = item
disconnect(source, target);
}
}}
节点删除对应 removeAudioNode,边删除对应 disconnect。
至此,我们的 React Flow 振荡器调音就完成了。
不过现在不好操作,Handle 有点小,我们加大一点:
看下效果:
这样,操作起来就方便多了。
案例代码上传了小册仓库
总结
这节我们实现了流程图节点和 AudioContext 节点的同步。
Audio 是通过 createOscillator 创建振荡器节点,通过 createGain 创建音量节点,然后把它们 connect 起来 connect 到 context.destination 节点播放声音。
这和 React Flow 流程图的节点创建、节点连接很容易对应上。
我们分别把流程图节点的 connect 对应到 Audio Node 的 connect 上。
流程图节点表单参数的修改对应到相同 id 的 Audio Node 的参数修改。
流程图节点的创建、删除对应到 Audio Node 的添加删除上。
这样,就可以可视化的调音了。