项目简介

2024-08-30 08.24.31.gif

这是一个基于 React + ReactFlow 的可视化音频调音项目。用户可以通过拖拽和连接不同的节点,调节频率、波形和音量,实现动态的声音合成。适合作为熟悉 ReactFlow、AudioContext 和前端可视化交互的练习项目。

🧰技术栈

  • 项目采用以下技术栈构建:

    • React:构建用户界面核心框架

    • ReactFlow:用于实现节点之间的可视化拖拽连接

      • npm install --save @xyflow/react
    • AudioContext API:用于控制音频的合成和播放

    • Tailwind CSS:快速构建样式,提升开发效率

🔧主要功能

  • 本项目主要实现以下功能:

    1. 添加振动器节点

    • 支持选择不同波形(正弦、锯齿、方波等)
    • 实时调节频率,产生不同音高的振动器输出

    2. 添加音量控制节点

    • 通过滑块调节音量强弱
    • 可将多个振动器连接到一个音量节点,实现合成混音

    3. 输出节点

    • 所有信号最终流向输出节点,实现声音播放

    4. 可视化连接与删除

    • 拖拽式创建连接线(边),支持手动断开连接

🏗️ 项目结构

1
2
3
4
5
6
7
8
9
src/
├── components/
│ ├── CustomEdge.tsx // 自定义边,支持删除功能
│ ├── OscillatorNode.tsx // 振动器节点组件
│ ├── VolumeNode.tsx // 音量控制节点
│ └── OutputNode.tsx // 音频输出节点
├── audio.ts // 音频控制逻辑,封装 AudioContext 相关 API
├── App.tsx // 主应用入口,集成 ReactFlow
└── main.tsx // 项目启动入口

源码

components/

CustomEdge.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
useReactFlow,
} from "@xyflow/react";
import { disconnect } from "../audio";
import { Trash2 } from "lucide-react";

export function CustomEdge({
id,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
selected,
}: EdgeProps) {
const { setEdges } = useReactFlow();

const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});

const onEdgeClick = () => {
setEdges((edges) => edges.filter((edge) => edge.id !== id));
disconnect(source, target);
};

return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
{selected && (
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// EdgeLabelRenderer 里的组件默认不处理鼠标事件如果要处理就要声明 pointerEvents: all
pointerEvents: "all",
}}
>
<button
onClick={onEdgeClick}
style={{
border: "none",
background: "#fff",
borderRadius: "50%",
padding: "4px",
cursor: "pointer",
boxShadow: "0 1px 4px rgba(0, 0, 0, 0.2)",
transition: "all 0.2s",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f87171";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#fff";
}}
>
<Trash2 size={16} color="#333" />
</button>
</div>
)}
</EdgeLabelRenderer>
</>
);
}

OscillatorNode.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { Handle, Position } from "@xyflow/react";
import { ChangeEventHandler, useState } from "react";
import { updateAudioNode } from "../audio";

export interface OscillatorNodeProps {
id: string;
data: {
frequency: number;
type: "sine" | "triangle" | "sawtooth" | "square" | string;
};
}

/**
* @description 渲染一个振荡器节点组件,用于 ReactFlow 流图中。节点底部包含连接句柄,顶部显示节点标题;中上部分为用户输入框,右下角展示输入数值;中下部分提供波形类型选择列表。
* @param {string} id - 节点的唯一标识符
* @param {number} data.frequency - 振动器频率(用户输入)
* @param {string} data.type - 波形类型(如 sine、square 等)
*/
export function OscillatorNode({ id, data }: OscillatorNodeProps) {
const [frequency, setFrequency] = useState(data.frequency);
const [type, setType] = useState(data.type);
const onFrequencyChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setFrequency(+e.target.value);
updateAudioNode(id, { frequency: +e.target.value });
};
const onTypeChange: 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={onFrequencyChange}
/>
<span className={"text-right"}>{frequency}赫兹</span>
</div>
<hr className={"mx-[4px]"} />
<div className={"flex flex-col p-[8px]"}>
<p>波形</p>
<select value={type} onChange={onTypeChange}>
<option value="sine">正弦波</option>
<option value="triangle">三角波</option>
<option value="sawtooth">锯齿波</option>
<option value="square">方波</option>
</select>
</div>
<Handle
type="source"
position={Position.Bottom}
style={{ width: "10px", height: "10px" }}
/>
</div>
);
}

VolumeNode.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { Handle, Position } from "@xyflow/react";
import { ChangeEventHandler, useState } from "react";
import { updateAudioNode } from "../audio";

export interface VolumeNodeProps {
id: string;
data: {
gain: number;
};
}

