🗣️

[RT/TS] 音声録音に関して

に公開

はじめに

近年、AIの進化とともに、その応用範囲は急速に拡大しています。少し前まで、AIは主にユーザーの入力に基づいてテキストを生成するために使われていましたが、現在では、画像生成、動画生成、そして音声認識といった分野でも目覚ましい成果を上げています。この記事では、特に「音声認識」に焦点を当て、フロントエンド開発者の視点から詳しく掘り下げていきたいと思います。

実用的な観点から見ると、音声認識の課題は、主に次の2つのサブタスクに分解できます。

  • 入力デバイスからの音声録音
  • データの整形とネットワーク送信

驚くべきことに、これらの各ステップには、議論すべき点がたくさんあります。この連載の他の記事と同様に、この記事でも実践的な内容に焦点を当て、すぐに皆さんのPJで活用できるコード例を紹介します。先にネタバレしておくと、基本的なロジックは、なんとたったの8行のコードに凝縮されています。どうなっているのか気になりますか?詳細は記事を読み進めてください。

const recorder = new Recorder({chunkSize: 1024});
const apiClient = new ApiClient();
const audioChunks = await recorder.start();
await apiClient.startBroadcast();
for await (const chunk of audioChunks) {
    apiClient.sendChunk(audioChunks);
}
await apiClient.endBroadcast();

録音

ブラウザで音声を扱うには、Web Audio APIを使用します。最初のリリース以来、Web Audio APIは多くの変更を経てきました。その中でも最も重要な変更は、ScriptProcessor
の代替としてのAudioWorkletProcessorの登場です。一部の大規模な音声処理ライブラリ(例:RecordRTC)は、まだScriptProcessorを使用していますが、ブラウザはこのクラスが非推奨であるという警告を繰り返し表示します。 それでは、AudioWorkletを使用したより現代的なアプローチを習得してみましょう。

AudioWorkletプロセッサー

実践的な開発に入る前に、AudioWorkletについて知っておくべきこと:

  • これは独立したスクリプトであり別のスレッドで実行されるため、アプリケーションのメインコードで起こっていることに直接アクセスできません。
  • メインスレッドとのやり取り(例:オーディオデータの送信など)は、イベントの購読と送信によって行われます。
  • httpsプロトコルでのみ動作します(localhostなどは開発者向けの例外です)。

AudioWorkletの簡単な実装は次のようになります。

class RawAudioProcessor extends AudioWorkletProcessor {
    process(inputs: Float32Array[][], outputs: Float32Array[][]) {
        const input = inputs[0];
        const output = outputs[0];

        // 簡単にするために、チャネル数を一時的にハードコードします
        const channelCount = 2;

        if (input.length > 0) {
            for (
                let channelIndex = 0;
                channelIndex < Math.min(input.length, channelCount);
                channelIndex++
            ) {
                const channel = input[channelIndex];
                if (output.length) output[channelIndex]?.set(new Float32Array(channel));
            }
        }
        return true;
    }
}

// 先ほど宣言したクラスを登録することを忘れないでください。
// これを行わないと、メインスレッドに接続できません。
registerProcessor("raw-audio-processor", RawAudioProcessor);

重要なポイント:

  • クラスはAudioWorkletProcessorを継承する必要があります。
  • オーディオストリームにアクセスするには、processメソッドを使用します。
  • inputsパラメータには、常に128個のオーディオサンプルがFloat32Arrayの形式で渡されます。現時点では、これがデフォルトの動作であり、変更できません。
  • processメソッドは、Workletのアクティブ状態を強制する必要があるかどうかを示すboolean値を返す必要があります。メインスレッドでのWorkletの操作が終了する前に、関数がfalseの値を返さないように注意してください。

このアプローチにより、多様なプロジェクトで使用できる柔軟なモジュール式音声処理システムを構築できます。ここでは深く掘り下げずに、必要なニュアンスのみに焦点を当てます。音声認識サービスは通常、入力される音声の断片(チャンク)が同じサイズであることを期待します。AudioWorkletは別のスレッドで実行されるため、固定サイズのチャンクに分割する機能を拡張できます。私が作成したコードは次のとおりです。

class RawAudioProcessor extends AudioWorkletProcessor {
    private readonly buffer;
    private readonly chunkSize;
    private readonly channelCount;
    private isRecording = true;

