实现一个简易录音机

Published on
/
/趣玩前端
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>实现一个简易录音机</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover"
    />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Asap+Condensed:wght@300;400&amp;display=swap"
    />
    <style>
      * {
        border: 0;
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      :root {
        --hue: 223;
        --bg: hsl(var(--hue), 10%, 70%);
        --fg: hsl(var(--hue), 10%, 10%);
        --focus: hsl(var(--hue), 90%, 60%);
        --focus-t: hsla(var(--hue), 90%, 60%, 0);
        --red-orange: hsl(11, 90%, 50%);
        --orange: hsl(21, 90%, 50%);
        --yellow-orange: hsl(31, 90%, 50%);
        --blue: hsl(198, 90%, 50%);
        --purple: hsl(287, 40%, 70%);
        --trans-dur: 0.3s;
        font-size: calc(20px + (80 - 20) * (100vw - 280px) / (3840 - 280));
      }

      body,
      button {
        color: var(--fg);
        font: 1em/1.5 'Asap Condensed', sans-serif;
      }

      body {
        background-color: var(--bg);
        overflow: hidden;
        height: 100vh;
        transition: background-color var(--trans-dur), color var(--trans-dur);
      }

      main {
        display: flex;
        overflow-x: hidden;
        padding: 1.5em 0;
        width: 100vw;
        height: 100vh;
      }

      .tb1 {
        --display-dim: 0.2;
        border-radius: 0.75em;
        box-shadow: 0.125em 0.125em 0.125em hsl(var(--hue), 10%, 90%) inset,
          -0.0625em -0.0625em 0.1875em hsl(var(--hue), 10%, 50%) inset,
          0 0 0.5em rgba(0, 0, 0, 0.5);
        margin: auto;
        padding: 1em;
        width: 14.25em;
        height: 15.75em;
        transition: background-color var(--trans-dur),
          box-shadow var(--trans-dur);
      }
      .tb1,
      .tb1__cell {
        background-color: hsl(var(--hue), 10%, 80%);
      }
      .tb1__bar {
        background-color: currentColor;
        height: 0.125em;
        opacity: var(--display-dim);
        transition: opacity calc(var(--trans-dur) / 2);
      }
      .tb1__bar--blue {
        background-color: var(--blue);
      }
      .tb1__bar--purple {
        background-color: var(--purple);
      }
      .tb1__bar--on {
        opacity: 1;
      }
      .tb1__bar--tall {
        height: 0.375em;
      }
      .tb1__bar--25p {
        width: 25%;
      }
      .tb1__bar--30p {
        width: 30%;
      }
      .tb1__bar--35p {
        width: 35%;
      }
      .tb1__bar--40p {
        width: 40%;
      }
      .tb1__bar--45p {
        width: 45%;
      }
      .tb1__bar--50p {
        width: 50%;
      }
      .tb1__bar--55p {
        width: 55%;
      }
      .tb1__bar--60p {
        width: 60%;
      }
      .tb1__bar--65p {
        width: 65%;
      }
      .tb1__bar--70p {
        width: 70%;
      }
      .tb1__bars {
        display: flex;
        gap: 0.125em;
        flex-direction: column-reverse;
      }
      .tb1__bars + .tb1__bars {
        align-items: flex-end;
      }
      .tb1__button,
      .tb1__dial {
        border-radius: 50%;
        margin: auto;
        width: 2.25em;
        height: 2.25em;
      }
      .tb1__button,
      .tb1__dial-control {
        display: flex;
      }
      .tb1__button {
        box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 70%) inset,
          -0.0625em 0 0.125em hsl(var(--hue), 10%, 90%) inset,
          0.0625em 0 0.125em hsl(var(--hue), 10%, 85%),
          -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
        transition: background-color var(--trans-dur),
          box-shadow var(--trans-dur), color var(--trans-dur);
      }
      .tb1__cell {
        border-radius: 0.25em;
        box-shadow: 0 0 0 0.125em var(--focus-t),
          0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 90%) inset,
          -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 50%) inset;
        display: flex;
        transition: background-color var(--trans-dur),
          box-shadow var(--trans-dur),
          transform calc(var(--trans-dur) / 3) cubic-bezier(0.65, 0, 0.35, 1);
      }
      .tb1__cell[type='button'] {
        cursor: pointer;
      }
      .tb1__cell[type='button']:active {
        transform: scale(0.95);
      }
      .tb1__cell[type='button']:focus-visible {
        box-shadow: 0 0 0 0.125em var(--focus),
          0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 90%) inset,
          -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 50%) inset;
      }
      .tb1__cell--display {
        background-image: linear-gradient(
            80deg,
            hsl(var(--hue), 10%, 15%) 49%,
            hsla(var(--hue), 10%, 15%, 0) 50%
          ),
          linear-gradient(hsl(var(--hue), 10%, 25%), hsl(var(--hue), 10%, 30%));
        box-shadow: 0 0 0.125em hsl(var(--hue), 10%, 30%) inset !important;
        display: grid;
        grid-column: 3;
        grid-row-start: 2;
        grid-row-end: 4;
        grid-template-columns: repeat(2, 1fr);
        grid-template-rows: 0.75em 1fr;
        padding: 0.5em;
        text-transform: uppercase;
      }
      .tb1__cell--speakers {
        display: grid;
        grid-column-start: 1;
        grid-column-end: 4;
        grid-template-columns: repeat(15, 0.625em);
        grid-template-rows: repeat(7, 0.625em);
        justify-content: center;
        align-content: center;
        place-items: center;
      }
      .tb1__dial {
        box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 70%) inset,
          -0.0625em 0 0.125em hsl(var(--hue), 10%, 30%) inset,
          0.0625em 0 0.0125em hsl(var(--hue), 10%, 90%),
          -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
        transition: background-color var(--trans-dur),
          box-shadow var(--trans-dur), color var(--trans-dur);
      }
      .tb1__dial-control {
        --shadow-before-rotate: 0;
        --shadow-after1-x: 1;
        --shadow-after1-y: 1;
        --shadow-after2-x: -1;
        --shadow-after2-y: -1;
        background-color: transparent;
        border-radius: inherit;
        box-shadow: 0 0 0 0.125em var(--focus-t);
        cursor: grab;
        position: relative;
        transition: box-shadow var(--trans-dur);
        width: 100%;
        height: 100%;
      }
      .tb1__dial-control:active {
        cursor: grabbing;
      }
      .tb1__dial-control:focus-visible {
        box-shadow: 0 0 0 0.125em var(--focus);
      }
      .tb1__dial-control:before,
      .tb1__dial-control:after {
        background-color: var(--blue);
        content: '';
        display: block;
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        z-index: 1;
      }
      .tb1__dial-control:before {
        border-radius: 50%;
        box-shadow: 0.0625em 0 0.0625em rgba(0, 0, 0, 0.3) inset,
          -0.0625em 0 0.0625em rgba(255, 255, 255, 0.3) inset;
        width: 1.75em;
        height: 1.75em;
        transform: translate(-50%, -50%)
          rotate(calc(1deg * var(--shadow-before-rotate)));
      }
      .tb1__dial-control:after {
        border-radius: 0.1875em;
        box-shadow: 2px 0 0 hsl(var(--hue), 10%, var(--shadow-after1-x)) inset,
          0 2px 0 hsl(var(--hue), 10%, var(--shadow-after1-y)) inset,
          -2px 0 0 hsl(var(--hue), 10%, var(--shadow-after2-x)) inset,
          0 -2px 0 hsl(var(--hue), 10%, var(--shadow-after2-y)) inset;
        opacity: 0.4;
        width: 0.375em;
        height: 1.25em;
      }
      .tb1__cell[type='button'],
      .tb1__dial-control {
        outline: 0;
        -webkit-appearance: none;
        appearance: none;
        -webkit-tap-highlight-color: transparent;
      }
      .tb1__display-label {
        color: white;
        display: block;
        font-size: 0.5em;
        line-height: 2;
      }
      .tb1__grid {
        background-color: hsl(var(--hue), 10%, 10%);
        border-radius: 0.25em;
        box-shadow: -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 90%),
          0.0625em 0.0625em 0.125em hsl(var(--hue), 10%, 50%);
        display: grid;
        gap: 0.25em;
        grid-template-columns: repeat(3, 1fr);
        grid-template-rows: 1fr repeat(2, 3.75em);
        padding: 0.25em;
        width: 100%;
        height: 100%;
        transition: box-shadow var(--trans-dur);
      }
      .tb1__icon {
        display: block;
        margin: auto;
        width: 1.5em;
        height: 1.5em;
      }
      .tb1__rec {
        display: flex;
        align-items: center;
        gap: 0.375rem;
        grid-column-start: 1;
        grid-column-end: 3;
      }
      .tb1__rec-light {
        border-radius: 50%;
        box-shadow: 0 0 0 0.25rem var(--orange) inset;
        display: block;
        opacity: var(--display-dim);
        transition: opacity calc(var(--trans-dur) / 2);
        width: 0.625rem;
        height: 0.625rem;
      }
      .tb1__rec-light--on {
        opacity: 1;
      }
      .tb1__speaker-hole {
        background-color: hsl(var(--hue), 10%, 10%);
        border-radius: 50%;
        box-shadow: 0 -0.0625em 0.0625em hsl(var(--hue), 10%, 70%),
          0 0.0625em 0.0625em hsl(var(--hue), 10%, 90%);
        width: 50%;
        height: 50%;
        transition: box-shadow var(--trans-dur);
      }
      .tb1__speaker-hole:first-child,
      .tb1__speaker-hole:nth-child(83) {
        grid-column: 3;
      }
      .tb1__speaker-hole:nth-child(12),
      .tb1__speaker-hole:nth-child(70) {
        grid-column: 2;
      }
      .tb1__speaker-hole:nth-child(25) {
        grid-column: 1;
      }
      .tb1__sr-only {
        position: absolute;
        overflow: hidden;
        width: 1px;
        height: 1px;
      }

      /* Dark theme */
      @media (prefers-color-scheme: dark) {
        :root {
          --bg: hsl(var(--hue), 10%, 20%);
          --fg: hsl(var(--hue), 10%, 90%);
        }

        .tb1 {
          box-shadow: 0.125em 0.125em 0.125em hsl(var(--hue), 10%, 40%) inset,
            -0.0625em -0.0625em 0.1875em hsl(var(--hue), 10%, 20%) inset,
            0 0 0.5em rgba(0, 0, 0, 0.5);
        }
        .tb1,
        .tb1__cell {
          background-color: hsl(var(--hue), 10%, 30%);
        }
        .tb1__button {
          box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 20%) inset,
            -0.0625em 0 0.125em hsl(var(--hue), 10%, 40%) inset,
            0.0625em 0 0.125em hsl(var(--hue), 10%, 35%),
            -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
        }
        .tb1__cell {
          box-shadow: 0 0 0 0.125em var(--focus-t),
            0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 40%) inset,
            -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 20%) inset;
        }
        .tb1__cell[type='button']:focus-visible {
          box-shadow: 0 0 0 0.125em var(--focus),
            0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 40%) inset,
            -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 20%) inset;
        }
        .tb1__dial {
          box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 30%) inset,
            -0.0625em 0 0.125em hsl(var(--hue), 10%, 10%) inset,
            0.0625em 0 0.0125em hsl(var(--hue), 10%, 40%),
            -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
        }
        .tb1__grid {
          box-shadow: -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 40%),
            0.0625em 0.0625em 0.125em hsl(var(--hue), 10%, 20%);
        }
        .tb1__speaker-hole {
          box-shadow: 0 -0.0625em 0.0625em hsl(var(--hue), 10%, 30%),
            0 0.0625em 0.0625em hsl(var(--hue), 10%, 50%);
        }
      }
    </style>
  </head>
  <body>
    <div id="root"></div>

    <script type="module">
      import React, {
        StrictMode,
        useEffect,
        useRef,
        useState,
      } from 'https://esm.sh/react';
      import { createRoot } from 'https://esm.sh/react-dom/client';
      import gsap from 'https://esm.sh/gsap';
      import { useGSAP } from 'https://esm.sh/@gsap/react?deps=react@18.3.1';
      import { Draggable } from 'https://esm.sh/gsap/Draggable';
      gsap.registerPlugin(useGSAP, Draggable);
      createRoot(document.getElementById('root')).render(
        React.createElement(
          StrictMode,
          null,
          React.createElement(
            'main',
            null,
            React.createElement(IconSprites, null),
            React.createElement(TB1, null)
          )
        )
      );
      function IconSprites() {
        const viewBox = '0 0 24 24';
        return React.createElement(
          'svg',
          { width: '0', height: '0', 'aria-hidden': 'true' },
          React.createElement(
            'defs',
            null,
            React.createElement(
              'clipPath',
              { id: 'record-clip' },
              React.createElement('circle', { cx: '12', cy: '12', r: '12' })
            ),
            React.createElement(
              'linearGradient',
              { id: 'record-grad', x1: '1', y1: '0', x2: '0', y2: '1' },
              React.createElement('stop', { offset: '0%', stopColor: '#000' }),
              React.createElement('stop', { offset: '100%', stopColor: '#fff' })
            ),
            React.createElement(
              'mask',
              { id: 'record-mask' },
              React.createElement('rect', {
                x: '0',
                y: '0',
                width: '24',
                height: '24',
                fill: 'url(#record-grad)',
              })
            )
          ),
          React.createElement(
            'symbol',
            { id: 'play', viewBox: viewBox },
            React.createElement('polygon', {
              points: '8 6,18 12,8 18',
              fill: 'currentcolor',
              stroke: 'currentcolor',
              strokeLinejoin: 'round',
              strokeWidth: '2',
            })
          ),
          React.createElement(
            'symbol',
            { id: 'record', viewBox: viewBox },
            React.createElement(
              'g',
              {
                clipPath: 'url(#record-clip)',
                fill: 'none',
                strokeWidth: '18',
              },
              React.createElement('circle', {
                cx: '12',
                cy: '12',
                r: '12',
                stroke: 'var(--yellow-orange)',
              }),
              React.createElement(
                'g',
                { mask: 'url(#record-mask)' },
                React.createElement('circle', {
                  cx: '12',
                  cy: '12',
                  r: '12',
                  stroke: 'var(--red-orange)',
                })
              )
            )
          ),
          React.createElement(
            'symbol',
            { id: 'stop', viewBox: viewBox },
            React.createElement('rect', {
              fill: 'currentcolor',
              rx: '2',
              ry: '2',
              x: '6',
              y: '6',
              width: '12',
              height: '12',
            })
          )
        );
      }
      function TB1() {
        const [playing, setPlaying] = useState(false);
        const [recording, setRecording] = useState(false);
        const [volume, setVolume] = useState(10);
        const volumeBars = 10;
        const [micLevel, setMicLevel] = useState(0);
        const micBars = 9;
        const recorder = useRef(null);
        const audio = useRef(null);
        const frameId = useRef(0);
        // audio recording
        useEffect(() => {
          if (!recording) {
            const stopRecording = async () => {
              var _a;
              audio.current = await ((_a = recorder.current) === null ||
              _a === void 0
                ? void 0
                : _a.stop());
              setMicLevel(0);
              cancelAnimationFrame(frameId.current);
            };
            stopRecording();
            return;
          }
          const audioCtx = new AudioContext();
          let analyser;
          let bufferLength;
          let frequencyData;
          let waveData;
          const analyzeAudio = (stream) => {
            analyser = audioCtx.createAnalyser();
            analyser.smoothingTimeConstant = 0.75;
            analyser.fftSize = 1024;
            bufferLength = analyser.frequencyBinCount;
            frequencyData = new Uint8Array(bufferLength);
            waveData = new Uint8Array(bufferLength);
            const source = audioCtx.createMediaStreamSource(stream);
            source.connect(analyser);
            // start the loop for displaying the mic level
            getMicLevel();
          };
          const getMicLevel = () => {
            analyser.getByteFrequencyData(frequencyData);
            analyser.getByteTimeDomainData(waveData);
            const level = Math.round((frequencyData[0] / 255) * micBars);
            setMicLevel(level);
            frameId.current = requestAnimationFrame(getMicLevel);
          };
          const recordAudio = async () => {
            return await navigator.mediaDevices
              .getUserMedia({ audio: true, video: false })
              .then(
                (stream) =>
                  new Promise((resolve) => {
                    const mimeTypes = ['audio/mp4', 'audio/ogg'];
                    const mimeType = mimeTypes.find((type) =>
                      MediaRecorder.isTypeSupported(type)
                    );
                    const recorder = new MediaRecorder(stream, { mimeType });
                    const chunks = [];
                    recorder.addEventListener('dataavailable', (e) => {
                      var _a;
                      if (
                        ((_a = e.data) === null || _a === void 0
                          ? void 0
                          : _a.size) > 0
                      )
                        chunks.push(e.data);
                    });
                    // recorder methods
                    const start = () => recorder.start(500);
                    const stop = async () =>
                      await new Promise((resolve) => {
                        recorder.addEventListener('stop', () => {
                          const blob = new Blob(chunks, { type: mimeType });
                          const url = URL.createObjectURL(blob);
                          const audio = new Audio(url);
                          // methods for controlling the audio via top-level functions
                          const play = () => audio.play();
                          const stop = () => {
                            audio.pause();
                            audio.currentTime = 0;
                          };
                          const volumeTo = (volume) => {
                            audio.volume = volume;
                          };
                          // audio listeners
                          audio.addEventListener('pause', () =>
                            setPlaying(false)
                          );
                          audio.addEventListener('ended', () => stop());
                          // stop the stream
                          stream.getTracks().forEach((track) => track.stop());
                          resolve({ blob, url, play, stop, volumeTo });
                        });
                        recorder.stop();
                      });
                    resolve({ start, stop, stream });
                  })
              )
              .catch(() => setRecording(false));
          };
          const startRecording = async () => {
            recorder.current = await recordAudio();
            if (!recorder.current) return;
            recorder.current.start();
            analyzeAudio(recorder.current.stream);
          };
          startRecording();
          return () => cancelAnimationFrame(frameId.current);
        }, [recording]);
        // volume control
        useEffect(() => {
          if (!audio.current) return;
          audio.current.volumeTo(volume / volumeBars);
        }, [volume]);
        /** Play the recorded audio. */
        async function play() {
          var _a;
          if (recording || playing) return;
          try {
            setPlaying(true);
            await ((_a = audio.current) === null || _a === void 0
              ? void 0
              : _a.play());
          } catch (_b) {
            setPlaying(false);
          }
        }
        /** Stop playing the recorded audio. */
        function stop() {
          var _a;
          if (recording || !playing) return;
          (_a = audio.current) === null || _a === void 0 ? void 0 : _a.stop();
        }
        /** Record or stop recording audio (secure connection required). */
        function record() {
          var _a;
          // `mediaDevices` is disabled in insecure HTTP
          if (!navigator.mediaDevices) {
            alert('Connection isn’t secure for recording!');
            return;
          }
          stop();
          setRecording(!recording);
          if (recording) return;
          // ensure the current volume is retained
          (_a = audio.current) === null || _a === void 0
            ? void 0
            : _a.volumeTo(volume / volumeBars);
        }
        return React.createElement(
          'div',
          { className: 'tb1' },
          React.createElement(
            TB1Grid,
            null,
            React.createElement(TB1Speakers, null),
            React.createElement(
              TB1Button,
              { icon: 'play', action: play },
              'Play'
            ),
            React.createElement(
              TB1Button,
              { icon: 'stop', action: stop },
              'Stop'
            ),
            React.createElement(
              TB1Display,
              null,
              React.createElement(TB1Rec, { recording: recording }),
              React.createElement(TB1Bars, {
                color: 'blue',
                label: 'Vol',
                level: volume,
                levels: volumeBars,
              }),
              React.createElement(TB1Bars, {
                color: 'purple',
                label: 'Mic',
                level: micLevel,
                levels: micBars,
                tall: true,
              })
            ),
            React.createElement(
              TB1Button,
              { icon: 'record', action: record },
              'Record'
            ),
            React.createElement(
              TB1Dial,
              { tick: volume, ticks: volumeBars, action: setVolume },
              'Volume'
            )
          )
        );
      }
      function TB1Bars({ color, label, level = 0, levels = 0, tall }) {
        const bars = [];
        for (let l = 0; l < levels; ++l) {
          const _color = color ? ` tb1__bar--${color}` : '';
          const _tall = tall ? ' tb1__bar--tall' : '';
          // use the nearest bar if the level is a decimal
          const _on = l < Math.round(level) ? ' tb1__bar--on' : '';
          const width = !tall ? ` tb1__bar--${25 + l * 5}p` : ' tb1__bar--25p';
          bars.push(
            React.createElement('div', {
              key: l,
              className: `tb1__bar${_color}${_tall}${_on}${width}`,
            })
          );
        }
        return React.createElement(
          'div',
          { className: 'tb1__bars' },
          bars,
          React.createElement(
            'span',
            { className: 'tb1__display-label' },
            label
          )
        );
      }
      function TB1Button({ action, children, icon }) {
        return React.createElement(
          'button',
          { className: 'tb1__cell', type: 'button', onClick: action },
          React.createElement(
            'div',
            { className: 'tb1__button' },
            React.createElement(
              'svg',
              {
                className: 'tb1__icon',
                width: '20px',
                height: '20px',
                'aria-hidden': 'true',
              },
              React.createElement('use', { href: `#${icon}` })
            ),
            React.createElement('span', { className: 'tb1__sr-only' }, children)
          )
        );
      }
      function TB1Dial({ action, children, tick, ticks }) {
        const dialRef = useRef(null);
        const startAngle = (tick / ticks) * 360;
        const [angle, setAngle] = useState(startAngle);
        const [isKeyDown, setIsKeydown] = useState(false);
        const _tick = (angle / 360) * ticks;
        const percent = `${Math.round((angle / 360) * 100)}%`;
        const toRad = Math.PI / 180;
        const shadowRotate = {
          '--shadow-before-rotate': -angle,
          '--shadow-after1-x': `${
            (Math.cos((-45 + angle) * toRad) / 2 + 0.5) * 100
          }%`,
          '--shadow-after1-y': `${
            (Math.cos((45 + angle) * toRad) / 2 + 0.5) * 100
          }%`,
          '--shadow-after2-x': `${
            (-Math.cos((-45 + angle) * toRad) / 2 + 0.5) * 100
          }%`,
          '--shadow-after2-y': `${
            (-Math.cos((45 + angle) * toRad) / 2 + 0.5) * 100
          }%`,
        };
        // action to update the tick when the angle changes
        useEffect(() => {
          action === null || action === void 0 ? void 0 : action(_tick);
        }, [action, _tick]);
        // initiate the draggable
        useEffect(() => {
          Draggable.create(dialRef.current, {
            type: 'rotation',
            bounds: {
              minRotation: 0,
              maxRotation: 360,
            },
            onDrag: function () {
              setAngle(this.rotation);
            },
          });
        }, []);
        // run a transition when adjusting using arrow keys
        useGSAP(() => {
          if (isKeyDown) {
            gsap.to(dialRef.current, {
              rotation: angle,
              duration: 0.15,
            });
          }
        }, [angle]);
        // starting angle
        useGSAP(() => {
          gsap.to(dialRef.current, {
            rotation: startAngle,
            duration: 0,
          });
        }, []);
        /** Go to the previous tick. */
        function tickDecrement() {
          let previousTick = _tick - 1;
          if (previousTick < 0) {
            previousTick = 0;
          }
          setAngle((previousTick / ticks) * 360);
        }
        /** Go to the next tick. */
        function tickIncrement() {
          let nextTick = _tick + 1;
          if (nextTick > ticks) {
            nextTick = ticks;
          }
          setAngle((nextTick / ticks) * 360);
        }
        /**
         * Perform a keyboard action on this dial.
         * @param e Keyboard event
         */
        function keyboardAction(e) {
          const increment = e.code === 'ArrowUp' || e.code === 'ArrowRight';
          const decrement = e.code === 'ArrowDown' || e.code === 'ArrowLeft';
          if (increment || decrement) {
            e.preventDefault();
            setIsKeydown(true);
          }
          if (increment) {
            tickIncrement();
          } else if (decrement) {
            tickDecrement();
          }
        }
        return React.createElement(
          'div',
          { className: 'tb1__cell' },
          React.createElement(
            'div',
            { className: 'tb1__dial' },
            React.createElement(
              'button',
              {
                className: 'tb1__dial-control',
                type: 'button',
                ref: dialRef,
                onKeyDown: keyboardAction,
                onKeyUp: () => setIsKeydown(false),
                'aria-description': percent,
                style: shadowRotate,
              },
              React.createElement(
                'span',
                { className: 'tb1__sr-only' },
                children
              )
            )
          )
        );
      }
      function TB1Display({ children }) {
        return React.createElement(
          'div',
          { className: 'tb1__cell tb1__cell--display' },
          children
        );
      }
      function TB1Grid({ children }) {
        return React.createElement('div', { className: 'tb1__grid' }, children);
      }
      function TB1Rec({ recording }) {
        return React.createElement(
          'div',
          { className: 'tb1__rec' },
          React.createElement('span', {
            className: `tb1__rec-light${
              recording === true ? ' tb1__rec-light--on' : ''
            }`,
          }),
          React.createElement(
            'span',
            { className: 'tb1__display-label' },
            'Rec'
          )
        );
      }
      function TB1Speakers() {
        const holes = [];
        const holeCount = 93;
        for (let h = 0; h < holeCount; ++h) {
          holes.push(
            React.createElement('div', {
              key: h,
              className: 'tb1__speaker-hole',
            })
          );
        }
        return React.createElement(
          'div',
          { className: 'tb1__cell tb1__cell--speakers' },
          holes
        );
      }
    </script>
  </body>
</html>