🎛️

ブラウザで動く波形編集アプリをReactで作った

2020/12/11に公開
2

リンク

以下の URL から登録不要で起動できます。

https://fono.app

目指したもの

誰でも直感的に操作がわかる波形編集アプリ。

機能

  • 複数ファイルのインポート、結合
  • フェード
  • クリップごとの音量調整
  • クリップの分割・長さの調整
  • コンプレッサー
  • EQ
  • リバーブ
  • VU メーター
  • wav ファイルでのエクスポート

技術スタック

  • Typescript
  • React
  • styled-components
  • zustand
  • Tone.js
  • Netlify

React とか styled-components について言うことは特に無いので、zustand と Tone.js のみ所感を書きます。

zustand - 状態管理ライブラリ

zustand は redux のような状態管理ライブラリです。ただ redux と比べて以下のような特徴があります。

  • flux アーキテクチャのような縛りがなく、ボイラープレートコードが少ない。
  • ストアはただのグローバルオブジェクトみたいなイメージで、なんでも放り込める(それが良いかは別として)。
  • selector は redux 同様で柔軟に書ける。
  • Provider で囲む必要がない。

シンプルに、サクッとグローバルステートを管理するぶんにはかなり便利に感じます。それでいて selector や購読を使いこなせばパフォーマンスチューニングもしっかりできる。

ただ、そうとうに柔軟なことができてしまうので、大人数で大規模な開発をする場合は向かないかもしれません。

Tone.js

Tone.js には全面的にお世話になっています。

Web アプリでオーディオを扱う以上、WebAudioAPI を使うことになるわけですが、Tone.js はさらに高レベルの API を提供してくれます。

たとえば、生の WebAudioAPI では AudioContext の持つクロックを使用して音源の再生タイミングをスケジューリングするんですが、これはコンテクスト作成時からひたすら加算されていく時間に過ぎません。音楽的な「拍」の概念はありません。

Tone.js の Transport はその無機的なクロックをいい感じにラッピングして、音楽的なタイミングでの再生をサポートしてくれます。リピートや、音源を途中から再生したときの処理もよしなにしてくれます。

ただ一点引っかかったのは、3 バンドイコライザの実装である EQ3 の音質がかなりアレだということです。github の issue としても挙がっていましたが、全バンドを0にしていてもカットオフ周波数あたりが相当に削れているようです。メンテナによると仕様らしいので、僕の使い方がどこか間違っていたのか・・・?  Fono では Biquad Filter を使って自前の実装をしています。

TODO

波形のレンダリング

オーディオ波形は canvas に描画しています。wavesurferの実装を参考にしたんですが、パフォーマンス不足によりズームやクリップ長の変更時にガクガクします。別に javascript の限界というわけではなく、他の波形編集アプリを触ってみるとスムースに動いているので、ここは改善したいです。

エフェクト

エフェクトの種類もそうですが、範囲指定で適用できるようにしたいですね。

タイムラインの表示

拡大・縮小してもつねに 1 秒単位で刻まれているのをなんとかしたい。

GitHubで編集を提案

Discussion

Temash!Temash!

wavのエクスポートはTone.jsの機能ですか?
Web Auido APIでミックスしたオーディオファイルをエクスポートする方法をググると、録音する方法ばっかり出てくるので、「一々音を最初から最後まで再生して録音することなく、エクスポートできてる!」と思って感動しました。

sabigarasabigara

AudioBuffer からwavファイルを生成するのは以下のようなスクリプトで行っています(このブログを参考にしました)。

ただマスターに接続されているエフェクトや音源をすべて書き出したい場合は、 OfflineContext にバックグラウンドでレンダリングして、そのバッファーをソースにする必要があります。しかもプレイバック用のノードをそのまま使えるわけではないので、常に同期しておくか最後にクローンしなければいけないので結構面倒です。(という理解ですが、間違っていたらすみません・・・)

OfflineContext 自体はWebAudioAPIに存在していますが、これもTone.jsを使うといろいろな便利機能が使えます。

export function makeWav(src: AudioBuffer) {
  const numOfChan = src.numberOfChannels;
  const length = src.length * numOfChan * 2 + 44;
  const buffer = new ArrayBuffer(length);
  const view = new DataView(buffer);
  const channels = [];

  let pos = 0;

  const setUint16 = (data: number) => {
    view.setUint16(pos, data, true);
    pos += 2;
  };

  const setUint32 = (data: number) => {
    view.setUint32(pos, data, true);
    pos += 4;
  };

  // write WAVE header
  setUint32(0x46464952); // "RIFF"
  setUint32(length - 8); // file length - 8
  setUint32(0x45564157); // "WAVE"

  setUint32(0x20746d66); // "fmt " chunk
  setUint32(16); // length = 16
  setUint16(1); // PCM (uncompressed)
  setUint16(numOfChan);
  setUint32(src.sampleRate);
  setUint32(src.sampleRate * 2 * numOfChan); // avg. bytes/sec
  setUint16(numOfChan * 2); // block-align
  setUint16(16); // 16-bit

  setUint32(0x61746164); // "data" - chunk
  setUint32(length - pos - 4); // chunk length

  // write interleaved data
  for (let i = 0; i < src.numberOfChannels; i++)
    channels.push(src.getChannelData(i));

  let offset = 0;
  while (pos < length) {
    for (let i = 0; i < numOfChan; i++) {
      // interleave channels
      let sample = Math.max(-1, Math.min(1, (channels[i] ?? [])[offset] ?? 0)); // clamp
      sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0; // scale to 16-bit signed int
      view.setInt16(pos, sample, true); // write 16-bit sample
      pos += 2;
    }
    offset++; // next source sample
  }

  return new Blob([buffer], { type: 'audio/wav' });
}