    // 課題に合わせてWorkletConstructorParamsの型を独自に定義してください。
    // この例では、チャンクサイズchunkSizeとチャネル数channelCountを定義しています。
    constructor({processorOptions: options}: WorkletConstructorParams) {
        super();
        this.chunkSize = options.chunkSize || 128;
        this.channelCount = options.channelCount || 1;
        this.buffer = new Array(this.channelCount).fill(new ArrayBuffer(0));

        // この記事を過度に複雑にしないために、イベントの型定義は省略します。
        // 基本的な型定義はジェネリックMessageEvent<T>を使用して実装されます。ここで、Tはevent.dataの内容の希望の形式です。
        this.port.onmessage = (event) => {
            switch (event.data.type) {
                case "STOP_RECORDING": {
                    this.isRecording = false;
                    break;
                }
            }
        };
    }

    process(inputs: Float32Array[][], outputs: Float32Array[][]) {
        const input = inputs[0];
        const output = outputs[0];

        if (input.length > 0) {
            for (
                let channelIndex = 0;
                channelIndex < Math.min(input.length, this.channelCount);
                channelIndex++
            ) {
                const rawData = input[channelIndex];
                // パススルーをサポートするためのコード
                if (output.length) output[channelIndex]?.set(new Float32Array(rawData));

                const chunkBuffer = this._mergeBuffers(
                    this.buffer[channelIndex],
                    rawData,
                );
                if (chunkBuffer.byteLength >= this.chunkSize) {
                    const chunk = chunkBuffer.slice(0, this.chunkSize);
                    this.buffer[channelIndex] = chunkBuffer.slice(this.chunkSize);

                    // AudioWorkletからのメッセージ送信とAudioWorkletへのメッセージ送信の両方で、
                    // 一貫したペイロード形式に従うことをお勧めします。
                    this.port.postMessage({
                        type: "EMIT_CHUNK", payload: {
                            buffer: chunk,
                            channelIndex,
                            isLast: !this.isRecording,
                        }
                    });
                } else this.buffer[channelIndex] = chunkBuffer;
            }
        }

        return this.isRecording;
    }

    // バッファを正しく結合するためのヘルパー関数
    private _mergeBuffers = (...buffers: ArrayBuffer[]) => {
        const totalLength = buffers.reduce(
            (sum, buffer) => sum + buffer.byteLength,
            0,
        );

        const mergedBuffer = new ArrayBuffer(totalLength);
        const mergedView = new Uint8Array(mergedBuffer);

        let offset = 0;
        buffers.forEach((buffer) => {
            const view = new Uint8Array(buffer);
            mergedView.set(view, offset);
            offset += view.length;
        });
        return mergedBuffer;
    };
}

重要なポイント:

  • AudioWorkletはクラスです。コンストラクターを使用して、操作に必要なパラメーター(上記のコードのチャンクサイズ、チャネル数など)を初期化するのが妥当です。
  • メインスレッドに(シリアル化可能な)データを送信するには、this.port.postMessage()メソッドを使用します。
  • メインスレッドからのメッセージを購読するには、this.port.onmessageにコールバックハンドラーを割り当てます。

AudioWorkletとの接続

AudioWorkletのコードが準備できたので、メインスレッドに接続する必要があります。その前に、音声入力デバイスにアクセスしましょう。これには、AudioContextクラスを使用する必要があります。

// AudioContextを作成します(sampleRateを明示的に指定すると、AudioContextが入力信号を正しく変換します)。
const audioContext = new AudioContext({sampleRate: 16000});

// 音声を含むMediaStreamを取得します。特定の入力デバイスのdeviceIdも指定できます。
const micStream = await navigator.mediaDevices.getUserMedia({
    audio: {
        echoCancellation: false,
        autoGainControl: false,
        noiseSuppression: false,
        sampleRate: 16000,
    },
});

// Contextに接続するためにノードを作成する必要があります。
const micStreamSource = new MediaStreamAudioSourceNode(audioContext, {
    mediaStream: micStream,
});
await audioContext.audioWorklet.addModule(workletPath);

const workletNode = new AudioWorkletNode(
    audioContext,
    "raw-audio-processor",
    {
        processorOptions: {
            // constructorに渡されるパラメーター
        },
    },
);

