• 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,它可以通过调整波形、频率产生不同的声音。

这节我们就用它来实现一个在线钢琴。

css 我们用过 CSS Modules、用过 tailwind,这节用 css in js 方法 styled-components 来写。

创建个项目:

npx create-vite online-piano

image.png

安装 styled-components

npm install

npm install --save styled-components

去掉 index.css 和 StrictMode:

然后改下 App.tsx:

import { styled, createGlobalStyle, css } from "styled-components";

function App() {
    const keys: Record<string, { frequency: number }> = {
        A: {
            frequency: 196,
        },
        S: {
            frequency: 220,
        },
        D: {
            frequency: 246,
        },
        F: {
            frequency: 261,
        },
        G: {
            frequency: 293,
        },
        H: {
            frequency: 329,
        },
        J: {
            frequency: 349,
        },
        K: {
            frequency: 392,
        },
    };

    const GlobalStyles = createGlobalStyle`
    body {
      background: #000;
    }
  `;

    const KeysStyle = styled.div`
        width: 800px;
        height: 400px;
        margin: 40px auto;

        display: flex;
        flex-direction: row;
        justify-content: space-between;
        overflow: hidden;
    `;
    const textStyle = css`
        line-height: 500px;
        text-align: center;
        font-size: 50px;
    `;

    const KeyStyle = styled.div`
        border: 4px solid black;
        background: #fff;
        flex: 1;
        ${textStyle}

        &:hover {
            background: #aaa;
        }
    `;

    const play = (key: string) => {
        const frequency = keys[key]?.frequency;
        if (!frequency) {
            return;
        }
    };

    return (
        <KeysStyle as="section">
            {Object.keys(keys).map((item: any) => {
                return (
                    <KeyStyle as="div" key={item}>
                        <div onClick={() => play(item)}>
                            <span>{item}</span>
                        </div>
                    </KeyStyle>
                );
            })}
            <GlobalStyles />
        </KeysStyle>
    );
}

export default App;

这里用一个对象来保存所有的 key 和对应的频率。

用 styled-components 来写样式。

image.png

这里用到 3 个 styled-components 的 api:

用 styled.xxx 写样式组件。

用 createGlobalStyle 写全局样式。

用 css 创建复用的 css 片段。

样式组件自然就是可以当作组件来用的:

image.png

这也是用了 styled-components 的代码的特点。

可以用 as 修改渲染的标签。

跑起来看下:

npm run start:dev

image.png

看下效果:

2024-08-30 21.49.47.gif

没啥问题。

打开控制台看下:

image.png

可以看到,className 是编译过的,完全不用担心样式冲突问题。

这就是 styled-components 的好处之一。

这样,样式部分就写完了。

然后我们来写 Audio 部分:

image.png

const context = useMemo(() => {
    return new AudioContext();
}, []);

const play = (key: string) => {
    const frequency = keys[key]?.frequency;
    if (!frequency) {
        return;
    }

    const osc = context.createOscillator();
    osc.type = "sine";

    const gain = context.createGain();
    osc.connect(gain);
    gain.connect(context.destination);

    osc.frequency.value = frequency;
    gain.gain.setValueAtTime(0, context.currentTime);
    gain.gain.linearRampToValueAtTime(1, context.currentTime + 0.01);

    osc.start(context.currentTime);

    gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 1);
    osc.stop(context.currentTime + 1);
};

我们从上到下看下代码:

image.png

首先,创建 AudioContext,这个不需要每次渲染都创建,所以用 useMemo 包裹。

然后创建 oscillator 节点、gain 节点、destination 节点,连接起来。

image.png

这些我们比较熟悉了。

重点是下面部分:

image.png

前面我们用 GainNode 修改音量的方式都是直接改 value。

其实它可以按照某种规律修改音量。

我们在 currentTime 当前时间设置音量为 0

然后 0.01 秒后设置为 1,也就是声音是逐渐变大的(linear 是线性)

然后在 1 秒后设置音量为 0.01,也就是声音指数级的变小。(exponential 是指数级)

这样,按每个键声音都是一秒,但这一秒内有音量从小到大再到小的变化。

大概是这样变化的:

image.png

这样听起来就很自然。

正好 start 到 stop 间隔 1 秒,就是按照上面的规律变化的音量:

image.png

我们试一下:

2024-08-30 21.49.47.gif

声音是这样的:

jaudio

是不是很自然!

如果没有音量变化是什么样呢?

注释掉试试:

image.png

听下现在的声音:

jaudio

音量完全没变化,听起来就不好听。

现在我们可以点击对应的键来演奏音乐了。

但这样不方便,我们再加上键盘控制:

image.png

监听 keydown 事件,调用 play 方法传入 key 就可以了。

但按键盘不会触发 hover 效果,所以我们手动加一下 className 来显示按下的效果。

在 global style 加一下这个全局的 className:

image.png

全局 className 不会被编译。

试一下:

2024-08-30 22.09.23.gif

这样,按键盘就可以弹奏了。

