LoginSignup
22
5

More than 1 year has passed since last update.

????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

image.png

音割れメーカー

これは何?

オンラインで簡単に音割れ音源が作れてしまうツールです。
「音割れポッター」というワードにピンと来ない人はぜひ下の動画を見てほしいです。

自己紹介

ナモすけです。3年目のクソアプリアドベントカレンダーになります。
去年はReactでPHP関数名しりとりを作りました。「k攻め」のような攻略法が発見されたり、PHP関数名しりとりをチートで倒す方が現れたりして面白かったです。

さて、今年は何を作ろうかと考えたときに、何年か前に音割れポッターなるものが流行っていたことを思い出しました。
音割れ動画は面白いのですが、実はけっこう作るのが難しく、きれいな音割れが作れるツールって、実はそんなに多くないんですよね。
そこで、「簡単に音割れさせられる音割れメーカーを作ろう」と思い至りました。

使い方

image.png
「音声・動画ファイルを選択」から、mp3/wav/mp4など任意のコンテンツファイルをアップロードすると、自動的に変換が始まります。
現時点でiOS端末からは変換できないようです。
image.png
変換が終わると動画や音声が再生できるようになり、ダウンロードできるようになります!
かなりの爆音で再生されるので、くれぐれも再生する際は音量に十分注意してください。

技術構成

ソースコードはこちらです。

全体のフレームワークにはNext.js (TypeScript) を、そして変換処理にはWebAssembly版のFFmpeg、「ffmpeg.wasm」を使いました。

FFmpegは元々C言語で組まれた巨大なソフトウェアですが、WebAssemblyのおかげでJavaScriptから簡単に使えるようになりました。最初はNode.jsサーバで変換しようと思っていましたが、パフォーマンス面、セキュリティ面、法的側面を考え、ローカルで処理するようにしました。
残念ながら、iOS端末では使えないようです。iOS 15.2 (2021/12/13) によってShared Array Bufferが使えない問題は解消されたのですが、Out of memoryなど他の問題が残っているようです。

Next.jsからffmpeg.wasmを使う

ffmpeg.wasm公式サイトにはJavaScriptで<script>タグを使う方法しか書かれていないので、そのままNext.jsやTypeScriptで使うことはできません。参照すべきリファレンスや注意点をここに残しておこうと思います。

まずはREADMEを読んでみます。

SharedArrayBuffer is only available to pages that are cross-origin isolated. So you need to host your own server with Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin headers to use ffmpeg.wasm.

と書かれています。これは、ffmpeg.wasmに使われているSharedArrayBufferという機能を使うために、サーバ側でカスタムヘッダを設定する必要があるよ~と言っています。
なので、next.config.jsを変更します。

next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  reactStrictMode: true,
  async headers() {
    return [
      {
        source: "/",
        headers: [
          {
            key: "Cross-Origin-Embedder-Policy",
            value: "require-corp",
          },
          {
            key: "Cross-Origin-Opener-Policy",
            value: "same-origin",
          },
        ],
      },
    ];
  },
};

次に、ffmpeg.wasmが公開しているCreate React Appのサンプルを見てみます。

これを見ると、パッケージは@ffmpeg/ffmpegの導入のみで良いみたいです。@ffmpeg/coreは使われません。その代わりに、coreに当たるファイルのパスを指定してあげる必要があります。(このサンプルアプリケーションには指定されていません。どうやって動いているのか正直わかりません…)

また、このサンプルではonClickからasync関数を呼び出していますが、ffmpeg本体のロードに少し時間がかかるので、コンポーネントが読み込まれた時点でロードを開始すべきです。そこで、useEffect(func, [])の中にもろもろ書いてあげることにします。useEffectを使わずにコンポーネントに直接書いてしまうと、ブラウザで実行されるべきなのに、Next.jsにサーバ側で実行されてしまいます。

これらを踏まえたコードの一部がこちらです。

