🚥

Angular SignalsとブラウザAPIで音声レコーダーを作る

2023/06/05に公開

はじめに

今回は、趣味でAngular Signalsを使って簡易的な音声レコーダーのアプリケーションを作ったので紹介します。

Angular Signalsとは

Angular Signalsは、リアクティブに状態を管理するためのAngularの新しい機能です。(@angular/coreというAngularのコアライブラリに入っています)Angular v16で公開され、現在は開発者プレビューの段階にあります。

具体的には、signalを作成した後、set, update, mutateなどのメソッドで値を更新し、computed, effectなどのメソッドを用いて副作用を定義することで、リアクティブな変更を可能にするものです。
公式ドキュメント

先日開催されたGoogle I/O 2023でも発表がありました。
https://www.youtube.com/watch?v=EIF0g9LDHcQ
https://codelabs.developers.google.com/angular-signals?hl=ja

また、Google I/O 2023のAngular関連セッションはGoogle Developer Expertであるlacolaco氏がまとめてくださっており、そちらを見ていただくと理解が深まります。ng-japan OnAirでもYoutubeライブで分かりやすく解説されていました。
https://www.youtube.com/live/IJEG5AOkhoM?feature=share&t=1240
https://blog.lacolaco.net/2023/05/io2023-angular-summary/

音声レコーダーの作り方

今回作成した音声レコーダーは、次のURLで公開しています。録音開始、一時停止、再開、録音停止し、同一セッション内だけ音声ファイルを維持するとてもシンプルなものになります。この記事では主に音声収録処理とSignalsを活用した箇所について触れていきます。
https://audio-recorder-signals.vercel.app/

音声収録処理

まず音声収録処理は、複数のブラウザAPIを使っています。MediaDevices.getUserMedia()でユーザー端末からの音声を取得し、それをMediaRecorderで収録しています。

record-audio.service.ts

  async initRecord() {
    if (!navigator.mediaDevices.getUserMedia) {
      console.error('getUserMedia is not supported');
      return;
    }

    const stream = await navigator.mediaDevices.getUserMedia({
      video: false,
      audio: true,
    });

    this.userMediaStream = stream;

    const mediaRecorder = new MediaRecorder(stream);

    this.setEvent(mediaRecorder);

    this.mediaRecorderInstance = mediaRecorder;
  }

MediaRecorderでは、収録停止時にdataavailableイベントが発火し、音声のBlobオブジェクトがイベントから取得できます。それをURL.createObjectURL()で一時URLを取得して、コンポーネント側のaudioタグのsrc属性にバインドすることで収録した音声を動的にプレビュー可能にしています。

Signalsを活用した箇所

本題です。
この実装では、2つの箇所でSignalsを活用しています。現在のSignalsは@angular/coreより読み込み、次のように作成することができます。この例では、signal()で作成した更新可能なWritableSignalからasReadonly()メソッドを呼んだgetterを用意することで、該当のService以外からSignalを更新(setやupdate)できないようにしています。個人的にはRxjsでのSubjectasObservable()するような感覚でした。

record-audio.service.ts

import { Injectable, NgZone, inject, signal } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class RecordAudioService {
  ...

  private readonly _recorderStateSignal = signal<
    'inactive' | 'recording' | 'paused'
  >('inactive');
  get recorderStateSignal() {
    return this._recorderStateSignal.asReadonly();
  }

  private readonly _recordedAudioURLsSignal = signal<string[]>([]);
  get recordedAudioURLsSignal() {
    return this._recordedAudioURLsSignal.asReadonly();
  }
  
  ...
}

1つ目のrecorderStateSignalは、MediaRecorderの状態(収録中など)をstring型で持ち、動的にボタンをdisabled(非活性)にするために記述しています。MediaRecorderの録音開始、一時停止、再開、録音停止イベントで状態を更新し、該当コンポーネントのボタンにバインディングしています。

record-audio.service.ts

  startRecord() {
    ...
    this.mediaRecorderInstance.start();
    this._recorderStateSignal.set(this.mediaRecorderInstance.state);
  }

  pauseRecord() {
    ...
    this.mediaRecorderInstance.pause();
    this._recorderStateSignal.set(this.mediaRecorderInstance.state);
  }

  resumeRecord() {
    ...
    this.mediaRecorderInstance.resume();
    this._recorderStateSignal.set(this.mediaRecorderInstance.state);
  }

  stopRecord() {
    ...
    this.mediaRecorderInstance.stop();
    this._recorderStateSignal.set(this.mediaRecorderInstance.state);
    this.mediaRecorderInstance = null;
    ...
  }

record-audio.component.html

    <button
      type="button"
      (click)="startRecord()"
      [disabled]="
        recorderStateSignal() === 'recording' ||
        recorderStateSignal() === 'paused'
      "
      class="button"
    >
      Start
    </button>
   ...

2つ目のrecordedAudioURLsSignalは、収録した複数の音声を動的にプレビュー可能にするために記述しています。複数の音声の一時的なURLをstring配列の型で持ち、コンポーネント側のaudioタグのsrc属性にバインドしています。更新時のポイントは、MediaRecorderからのイベントの関数に渡しているため、おそらくsignalの範囲外になってしまい、updateしたsignalがリアクティブに反映されなかったため、ngZoneを使用してsignalの変更が検知されるようにしました。ここに関しては、深く調べていないため他に解決策があれば教えてくださると嬉しいです。

record-audio.service.ts

  private setEvent(mediaRecorder: MediaRecorder) {
    mediaRecorder.ondataavailable = (event) => this.ondataavailable(event);
    ...
  }

  private ondataavailable(event: BlobEvent) {
    this.setAudioURLs(event.data);
  }
  private setAudioURLs(blob: Blob) {
    const audioURL = window.URL.createObjectURL(blob);

    // MediaRecorderの関数に渡してる都合で、リアクティブにならないため、ngZone内で行う
    this.ngZone.run(() => {
      this._recordedAudioURLsSignal.update((audioURLs) => {
        audioURLs.push(audioURL);
        return audioURLs;
      });
    });
  }

record-audio.component.html

  <div class="audio-players">
    <audio
      *ngFor="let audioURL of recordedAudioURLsSignal()"
      controls
      [src]="audioURL"
      class="audio-player"
    ></audio>
  </div>

おわりに

今回作ったアプリケーションの全コードはGithubで公開しています。
https://github.com/komura-c/audio-recorder-signals

Angular SignalsはTwitterで話題になったり、ng-japan OnAirで取り上げられていた回を見て、とても興味があったので、今回のアプリケーションで少しだけ触ることができ、便利さを実感しました。個人的には、Rxjsでの状態管理よりも手軽に使えて良いなと感じました。(自分のRxjsへの知識の足りなさもあるため)
これからどのように、Angular Signalsが開発者プレビューから外れて進化していくかが楽しみです。
ここまで読んでいただきありがとうございました。

Voicyテックブログ

Discussion