// WorkletとAudioContextを入力デバイスに接続します。
micStreamSource
    .connect(workletNode)
    .connect(audioContext.destination);

重要な注意事項:

  • AudioWorkletを追加するaudioContext.audioWorklet.addModuleでは、引数としてAudioWorkletのコードを含むファイルへのパスの文字列を使用する必要があります。
  • 上記の例の"raw-audio-processor"は、AudioWorkletのコードでregisterProcessorメソッドで使用したものと一致する必要があります。

あとは、AudioWorkletからのメッセージ公開イベントを購読するだけです。これは、workletNode.port.onmessageにコールバックハンドラーを割り当てることによって行われます。

// 先ほどと同様に、型定義は省略します。イベントの型を定義するには、MessageEvent<T>を使用してください。
this.workletNode.port.onmessage = (event) => {
    // ここでは、AudioWorkletのコードで送信したのと同じメッセージ形式を使用します。
    switch (event.data.type) {
        case "EMIT_CHUNK": {
            const {payload} = event.data;
            // 音声チャンクはpayload.bufferにあり、payload.channelIndexにはチャネルのインデックスが格納されています。
        }
    }
};

AudioWorkletにメッセージを送信するには、postMessageメソッドも使用します。

this.workletNode?.port.postMessage({type: "STOP_RECORDING"});

注意:前述のAudioWorkletのコードでは、"STOP_RECORDING"メッセージは音声ストリームの処理を中止するために使用されます。

このように、購読メカニズムを使用して、録音の開始と中止機能を実装できます。このメカニズムは実装の柔軟性を提供しますが、実際の開発では常に便利であるとは限りません。

他にどのような代替手段があるでしょうか?録音を担当するRecorderクラスがあるとします。実用的な観点から見ると、録音の開始後に次の音声チャンクを待機し、その後特定の操作を実行できる実装が最も便利です。録音を中止した後、入力されるチャンクの完了を待機し、その後クリーンアップと追加の操作を実行します。下記の図を参照してください

このような動作を実装するには、非同期ジェネレーターが最適です。

ジェネレーター

私の経験から言うと、ジェネレーターを聞いたことがあるフロントエンド開発者は少なく、ましてや実際に使用したことがある人はさらに少ないです。これは驚くべきことではありません。ジェネレーターはかなり特殊な機能であり、その使用シナリオは必ずしも明確ではありません。ちなみに、ジェネレーター関数の構文は次のようになります。

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

const generator = generateSequence();

ジェネレーターは関数ですが、普通の関数とは少し異なります。その主な特徴は、リクエストに応じて値を順番に返すことができます。たとえば、上記のジェネレーターは、最初のgenerator.next()呼び出しで1を返します。後続のnext呼び出しでは2、そして3が返されその後、ジェネレーターには事前定義された値がなくなります。ちなみに、ジェネレーターは無限にすることもできます(例:乱数ジェネレーター)。

ジェネレーターには多くの興味深い特徴があります。その仕組みについてより包括的な理解を得るために、こちらの記事をお勧めします。ここでは、今後役立つ特徴のみを説明します。

ジェネレーターは反復可能

言い換えるとジェネレーターによって返される値をfor..ofを使用して反復処理できることを意味します。

let generator = generateSequence();

for (const value of generator) {
  alert(value); // 1, 2, 3
}

この構文を使用すると、ジェネレーターの値を同期的に反復処理し、それらに対して追加の操作を実行できます。これは、スプレッド構文(...)も使用できることを意味します。

// [1, 2, 3]
const sequence = [...generateSequence()];

ジェネレーターは非同期に操作できます

デフォルトでは、ジェネレーターは値を同期的に返す必要がありますが、非同期ジェネレーターを作成することも可能です。たとえば、githubのコミットを反復処理する場合に便利です。大まかに言うと、次のようになります。

// リポジトリrepoのコミットを1つずつ返すジェネレーター
async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url);
    const body = await response.json();
    url = body.meta.nextPageUrl;

    for (let commit of body) {
      yield commit;
    }
  }
}

const commitGen = fetchCommits("my-repo-name");
const firstCommit = await commitGen.next();
const secondCommit = await commitGen.next();

