• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

这节来写音频部分,通过流程图设置参数,然后生成声音。

我们先用一下 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 节点连接起来:

image.png

Oscillator 振荡器节点产生不同波形、频率的声音,Gain 节点调节音量,然后 destination 节点播放声音。

在 main.ts 里引入下:

image.png

这时候你在页面上就能听到声音了。

jaudio

有 connect 当然也有 disconnect:

image.png

断开节点的连接就没声音了。

connect、disconnect 在流程图上就是 edge 的创建和删除。

所以很容易把两者结合起来。

而且你可以用两个振荡器节点 connect 到一个 destination

image.png

对应的代码就是这样:

image.png

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

两个振荡器分别设置不同的波形、频率,产生不同的声音。

你可以听一下,声音是不是两者的合并:

jaudio

对比听下之前的:

jaudio

对应到流程图就是这样的:

image.png

接下来我们就来实现流程图操作到 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:

image.png

首先,内置 3 个节点:

image.png

然后暴露了一个 createAudioNode 的方法来创建两种节点(destination 节点只有一个):

image.png

创建完加到 Map 里。

然后提供两个 Audio 节点的连接和断开连接的方法:

image.png

这就是我们用流程图节点 id 来作为 Map 的 key 的好处,可以直接把流程图节点的操作对应到 Audio 节点。

然后暴露一个删除 Audio 节点的方法:

image.png

首先 disconnect 所有的连接,然后 stop 这个 Audio 节点,之后从 map 中删除它。

然后是更新参数的方法:

image.png

两种流程图节点中的参数修改,就通过这个方法更新到 Audio 节点

image.png

最后暴露一个暂停、修复声音播放的方法:

image.png

总结一下,就是用一个 Map 保存所有的 Audio 节点,key 为对应流程图节点的 id,然后暴露创建节点、节点连接、删除节点、更新节点参数,暂停、恢复播放的方法。

之后就可以把节点的 onNodeChanges、onEdgeChanges、onConnect 事件对应到这些 更新 audio 节点的方法了。

改下 App.tsx

初始有 a、b、c 三个节点:

image.png

没有边。

流程图节点 connect 的时候,顺便也把对应的 Audio 节点 connect:

image.png

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

然后你再在界面上连下线:

2024-08-29 21.13.32.gif

连完 3 个节点,你会发现还是没声音。

因为默认是在 suspend 状态,需要 resume 一下:

我们在 OutputNode 点击喇叭的时候调用下 toogleAudio 来切换状态:

image.png

这样点击喇叭就有声音了:

2024-08-29 21.51.05.gif

再点击一次就会暂停。

然后再支持下参数的调整:

在 onChange 的时候,修改 audio 节点的参数:

image.png

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

试一下:

2024-08-30 08.06.37.gif

拖动调整音量,你能听到声音大小的变化。

jaudio

注意,这里加上了 nodrag:

image.png

不加的话拖动进度条就变成了拖动节点:

2024-08-30 07.56.40.gif

这个是 react flow 提供的用于禁止拖动的 className:

image.png

同样的方式处理下 OscillatorNode

image.png

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

2024-08-30 08.11.10.gif

现在就能听到不同频率、波形的声音了。

然后我们再支持下添加振荡器节点和音量节点:

image.png

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

试一下:

2024-08-30 08.24.31.gif

这样,添加节点就完成了。

多个节点的时候,声音是它们的合成音。

我们还没处理流程节点删除的时候,去掉 Audio Node,也做一下:

image.png

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 有点小,我们加大一点:

image.png

看下效果:

image.png

这样,操作起来就方便多了。

案例代码上传了小册仓库

总结

这节我们实现了流程图节点和 AudioContext 节点的同步。

Audio 是通过 createOscillator 创建振荡器节点,通过 createGain 创建音量节点,然后把它们 connect 起来 connect 到 context.destination 节点播放声音。

这和 React Flow 流程图的节点创建、节点连接很容易对应上。

我们分别把流程图节点的 connect 对应到 Audio Node 的 connect 上。

流程图节点表单参数的修改对应到相同 id 的 Audio Node 的参数修改。

流程图节点的创建、删除对应到 Audio Node 的添加删除上。

这样,就可以可视化的调音了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
82.ReactFlow振荡器调音:流程图绘制
Next
84.AudioContext实现在线钢琴