然后我们用它来演奏几首歌曲:

从网上找下歌曲的简谱:

image.png

这里我们就只演奏第一句吧

image.png

image.png

我们定义了简谱数字和键的对应关系。

然后不同的时间按下不同的键就可以了。

import { useEffect, useMemo } from "react";
import { styled, createGlobalStyle, css } from "styled-components";

function App() {
    const keys: Record<string, { frequency: number }> = {
        A: {
            frequency: 196,
        },
        S: {
            frequency: 220,
        },
        D: {
            frequency: 246,
        },
        F: {
            frequency: 261,
        },
        G: {
            frequency: 293,
        },
        H: {
            frequency: 329,
        },
        J: {
            frequency: 349,
        },
        K: {
            frequency: 392,
        },
    };

    const GlobalStyles = createGlobalStyle`
    body {
      background: #000;
    }
    .pressed {
      background: #aaa;
    }
  `;

    const KeysStyle = styled.div`
        width: 800px;
        height: 400px;
        margin: 40px auto;

        display: flex;
        flex-direction: row;
        justify-content: space-between;
        overflow: hidden;
    `;
    const textStyle = css`
        line-height: 500px;
        text-align: center;
        font-size: 50px;
    `;

    const KeyStyle = styled.div`
        border: 4px solid black;
        background: #fff;
        flex: 1;
        ${textStyle}

        &:hover {
            background: #aaa;
        }
    `;

    const context = useMemo(() => {
        return new AudioContext();
    }, []);

    const play = (key: string) => {
        const frequency = keys[key]?.frequency;
        if (!frequency) {
            return;
        }

        const osc = context.createOscillator();
        osc.type = "sine";
        osc.frequency.value = frequency;

        const gain = context.createGain();
        osc.connect(gain);
        gain.connect(context.destination);

        gain.gain.setValueAtTime(0, context.currentTime);
        gain.gain.linearRampToValueAtTime(1, context.currentTime + 0.01);

        osc.start(context.currentTime);

        gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 1);
        osc.stop(context.currentTime + 1);

        document.getElementById(`key-${key}`)?.classList.add("pressed");
        setTimeout(() => {
            document.getElementById(`key-${key}`)?.classList.remove("pressed");
        }, 100);
    };

    useEffect(() => {
        document.addEventListener("keydown", (e) => {
            play(e.key.toUpperCase());
        });
    }, []);

    const map: Record<number, string> = {
        1: "A",
        2: "S",
        3: "D",
        4: "F",
        5: "G",
        6: "H",
        7: "J",
        8: "K",
    };

    function playSong1() {
        const music = [
            [6, 1000],
            [5, 1000],
            [3, 1000],
            [5, 1000],
            [8, 1000],
            [6, 500],
            [5, 500],
            [6, 1000],
        ];

        let startTime = 0;
        music.forEach((item) => {
            setTimeout(() => {
                play(map[item[0]]);
            }, startTime);
            startTime += item[1];
        });
    }

    return (
        <div>
            <KeysStyle as="section">
                {Object.keys(keys).map((item: any) => {
                    return (
                        <KeyStyle as="div" key={item}>
                            <div onClick={() => play(item)} id={`key-${item}`}>
                                <span>{item}</span>
                            </div>
                        </KeyStyle>
                    );
                })}
                <GlobalStyles />
            </KeysStyle>
            <div className="songs">
                <button onClick={() => playSong1()}>世上只有妈妈好</button>
            </div>
        </div>
    );
}

export default App;

听一下:

2024-08-30 22.36.06.gif

jaudio

再加一首《奢香夫人》:

image.png

image.png

抽取一个 playMusic 的方法,并且 startTime 缩短一半。

function playMusic(music: number[][]) {
    let startTime = 0;
    music.forEach((item) => {
        setTimeout(() => {
            play(map[item[0]]);
        }, startTime * 0.5);
        startTime += item[1];
    });
}

function playSong2() {
    const music = [
        [6, 1000],
        [6, 1000],
        [6, 1000],
        [3, 500],
        [6, 500],
        [5, 1000],
        [3, 500],
        [2, 500],
        [3, 1000],
    ];

    playMusic(music);
}
<button onClick={() => playSong2()}>奢香夫人</button>

听一下:

2024-08-30 23.03.28.gif

jaudio

至此,我们的在线钢琴就完成了。

案例代码上传了小册仓库

总结

上节学了 AudioContext 的振荡器调音,这节我们基于 AudioContext 实现了一个在线钢琴。

不同键只是振动频率不同,然后按下的时候设置音量有个从小到大再到小的变化就好了。

我们用 styled-components 写的样式,它是通过组件的方式来使用某段样式。

我们监听了 keydown 事件,触发不同键的按下的处理。

然后根据简谱,通过不同 setTimeout 实现了乐曲的自动播放。

做完这个案例,我们会对 AudioContext 有更深的理解。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
83.ReactFlow振荡器调音:合成声音
Next
85.React服务端渲染:从SSR到hydrate