このようにして、ジェネレーターが次の値を返すのを待ってから、コードの実行を続行できます。このアプローチはasync/await構文の利点を活用し、ジェネレーターを使用することで糖衣構文だけでなく、反復処理のロジックがカプセル化され、コードの可読性が向上します。

チャンクジェネレーター

非同期性と反復構文を組み合わせることで、音声チャンクを処理するための優れたソリューションが得られます。このようなジェネレーターの一般的な使用方法は次のとおりです。

// ジェネレーターを初期化します。
let chunkGenerator = generateChunks();

// 非同期イテレーション構文を使用します。
for await (const chunk of chunkGenerator) {
  // ここでチャンクにアクセスできます。たとえば、フォーマットしてサーバーに送信できます。
}

// コードの実行がここまで到達した場合、チャンクの生成は終了しています。
// 通常、これはユーザーが録音を中止したことを意味します。
// ネットワーク経由でチャンクを送信する場合、これはサーバーにパケットの送信が完了したことを通知するのに理想的なタイミングです。

便利ですよね?チャンクを制御するすべてのロジックがchunkGeneratorにカプセル化されているため、シンプルで直感的な高レベルコードを記述できます。ジェネレーターについてほとんど聞いたことがない初心者でも、このコードを実際に使用できます。

完成したバージョン

それでは、Recorderクラスの最終的な録音ロジックを見ていきましょう。

import workletPath from "./worklet-processor?url";
import {
    OutgoingMessageMap,
    WorkletConstructorParams,
    WorkletOutgoingMessage,
} from "./types";

export class Recorder {
    private readonly workletParams;

    private readonly audioContext;
    private micStream: MediaStream | null = null;
    private workletNode: AudioWorkletNode | null = null;

    private stopPromiseResolver: (() => void) | null = null;

    private chunks: ArrayBuffer[] = [];
    private chunkPromiseResolver: ((isLast: boolean) => void) | null = null;

    constructor(params?: WorkletConstructorParams["processorOptions"]) {
        this.audioContext = new AudioContext({sampleRate: 16000});
        this.workletParams = params;
    }

    // 録音を開始するメソッド
    // Promiseが完了するまでインターフェイス側で録音ボタンを無効にするために、非同期メソッドを使用します。
    async start() {
        // 録音の開始時に、AudioContextが中断状態になっている可能性があります。
        // その場合、AudioWorkletが動作するようにAudioContextを再開する必要があります。
        if (this.audioContext.state === "suspended")
            await this.audioContext.resume();

        const micStream = await navigator.mediaDevices.getUserMedia({
            audio: {
                echoCancellation: false,
                autoGainControl: false,
                noiseSuppression: false,
                sampleRate: 16000,
            },
        });
        const micStreamSource = new MediaStreamAudioSourceNode(this.audioContext, {
            mediaStream: micStream,
        });

        await this.audioContext.audioWorklet.addModule(workletPath);
        this.workletNode = new AudioWorkletNode(
            this.audioContext,
            "raw-audio-processor",
            {
                processorOptions: {
                    ...this.workletParams,
                },
            },
        );

        this.workletNode.port.onmessage = (event) => {
            switch (event.data.type) {
                case "EMIT_CHUNK": {
                    const {payload} =
                        event.data as WorkletOutgoingMessage<"EMIT_CHUNK">;

                    // 次のチャンクを受信したら、準備完了のチャンクの配列に追加します。
                    this.chunks.push(payload.buffer);

                    // 非同期ジェネレーターが次のチャンクを待機している場合、Promiseを解決して、新しいチャンクの出現を通知します。
                    this.chunkPromiseResolver?.(payload.isLast);

                    // isLastフラグ付きのチャンクが到着した場合、中止Promiseを解決して、録音プロセスの完了を通知します。
                    if (payload.isLast) this.stopPromiseResolver?.();
                }
            }
        };

        micStreamSource
            .connect(this.workletNode)
            .connect(this.audioContext.destination);

        // 録音の開始が成功した結果としてジェネレーターを返します。
        return this.getChunks();
    }

    // 録音の中止は3つのステップで行われます。
    // 録音の中止をAudioWorkletに通知します。最後のチャンクを待機します。audioContextを中止し、録音の終了を通知します(Promiseを解決します)。
    stop() {
        return new Promise<void>((resolve) => {
            // ワークレットノードに録音が中止したことを通知し、最後に録音されたチャンクを待機します
            this.workletNode?.port.postMessage({type: "STOP_RECORDING"});
            this.micStream?.getAudioTracks().forEach((track) => track.stop());

            this.stopPromiseResolver = async () => {
                await this.audioContext.close();
                resolve();
            };
        });
    }

