音声を波形表示する際のデータ処理

Published:2024-12-15 | Updated:2024-12-20

--- みすてむずアドベントカレンダー2024 19日目 ---


はじめに

これは、みすてむず いず みすきーしすてむず Advent Calendar 2024 19日目の記事です。

みすてむずは、ITに関わる人のためのMisskeyサーバーです。

こんなアプリを開発中

Windows用の音声編集アプリ(になる予定)

ResonanceParrotScreenShot
開発中アプリ

今後の開発予定は、再生中・録音中にエフェクトをかける機能と、音声ファイル自体にエフェクトをかけたり編集する機能と、上部の空きスペースにタブを設置して複数ファイル対応にしたりを考えています。
まあ、その前に目立たないところをいっぱい作ったり直したりしないといけない。

開発言語はRust。
GUIや音声デバイス周りはwindows-rs経由でWindows32APIを叩いていて、描画はDirectX、音声はWASAPIを使っています。

なんの記事?

音声データを扱うなら波形表示したい、ということで今開発中なのですが、その際に音声データをどうやって処理したかを中心に振り返る記事です。
振り返ってるだけなのでこの方式で○%速くなったよとかいうデータはありません。しょうがない話。

こんな表示がしたい

冒頭のスクリーンショットのように、横軸に時間、縦軸に音の信号強度を取っています。とても普通。
あとは以下の通りです。

  • 音声データは基本的に線でつなぐ(サンプリングしたデータを線でつなぐな、的なお話があるのかもしれませんが、今回はそうしたかったのでそうしてます)。
  • 再生ヘッドが一定の位置に固定されて波形が流れる表示、と、再生ヘッドが範囲外に言ったら範囲を遷移させる表示、がある。
  • 波形を表示するウィンドウのサイズは可変とする。画像を拡大縮小するのではなくウィンドウサイズに合わせて描画する。
  • ウィンドウに表示する時間の幅は可変。サンプリングレートの整数倍などに縛られないようにする。
  • CD-DA(音楽CD)程度のフォーマットで数分間の音声を一画面で表示。
  • ある程度のリアルタイム性(50msくらいで表示更新できる)。

波形表示に関わってくる要素

まず、波形表示に関わってくる要素を大雑把に確認しておきます。

OSとか言語とかライブラリとか

この辺は、かなり初期からRust製のWindowsで動く音声アプリをWASAPI使って作りたいっていう前提があったので、あまり考慮してません。
表示のDirectXまわりはいろいろ悩んだんですが、今回はほぼ触れません(振り返ろうとしたけど、よくわかんなくなっちゃった)。

音声データのフォーマット

音声データのフォーマットリニアPCMのみです。 音声信号をチャンネルごとにサンプリング周波数でA/D変換したデータが時間分だけ存在します。

詳細はこちら
パルス符号変調 - Wikipedia
https://ja.wikipedia.org/wiki/%E3%83%91%E3%83%AB%E3%82%B9%E7%AC%A6%E5%8F%B7%E5%A4%89%E8%AA%BF

CD-DA(いわゆる音楽CD)だとそれぞれのスペックはこんな感じです。

  • サンプリング周波数 : 44.1kHz
  • ビット数 : 16bit
  • チャンネル数:2(ステレオ)

たとえば、1秒間のデータの個数は 44,100(Hz) × 16(bit) × 2(channel) = 1,411,200 個のデータになります。

表示ウィンドウの幅、ウィンドウに表示する時間の長さ

ウィンドウ幅が増えるとピクセル数も増え、時間軸も細かくとることができますが、 その分、ウィンドウ幅が増えると描画できる点が増えるので描画が大変になります。

また、一度に表示する時間の長さが増えると、その分、表示対象になる音声データが増え、波形表示するための計算量が増えます。

描画処理の発生タイミング

以下が、描画処理の発生タイミングとなります。

  • OSから必要と判断されたとき
  • ユーザーから波形表示に関連する操作があったとき
  • 再生中&録音中は一定間隔(暫定で音声のバッファ処理タイミングと同じとする)

ユーザー操作については、多少のラグがあってもカクつくくらいの話で、捜査終了後に追いつくことができますが、 再生中&録音中については一定間隔でイベントが発生し続けるため、基本的にはこの周期で描画できることが必要となります(まれに遅くなっても追いつけるなら一応可)。

問題になりそうなこと

上記を踏まえるとだいたい以下のことが問題になりそうです(なりました)。

  • 座標計算が難しそう
  • 処理速度

全体の方針(事前の処理 & 描画時の処理)

やりたい表示と関係する要素を考慮しつつ、どのような処理をいつやるかを考えます。

まず、波形を描画する処理は、下記のような段階を踏んでいきます。

  • 音声データ → 座標データ → 画像 → ウィンドウに描画
    (画像は経由しないこともある)