pages/index.tsx
useEffect(() => {
    (async () => {
      const ffmpeg = createFFmpeg({
        corePath: "https://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js",
        logger: (log) => {
          setProgressMessage(log.message);
        },
        progress: (p) => {
          setProgressValue(p.ratio);
        },
      });
      try {
        await ffmpeg.load();
      } catch (e) {
        console.error(e);
        setUnsupported(true);
      }
      onFileInputChange.current = async ({
        target: { files },
      }: React.ChangeEvent<HTMLInputElement>) => {
        if (files) {
          setIsProgress(true);
          const { name } = files[0];
          const ext = name.split(".").splice(-1)[0];
          const mediaType = files[0].type.split("/")[0];
          ffmpeg.FS(
            "writeFile",
            encodeURIComponent(name),
            await fetchFile(files[0])
          );
          if (mediaType === "audio" || mediaType === "video") {
            if (mediaType === "audio") {
              await ffmpeg.run(
                "-i",
                encodeURIComponent(name),
                "-af",
                "volume=91dB",
                "-c:a",
                "pcm_s16le",
                "step1.wav"
              );
              await ffmpeg.run("-i", "step1.wav", `output.${ext}`);
              setMediaTypeState("audio");
            } else if (mediaType === "video") {
              await ffmpeg.run(
                "-i",
                encodeURIComponent(name),
                "-vn",
                "-af",
                "volume=91dB",
                "-c:a",
                "pcm_s16le",
                "step1.wav"
              );
              await ffmpeg.run(
                "-i",
                encodeURIComponent(name),
                "-i",
                "step1.wav",
                "-c:v",
                "copy",
                "-c:a",
                "aac",
                "-map",
                "0:v:0",
                "-map",
                "1:a:0",
                `output.${ext}`
              );
              setMediaTypeState("video");
            }
            const data = ffmpeg.FS("readFile", `output.${ext}`);
            setMediaSrc(
              URL.createObjectURL(
                new Blob([data.buffer], { type: files[0].type })
              )
            );
            setOutputSize(data.byteLength);
            setOutputName(`音割れ${name}`);
          } else {
            console.log("音声ファイルか動画ファイルを指定してください");
          }
          setIsProgress(false);
        }
      };
    })();
  }, []);

ものすごく読みづらくて申し訳ないのですが、coreファイルを指定するcorePathhttps://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.jsを指定しています。
その他の部分はffmpeg.wasmのメソッドを使いつつ、stateを色々書き換えている感じです。

ffmpeg.wasmのメソッドについてはこちらのAPIリファレンスが参考になります。

最高の音割れを作る

音割れを作るのは少し難しかったです。最初は単純に音量を50dBほど上げていたのですが、これでは潰れるべき音が潰れていないため、プレーヤーによって音割れのクオリティに差が出てしまいます。例えばVLC Media Playerは音量を自動的に抑えるようになっていて、音割れしませんでした。
そこで音声の専門家に教えを乞い、音声コーデックにpcm_s16leを使うといいよと教わりました。また、91dB上げると完全な音割れが作れると言われたので、音割れメーカーでは音量を91dB上げるようにしています。
pcm_s16leはwav出力でしか使えないので、一度音声をwavで出力してから、元のファイル形式に再エンコードしています。

その他工夫した点

ダウンロードする際にファイル名やサイズが表示されたらオシャレかな~と思ったので、react-tooltipでツールチップを実装しています。

image.png

バイトをKBとかMBとかに変換するために、StackOverflowに書かれていた関数を使いました。

function formatBytes(bytes: number, decimals = 2) {
    if (bytes === 0) return "0 Bytes";
    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
  }

音声・動画プレーヤーは、与えられたファイルの形式によって出し分けています。皆様の鼓膜と社会生活を守るため、自動再生されず、ミュートされた状態で表示されるようにしています。

また、背景やロゴは自作したものとフリー素材を使いました。左右の虎縞は自作したものですが、backgroundプロパティってカンマで繋げることで重ね掛けできるんですね。思っていたよりオシャレなデザインができて驚きました。

最後に

実はNext.jsで一からアプリを作るのはこれが初めてでした。慣れない技術が多く、また色々凝った作りにしていたら遅刻してしまいましたが、クオリティ高めに仕上がったと思うので、ぜひ楽しんでいただけたら幸いです。

それから、もしよろしければ最近p5.jsで作った「ラー油」も自信作なので見てもらえたら嬉しいです。醤油皿にラー油をポタポタしたり、箸でラー油をくっつけて遊べます!

最後までお読みいただきありがとうございました!

22
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
5