    private async* getChunks(): AsyncGenerator<ArrayBuffer> {
        let shouldGenerate = true;

        while (shouldGenerate) {
            // リクエスト時に準備完了のチャンクが既に存在する場合、キューからチャンクを取得して返します。
            if (this.chunks.length > 0) yield this.chunks.shift()!;
                // チャンクがまだない場合、チャンクの出現(ジェネレーターの新しい反復)を示すPromiseを新しく作成します。
            else
                await new Promise<void>((resolve) => {
                    this.chunkPromiseResolver = (isLast: boolean) => {
                        resolve();
                        // isLastフラグ付きのチャンクを受信した場合、次の反復でループを終了するようにフラグを変更します。
                        if (isLast) shouldGenerate = false;
                    };
                });
        }
    }
}

すぐにすべてを理解できなくても心配しないでください。練習して、この例をもう一度読んでみてください。実装がより明確になります。

最も難しい部分は終わりました。これで、努力の成果を享受できます。それはRecorderクラスを使用するのが非常に簡単になったことです。

const recorder = new Recorder({chunkSize: 1024});
// 例を簡単にするために、プロジェクトにサーバーとの通信を担当するクラスがあるとします。
const apiClient = new ApiClient();

const audioChunks = await recorder.start();
// 音声の送信開始をサーバーに通知します。
await apiClient.startBroadcast();

for await (const chunk of audioChunks) {
    apiClient.sendChunk(audioChunks);
}

// 音声の送信完了をサーバーに通知します。
await apiClient.endBroadcast();

recorderの内部構造を意識せずに簡単に使用できることに注目してください。非同期アクションの順序が明示的に指定されているため、イベントの購読/送信の混乱を回避できます。内部の非同期ジェネレーターはオンデマンドで新しいチャンクを返し、チャンクの存在を確認する冗長なロジックを回避できます。

音声認識のさらなる実装は、特定のAPIに完全に依存します。 ただし、これまで説明したアプローチは非常に柔軟であり、音声認識サービスに関係なく効率的に動作します。

結論

この記事では、AudioWorkletと非同期ジェネレーターを使用して録音の問題を解決しました。

AudioWorkletは、録音の一部を別のスレッドにオフロードし、その過程で入力された音声を送信しやすいチャンクに分割しました。AudioWorklet内では、元の音声ストリームデータに直接アクセスできるため、開発者のニーズ(APIでサポートされている形式)に応じて自由に編集できます。

非同期ジェネレーターは、async/await構文と組み合わせて、録音の開始と終了の一連のアクションを形式化するのに役立ちました。また、オンデマンドでチャンクの待機と発行のロジックをカプセル化することもでき、Recorderクラスの使用が非常に柔軟になりました。シンプルで直感的な構文により、経験豊富な開発者と初心者の両方が簡単に使用できます。

上記のすべてにより、高品質でデバッグとスケーリングが容易なWebアプリアーキテクチャが作成され、本番環境での使用に簡単に適応できます。

この記事から更にわかることは下記の通りです。

  • アプリでデータストリーミングが必要な場合は、ジェネレーターの使用を検討してください。Webアプリのアーキテクチャを大幅に改善できます。
  • ブラウザーの警告を無視しないでください。Web開発は急速に進化しており、API仕様は既存の課題をより効率的に解決する新機能で常に更新されています。
  • カプセル化と高レベルの抽象化をJS/TSの特性に関する知識と組み合わせることで、経験豊富な開発者は堅牢なシステム全体のメカニズムを作成できます。一方、初心者はそれらを使用してアプリケーションレベルの問題を解決できます。コードの質が上がるはもちろん、タスク管理もより効果的になります。

お読みいただきありがとうございます!ここまでお読みいただいた方へのボーナスとして、Google Chrome Labsが作成したAudioWorkletの使用に関する多くの実践的な例へのリンクがあります。

今後どのようなメカニズムの実装について学びたいかお聞かせください。
次回の記事でお会いしましょう!
Happy coding!

Sun* Developers

Discussion