事前処理としてどこまでやっておくことで描画処理が簡単になりますが、その分、柔軟性に問題が出てきます。

例えば、ファイルを開いた直後に、音声データを全て波形の画像に変換してしまえば、スクロールや再生時の描画処理はそれの一部を表示するだけとなり簡単になりますが、 時間軸の拡大縮小や録音時の音声データ更新への対応が難しくなります。

今回は、時間軸の拡大縮小や録音時もサクサク行いたいため、表示時に音声データから計算を行うことを基本とし、 速度等の関係で必要になったら適宜事前にデータを準備したり他の段階の有効利用を考えていくことにします。

実装とデータ処理

というわけで実装していったのですが、大変だったところを振り返りつつ、こんなデータ処理をしたよというメインコンテンツ(?)です。

音声データと表示ウィンドウのピクセルで時間の単位が違う

音声データ、つまりリニアPCMのある1つのデータはその時点での信号レベルで、時間としては点と捉えられます(たぶん)。
一方、波形表示するウィンドウのある1ピクセルは前のピクセルが終わり次のピクセルが始まるまでの信号レベルの集合となり、時間としては幅と捉えられます(たぶん)。
これにより、音声データの区間⇔ピクセルの区間 の変換は違った意味を持ちます。

さらに、音声データの1/サンプリングレートとウィンドウのピクセル幅の時間も異なるので、変換するための計算が必要になります。
例えば、波形を表示するウィンドウの幅が800ピクセル、44100Hzでサンプリングされたデータを5秒間表示するとします。
1ピクセル当たりのデータ数は、44100(Hz) * 5(sec) / 800(px) = 275.625個 になります。
小数点でてきたな?

上記を考慮しつつ実装を考えます。 例として音声データの更新を図にするとこんな感じです。

Example of Calcurating Audio Data Arrays and Window Pixels
音声データと波形表示ウィンドウのピクセルの変換例

合ってるかな?合ってるよね?たぶん。
まとめると下記のようになります。(変換係数 = 音声データの間隔/ピクセルの間隔、とします)

  • ピクセルの区間 → 音声データの区間 への変換
    • 音声データの始点 : ピクセル区間の始点の添字に変換係数をかけて小数点切り上げ
    • 音声データの終点 : ピクセル区間の終点の添字に変換係数をかけて小数点切り上げ
  • 音声データの区間 → ピクセルの区間 への変換
    • 始点 : 音声データ区間の始点の添字を変換係数で割って小数点切り捨て/li>
    • 終点 : 音声データ区間の終点の添字を-1(※1)してから変換係数で割って小数点切り捨てして(※2)+1
      ※1:区間に含まれる最後の音声データ。※2:区間に含まれる最後のピクセルになる

波形の描画速度

なんとなくわかっていたことなのですが、愚直に表示ウィンドウ内に必要なだけの音声データを全部描画しようとすると、(特に表示する区間が大きい場合)大量のデータを短時間で処理することになり描画しきれなくなります。
そこでいろいろ工夫します。

元データを事前に圧縮しておく

波形を表示する前に、ウィンドウに表示される区間の分だけ音声データを走査して座標データを作る必要があります。
ウィンドウに表示される区間が長い場合、大量の音声データを走査しなければいけません。
そこで、決められた数の音声データごとに最大値と最小値のみを保存した圧縮データをあらかじめ作成しておくことにします。
決められた数は、現状、256(0x100), 65536(0x10000), 16777216(0x1000000)としました。
この圧縮された音声データを使うことで走査時間を大幅に短縮できます。

この圧縮データはファイル読み込み時に作成され、音声データ更新時に更新されます。
音声データ更新に必要な処理量は、仮に1データだけ更新された場合を考えると、

  • 元データ : 1データだけ更新
  • 1/256圧縮データ : 元データを256個走査して更新
  • 1/65536圧縮データ : 1/256圧縮データを256個走査して更新
  • 1/16777216圧縮データ : 1/65536圧縮データを256個走査して更新

という具合に段階ごとの倍率の数の和で抑えることが可能になります。

図にするとこんな感じです(図では1/2,1/4,1/8の圧縮にしてあります)。

Example of Compresssed Audio Data
圧縮音声データの更新例

表示時は1ピクセル幅に入るデータ数を計算し、1ピクセル幅に0~255個のデータが入る場合は元の音声データを、1ピクセル幅に256~65535個のデータが入る場合は1/16圧縮データを、1ピクセル幅に(以下略)、使うようにすることで走査するデータ数が減らせます。

座標データへの変換時に圧縮する

次は音声データから座標データに変換時にデータを圧縮します。