/**
* @description 渲染一个音量节点组件,用于 ReactFlow 流图中。节点包含上下两个连接句柄,顶部展示节点标题,中部为用户输入框,右下角实时显示输入值。
* @param {string} id - 节点的唯一标识符
* @param {number} data.gain - 音量增益值(用户输入)
*/
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=" shadow-xl rounded-md bg-white">
<Handle
type="target"
position={Position.Top}
style={{ width: "10px", height: "10px" }}
/>
<p className="bg-blue-500 text-white p-[4px] rounded-t-md">音量节点</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}
style={{ width: "10px", height: "10px" }}
/>
</div>
);
}

OutputNode.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @description 渲染一个 ReactFlow 输出节点,节点顶部包含连接句柄,主体区域展示标题及声音开关按钮。
*/
import { Handle, Position } from "@xyflow/react";
import { useState } from "react";
import { toggleAudio } from "../audio";

export function OutputNode() {
const [isRunning, setIsRuning] = useState(false);
return (
<div className={"bg-white shadow-xl p-[20px]"}>
<Handle
type="target"
position={Position.Top}
style={{ width: "10px", height: "10px" }}
/>

<div>
<p>输出节点</p>
<button
onClick={() => {
setIsRuning((isRunning) => !isRunning);
toggleAudio();
}}
>
{isRunning ? <span role="img">🔈</span> : <span role="img">🔇</span>}
</button>
</div>
</div>
);
}

audio.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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);

/**
* @description context.state是否在运行
* @returns {boolean} 是否运行
*/
export function inRunning() {
return context.state === "running";
}

/**
* @description 在音频正在运行时暂停它,不在运行时恢复它如果在运行,
*/
export function toggleAudio() {
return inRunning() ? context.suspend() : context.resume();
}

/**
* @description 传入id和data更新对应的Node参数
* @param {string} id
* @param {Record<string, any>} data
*/
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;
}
}
}

/**
* @description 传入id,断开节点连接,暂停节点状态,删除节点
* @param {string} id
*/
export function removeAudioNode(id: string) {
const node = nodes.get(id);

node.disconnect();
node.stop?.();
nodes.delete(id);
}

/**
* @description 传入源节点和目标节点的id,将他们连接起来
* @param {string} sourceId
* @param {string} targetId
*/
export function connect(sourceId: string, targetId: string) {
const source = nodes.get(sourceId);
const target = nodes.get(targetId);

source.connect(target);
}

/**
* @description 传入源节点和目标节点的id,将他们的联系断开
* @param {string} sourceId
* @param {string} targetId
*/
export function disconnect(sourceId: string, targetId: string) {
const source = nodes.get(sourceId);
const target = nodes.get(targetId);

source.disconnect(target);
}

/**
* @description 传入类型,根据类型生成对应的AudioNode
* @param {string} id
* @param {string} type
* @param {Record<string, any>} data
*/
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;
}
}
}

App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import "./App.css";
import { connect, createAudioNode, removeAudioNode } from "./audio";
import { OscillatorNode } from "./components/OscillatorNode";
import { VolumeNode } from "./components/VolumeNode";
import {
addEdge,
Background,
BackgroundVariant,
Connection,
Controls,
Edge,
MiniMap,
Node,
Panel,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { OutputNode } from "./components/OutputNode";
import { CustomEdge } from "./components/CustomEdge";
const initialNodes: Node[] = [
{
id: "a",
type: "osc",
data: { frequency: 220, type: "square" },
position: { x: 200, y: 0 },
},
{
id: "b",
type: "vol",
data: { gain: 0.5 },
position: { x: 150, y: 250 },
},
{
id: "c",
type: "out",
data: {},
position: { x: 350, y: 400 },
},
];

const initialEdges: Edge[] = [];
const nodeType = {
vol: VolumeNode,
osc: OscillatorNode,
out: OutputNode,
};

function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = (parmas: Connection) => {
connect(parmas.source, parmas.target);
setEdges((eds) => addEdge({ ...parmas, type: "custom" }, 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, position, type, data }]);
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={nodeType}
edgeTypes={{
custom: CustomEdge,
}}
onNodesDelete={(nodes) => {
for (const { id } of nodes) {
removeAudioNode(id);
}
}}
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>
</>
);
}

export default App;

📝 总结与收获

这个项目让我深入理解了以下几点:

  • 如何使用 ReactFlow 构建可视化编辑器
  • 如何结合 Web Audio API 实现前端音频处理
  • React 状态与底层对象(AudioNode)如何同步更新
  • 模块化项目结构和组件复用的重要性

📚 参考资料