🎛️

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

1 min read 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秒単位で刻まれているのをなんとかしたい。

Discussion

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

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' });
}
ログインするとコメントできます