2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C#】Waveファイルを自力で読み書きし音声を自動分割するアプリケーションを書いた話

Posted at

はじめに

今回は、音声ファイルの無音を検知して自動で分割するアプリケーションを書いたのでその技術的な話を記録として書いておきます。開発記シリーズです。そしてまたもマルチメディア関係のアプリケーションです。

後述しますが、1~2年前に書いたコードをそのまま使いまわしているので書き方が最適ではない、あるいは古臭い部分がありますが、温かい目で見守ってくれると嬉しいです。

ソースコード全文は以下のリポジトリにあります。
ベータ版のビルド済みバイナリは以下にあります。

Wave入出力

今回はwaveファイルをライブラリの力を借りずに自力で読み書きしてみようと思います。Waveファイル以外の読み取りには以前書いたffmpegの記事が参考になるかもしれません(宣伝)。

Waveヘッダーの構造

まず、Waveのヘッダーの構造を理解する必要があります。適宜バイナリエディタを併用するなどすると理解が早いでしょう。

Waveファイルの最初の12バイトは以下のような構造になっています。

開始バイト 終了バイト 内容
0 3 "RIFF"という文字列
4 7 全体のファイルサイズ[bytes]
8 11 "WAVE"という文字列

文字列はASCII(またはUTF-8などのASCII互換なcharset)で読み書きします。
最初の12バイトがこのように読めない場合は、別のフォーマットであるので例外を投げるなど解釈をやめるようにしてください。

また、ファイルサイズはバイト単位で書かれており、フォーマット上4GBまで想定する必要があります(32ビット符号 なし 整数またはそれ以上の型で表現してください)。

以下に参考実装を示します。実際には unsafe なコードを書いたほうがパフォーマンスはよいです。

private static readonly string _RIFF = "RIFF";
private static readonly byte[] RIFF = Encoding.UTF8.GetBytes(_RIFF);
private static readonly string _WAVE = "WAVE";
private static readonly byte[] WAVE = Encoding.UTF8.GetBytes(_WAVE);

/// <summary>
/// Waveのヘッダ部を読み取ります。
/// </summary>
/// <param name="stream">読み取る対象。ヘッダ部の最初を示している必要があります。終了時はデータの先頭にまで移動します。</param>
/// <returns></returns>
public static WaveHeader Read(Stream stream)
{
    var buffer = new byte[4];
    uint fileSize;

    stream.Read(buffer, 0, 4);
    if (!buffer.SequenceEqual(RIFF))
    {
        throw new InvalidHeaderException(nameof(RIFF));
    }

    stream.Read(buffer, 0, 4);
    fileSize = BitConverter.ToUInt32(buffer, 0);

    stream.Read(buffer, 0, 4);
    if (!buffer.SequenceEqual(WAVE))
    {
        throw new InvalidHeaderException(nameof(WAVE));
    }
}

チャンク

一番最初以外は、チャンクという単位に分かれています。チャンクは以下のような構造をしています。

開始バイト 終了バイト 内容
0 3 チャンクの名前
4 7 チャンクのデータのサイズ[bytes]
8 ... チャンクのデータ(可変長)

注意事項などはヘッダーの最初の部分と同じです。
主なチャンクの種類を以下に示します。

チャンク名 必須? 内容
fmt true 音声フォーマットを記載
data true 音声データを記載
list false メタデータを記載

最低限、fmtチャンクとdataチャンクさえ読み書きできればOKです。
それ以外のチャンクは読み飛ばすことにします。

fmtチャンク

ファイルのフォーマットを教えてくれるのがfmtチャンクです。
ヘッダーに実際に書かれるチャンクの名前は、fmt です。(スペースに注意)
最初にfmt の4byteが書かれ、それ以降は以下のような構造をしています。

/// <summary>
/// Waveヘッダのフォーマットチャンク部に対応するデータを表現します。
/// </summary>
/// <remarks>メモリレイアウトが一致するように設定されています。</remarks>
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 20)]
public struct WaveHeaderFormatChunk
{
    [FieldOffset(0)]
    public int FormatSize;

    [FieldOffset(4)]
    public short FormatCode;

    [FieldOffset(6)]
    public short ChannelCount;

    [FieldOffset(8)]
    public int SampleRate;

    [FieldOffset(12)]
    public int ByteRate;

    [FieldOffset(16)]
    public short BlockSize;

    [FieldOffset(18)]
    public short BitPerSample;
}

こういう構造体を書いておいて Unsafe.As メソッドで再解釈するのが結構楽です。
フィールド名はある程度適当です。専門用語とかではないのですいません。
ヘッダーの意味をかんたんに説明すると

1 2
FormatSize このチャンクの長さ。通常16。
FormatCode フォーマットの種類。通常1。
ChannelCount チャンネル数。ステレオなら2。
SampleRate 一秒あたりのサンプル数。CDなら44100。
ByteRate 一秒あたりのバイト数。ビットレートではなくバイトレートなので注意。
BlockSize 1ブロックのサイズ。つまり、全チャンネルぶん合計の1サンプルのサイズ。
BitPerSample サンプルのビットの深さ。CDなら16bit。

となります。上記の構造体を介した読み取りのサンプルコードは以下。"fmt " が見つかるまで stream を読み進め、見つかったら以下のメソッドを呼びます。

private static WaveHeaderFormatChunk ReadFormatChunk(Stream stream)
{
    const int size = 20;
    var buffer = new byte[size];
    stream.Read(buffer, 0, size);
    ref WaveHeaderFormatChunk result = ref Unsafe.As<byte, WaveHeaderFormatChunk>(ref buffer[0]);
    return result;
}