幅1ピクセル内に多くのデータがあっても表示できるのは1本の線だけです。その観点で同じピクセル内に打たれる座標データを圧縮していきます。

幅1ピクセルあたりのデータ数が少ない場合(0~255)

「元データを事前に圧縮しておく」の章で、圧縮されていないの元の音声データへの処理です。

ある幅1ピクセルに座標データが打たれる場合、そのピクセルに入ってきた直後・最大値・最小値・そのピクセルから出ていく直前、の4データのみを使用するようにしました。 直前・直後のデータは波形を線で繋ぐために使います。

Compress conversion to coordinates
音声データを座標データに圧縮変換1

これにより、表示の際に打たれる点は一番多いときでも各チャンネルごとに波形表示ウィンドウの幅のほぼ4倍となります。ウィンドウ幅が1000pixelなら4000点程度となります。 ある程度の時間幅を表示する場合、例えば44.1kHz×5秒なら220,500個のデータが4000点程度になるのでかなりデータが削れることになります。

なお、この方法だと、1ピクセル幅に入るデータが1以上4未満の場合も1ピクセルごとに4つのデータを持ってしまうという欠点もありますが、 各チャンネルの座標データ数が「波形表示ウィンドウの幅のほぼ4倍」を超えることがないという点を重視しています。

なお、1ピクセル幅に入るデータが1未満のときにもこの方法を使っていて、ピクセルに入ってきた直後・そのピクセルから出ていく直前 のデータをつなぐことで対応しています。

幅1ピクセルあたりのデータ数が多い場合(256~)

こちらは、「元データを事前に圧縮する」で圧縮したデータへの処理です。
この場合は、最大値・最小値の2データのみを使用します。

Compress conversion to coordinates
音声データを座標データに圧縮変換2

この場合は1ピクセルごとに2点、ウィンドウ幅1000pixelなら2000点程度の点で済みます。 なお、最大値・最小値の2データのみとした理由は、この表示になるのは元々圧縮されたデータを使用するときであり圧縮率も高いため、1データずつを線でつなぐことの意味が薄れているいるためです。

座標データを再利用する

さて、再生ヘッドを画面上で固定しての再生、スクロールなど、波形が平行移動するような画面遷移が多くあります。
また、録音においても新しく録音された箇所以外では表示される波形は変わりません。
ということは、直前に描画したデータの大部分が再利用できるケースが多いということになります。

再利用するデータですが、ビットマップ画像は目盛りなどが載っていたり端の処理がめんどくさいという理由で(やろうと思えばできそうだけど)、座標データを再利用することにします。
座標データは音声データに比べ「座標データを圧縮する」で圧縮済みなのでこれを再利用することで処理量の削減につながります。

概要としては下図のような感じです。

Determining which segment to update
前回表示時の座標を使用する区間と新規計算する区間の分け方

処理としてはこんな感じに処理します。

  1. 描画した座標を前回描画座標として保存する。また、描画した区間を前回描画区間として保存する。
  2. 描画と描画の間のタイミングで、録音や編集があった場合、その部分を無効区間として保存する。
  3. 次の描画時に、前回描画区間と無効区間を合成し、さらに今回描画区間と合成する。それぞれの区間に前回描画有効区間or無効区間かのフラグを設定する。
  4. フラグに応じて、前回描画座標を使うか、音声データから座標を計算するか、を決めるて座標を計算してそれぞれの区間を繋げる。
  5. 座標を描画。

その他:データ処理以外の速度関係

描画APIの変更

DirectXを使用して描画しているのですが、開発の途中でID2D1HwndRenderTargetからID2D1DeviceContext6に変更にしてswap chainを使えるように変更しました。
これにより、特に描画するデータが多い場合にかなり描画速度が上がりました。
そこで、ここの解説をしようと思ってたんだけど、見直してたらよくわからなくなっちゃったのでパス。

描画する線を透明にしない・アンチエイリアシングしないように設定

画像処理は全然詳しくないので、DirectXの処理の中身がどうなっているかは知りませんが、 単色で不透明が一番処理が軽そうですし、(描画のジャギを軽減する)アンチエイリアシングもしないほうが処理が軽そうです。
どちらが効いているのかはよくわかりませんが結構描画速度上がりました。

Rust / windows-rs

特に処理速度を狙ったわけではないんですが、処理速度で悩む必要のない言語とライブラリでした。

まとめ

というわけで、いろいろ組み合わせ組み合わせした結果、結構いい感じになってきたと思います。
まだ細かい試験はしてないけどね。

みすてむず いず みすきーしすてむず Advent Calendar 2024 19日目の記事でした。

更新

2024-12-17 : 一部画像、内容訂正 2024-12-20 : 誤字修正

前の投稿: 2023年とその前に... 次の投稿: 2024年に買ったも...