dataチャンク

音声データが書き込まれているのがこのdataチャンクです。やはり最初の4byteが"data"で、次の4byteがdataチャンク本体のサイズです。

44.1Khz/16bitでステレオの音声ファイルを例に取ると、dataチャンクには、右チャンネルの1サンプル目(2byte)、左チャンネルの1サンプル目(2byte)、右チャンネルの2サンプル目(2byte)...と続きます。
チャンネルごとに分けてデータを持ちたい場合、以下のようなコードで読めます。

public static WaveAudio Read(Stream stream)
{
    WaveHeader header = WaveHeaderReader.Read(stream);
    WaveHeaderFormatChunk format = header.WaveHeaderFormatChunk;

    // PCMかどうか確認。PCM以外はサポートする気はない。
    if (format.FormatCode != 1 || format.FormatSize != 16)
    {
        throw new NotSupportedException();
    }

    // 16ビットのステレオか確認、それ以外も本当はサポートしたい。
    if (format.ChannelCount != 2 || format.BitPerSample != 16)
    {
        throw new NotSupportedException();
    }

    var length = header.AudioDataSize / (format.ChannelCount * sizeof(short));
    var right = new short[length];
    var left = new short[length];

    var buffer0 = new byte[format.BlockSize / format.ChannelCount];
    var buffer1 = new byte[format.BlockSize / format.ChannelCount];

    for (var i = 0; i < length; i++)
    {
        if (stream.Read(buffer0, 0, buffer0.Length) + stream.Read(buffer1, 0, buffer1.Length) != 0)
        {
            right[i] = BitConverter.ToInt16(buffer0, 0);
            left[i] = BitConverter.ToInt16(buffer1, 0);
        }
        else
        {
            break;
        }
    }
}

出力は入力の逆操作をします。出力のほうが楽です。

オーディオを抽象化(ArraySegment like)

今回は読み取ったデータをガンガン分割したいのですが、メモリ上で実際にコピーすると辛いので ArraySegment のようなイメージで元データの区間を参照できるクラスを実装していきましょう。

interface を定義して...

オーディオデータを以下のような interface で定義します。

/// <summary>
/// 読み取り専用のオーディオデータを表現します。
/// </summary>
public interface IReadOnlyWaveAudioSource
{
    IReadOnlyList<short> RightData { get; }
    IReadOnlyList<short> LeftData { get; }

    int BitDipth { get; }
    double Length { get; }
    int SampleRate { get; }
}

メモリ上の実体で実装するクラスを書く

そして、Waveをちゃんと読み取ったデータでこの interface を実装するクラスを定義します。
普通の実装なので省略します。

部分参照にも interface を継承させる

そして、これに対する部分参照を表すクラスを書きます( ArraySegment のイメージです)
offset と length を持っておき、さきほどのメモリ実体での実装の一部分を参照できるようにします。

これで、メモリ上で実際にコピーすることなく、あたかもオーディオデータが分割されたかのように見える設計をすることができるようになりました。
パフォーマンスはちょっと悪いです。Span<T> を活用すればよかった。ref struct 制約結構面倒なんですよね。

簡易編集

指定したしきい値よりも信号強度が小さいサンプルを「無音」とみなし、この無音でオーディオを分割できるメソッドを定義します。
無音とみなすしきい値、分割する最低の無音長さの 2 つのパラメータを受け取って分割することとします。
分割したあとに前後の「無音」をトリムする必要があるのでそのメソッドも実装します。
どちらのメソッドも、さきほど実装した「参照」をもつのみで、データをコピーするわけではないのがポイントです。

自動分割は難しい

ということで実際に試してみたのですが、人間の意図通りに分割するのは難しいです。喋っている人が詰まったときの微妙な間、音楽のシーンチェンジの微妙な間、こういった区間でも分割されてしまったり、逆に分割してほしいところで分割されなかったりと、結局人間が多分に介入することになりあまり実用的ではありませんでした。
結果的に、勉強にはなりましたがアプリケーション自体はボツになってしまったのです。

そして、1年後

しかし、約1年後(今記事を書いている時空です)、新しいアイディアがありました。

分割した結果どのくらいの長さになるべきかを考慮したらどうだろうか

分割したいソースが動画の場合、そのおおよその分割位置は概要欄やコメント欄から取得できるケースが多いでしょう。
例えば以下のように...

#01. 稲妻 - 00:00
#02. 落葉風波 - 05:30
#03. 羈留の客 - 07:26
(以下略)

出典

こういったデータは、単独で分割の指標にするには精度不足ですが、さきほどの音声解析による分割を最適化する指標として用いるには十分です。
分割されるべき長さというメタデータを文字列解析により受け取り、分割の指標にすることで正しく分割できるアルゴリズムを実装します。

アルゴリズム

戦略は以下の通りです。

  • 外部メタデータとして、分割する目標の長さを得る
  • ゆるいパラメータで分割し、過剰分割する
  • 分割しすぎたものを結合していき、目標長さとの誤差を最小化する

競プロで培ったアルゴリズム力(?)を駆使し、このロジックを実装します。すごそうなことを言っていますが頭から順に見てそれなりに最小化するだけです。

結果

依然、分割ターゲットの状態によっては無音検出のしきい値などのパラメータを人力で詰める必要がありますが、少なくとも人間が波形編集ソフトで手直しすることは不要になり十分実用的な品質になりました。結構きれいに分割できるので作者としては気持ちいいです。

2
0
1

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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?