【ハンズオン資料】 Monacaでボイスレコーディングアプリを作る


NCMBとMonacaを使ってボイスレコーディングアプリを作ります。HTML APIだけを使うので、プラグインを使わずに開発できます。録音した音声をNCMBのファイルストアへアップロードし、逆にダウンロードして再生できるアプリです。

まず最初の記事では画面の説明とSDKの導入までを進めます。

## コードについて

今回のコードは [NCMBMania/monaca-voice-recorder-handson](https://github.com/NCMBMania/monaca-voice-recorder-handson) にアップロードしてあります。実装時の参考にしてください。

## 利用技術について

今回は次のような組み合わせになっています。

- Monaca
- Framework7
- NCMB JavaScript SDK

## 仕様について

画面表示はFramework7を使っています。とはいえ、1画面の構成ですし、 `www/pages/home.html` だけ見れば実装内容は分かるでしょう。また、Framework7はVanilla JSで、VueやReactなどのUIフレームワークは利用していません。

## 利用する機能について

ボイスレコーディングアプリで利用するNCMBの機能は次の通りです。

- ファイルストア
    - アップロード
    - 検索

## 画面について

今回は以下の1つの画面があります。

### www/pages/home.html

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/20dece42-add2-8029-16a6-2c98eb29e376.png)


ボイスレコーディングを行い、ファイルストアへアップロードします。また、既存のボイスレコーディングを一覧表示し、選択された音声を再生します。

以下はFramework7のテンプレートですが、変数として `status` と `files` 、 `uri` を使っています。これは以下の内容を持つ変数です。

- **status**
録音状態を表す。 `ready` が初期状態、 `recording` は録音中
- **files**
ファイルストアからダウンロードした録音済みファイルの配列
- **uri**
録音データのURI（ファイルストアへのHTTPSアクセス用URL）

```html
<!-- 記述済み -->
<template>
  <div class="page" data-name="catalog">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="title">レコーダー</div>
      </div>
    </div>
    <div class="page-content">
      <!-- 録音状態に合わせてボタンの出し分け -->
      ${ status == 'ready' ?
        $h`<button class="button button-large button-fill" @click=${start}>録音開始</button>` :
        $h`<button class="button button-large button-fill" @click=${stop}>録音終了</button>`
      }
      <p>
        <div class="block-title">録音したデータ</div>
        <div class="list simple-list">
          <!-- 既存の録音データを一覧表示する -->
          <ul>
            ${ files.map(file => $h`
              <li @click="${() => listen(file)}">
                ${ file.fileName }
              </li>
            `)}
          </ul>
        </div>
      </p>
    </div>
  </div>
</template>
```

### SDKのインストール

今回はMonacaのJS/CSSコンポーネントの追加と削除より、NCMBを追加します。アプリのテンプレートはFramework7のJavaScript版（VueやReactではなく）を選択しています。

## NCMBのAPIキーを取得

[mBaaSでサーバー開発不要！ | ニフクラ mobile backend](https://mbaas.nifcloud.com/)にてアプリを作成し、アプリケーションキーとクライアントキーを作成します。

## js/config.jsonの作成

js/config.jsonを開き、その中に先ほど取得したNCMBのAPIキーを設定します。内容は次のようになっています。

```json
{
	"applicationKey": "YOUR_APPLICATION_KEY",
	"clientKey": "YOUR_CLIENT_KEY"
}

```

## 初期化

初期化は `js/app.js` にて行います。config.jsonを読み込む関係上、非同期処理内にて行います。cordovaの有無（アプリまたはプレビューの違いを検知）によって初期化時のイベント処理を変えています。

```jsx
// 記述済み
// NCMBの初期化用
const event = window.cordova ? 'deviceready' : 'DOMContentLoaded';
document.addEventListener(event, async (e) => {
	// この中に処理を書きます
});

```

config.jsonの内容を読み込んで、NCMBとFramework7の初期化を行います。

```jsx
// 記述済み
window.config = await (await fetch('./js/config.json')).json();
window.ncmb = new NCMB(config.applicationKey, config.clientKey);
window.app = new Framework7({
	name: 'My App', // App name
	theme: 'auto', // Automatic theme detection
	el: '#app', // App root element
	// App store
	store: store,
	// App routes
	routes: routes,
});

```

これでNCMBの初期化が完了します。

## ルーティング設定

今回は音声入力・出力用の1画面になります。これは `js/routes.js` に定義します。

```jsx
// 記述済み
const routes = [
  {
    path: '/',
    url: './index.html',
  },
  // メイン画面
  {
    path: '/home/',
    componentUrl: './pages/home.html',
  },
  // Default route (404 page). MUST BE THE LAST
  {
    path: '(.*)',
    url: './pages/404.html',
  },
];

```

## 必要な変数の用意

今回の処理はすべて `www/pages/home.html` に記述しています。まず録音関係で必要な変数を準備します。

```jsx
// 記述済み
// 録音系のオブジェクト
let recorder, audioExtension;
let audioData = [];
// 録音状態
let status = 'ready';
// 既存の録音データが入る配列
let files = [];
```

## 録音ボタンを押した際の処理

録音ボタンを押した際には `start` 関数を呼び出します。

```html
<!-- 記述済み -->
$h`<button class="button button-large button-fill" @click=${start}>録音開始</button>` :

```

以下の処理は `start` 関数の中に記述します。

```jsx
// 記述済み
// 録音開始ボタンを押した時の処理
const start = async (e) => {
	e.preventDefault();
	// この中に記述する
}

```

### 初期化

すでに録音を行っている場合もありますので、録音データを初期化します。

```jsx
// 記述済み
// 初期化
audioData = [];

```

### オーディオ入力用ストリームを作成

録画用のオーディオ入力用ストリームを作成します。 `{ audio: true }` を指定することで、録音のみ行うようになります。

```jsx
// 記述済み
// オーディオ入力用ストリームを作成
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// MediaRecorderを作成
recorder = new MediaRecorder(stream);

```

![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/621d7475-f4f7-96a4-3211-262df9f25a0a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/621d7475-f4f7-96a4-3211-262df9f25a0a.png)

### イベントの設定

録音中、録音終了時のイベントをそれぞれ指定します。

```jsx
// 記述済み
// 録音中に呼ばれるイベント
recorder.addEventListener('dataavailable', _dataavailable);
// 録音終了時に呼ばれるイベント
recorder.addEventListener('stop', _stop);

```

### 録音開始

準備が終わったので録音を開始します。

```jsx
// 記述済み
// 録音開始
recorder.start();

```

### 画面表示更新

`status` を変更して、画面表示に反映します。

```jsx
// 記述済み
// 画面表示用にステータス変更
status = 'recording';
// 画面更新
$update();

```

## 録音時の処理

録音が実行されていると、常時 `_dataavailable` が呼び出されます。

このタイミングで、データのフォーマットから拡張子を指定します。例えばiOSの場合は `audio/mp4` で、 Androidは `audio/webm;〜` などとなります。拡張子は正規表現で取得します。

```jsx
// 記述済み
const _dataavailable = (e) => {
	// 送られてくるデータを追加
	audioData.push(e.data);
	// 拡張子を設定
	audioExtension = e.data.type.match(/audio\\/(.*?)($|;)/)[1];
}

```

## 録音終了時のイベント

録音を終了させるのは `stop` 関数を呼び出す時です。これはHTML画面で録音終了ボタンを押した際のイベントになります。

```html
<!-- 記述済み -->
$h`<button class="button button-large button-fill" @click=${stop}>録音終了</button>`
```

この `stop` 関数ではレコーダーの処理をストップさせるだけです。そして、録音状態をリセットして、画面表示に反映させます。

```jsx
// 記述済み
const stop = (e) => {
	e.preventDefault();
	// 録音停止
	recorder.stop();
	// ステータスリセット
	status = 'ready';
	$update();
}

```

録音状態が停止すると、 `_stop` 関数が呼ばれます。これは元々レコーダーに対して指定していたイベントです。

```jsx
// 記述済み
// 録音終了時に呼ばれるイベント
recorder.addEventListener('stop', _stop);

```

## 録音終了時の処理

以下は `_stop` 関数の内容です。

```jsx
// 記述済み
const _stop = async (e) => {
	// この中に記述
}

```

### 蓄積した録音データをBlobオブジェクトに変換

録音データをNCMBで扱えるBlobオブジェクトに変換します。

```jsx
// 録音データをBlobに変換
const audioBlob = new Blob(audioData);

```

### ファイル名を生成

ファイル名を生成します。今回はタイムスタンプを使っているだけですが、複数人で同時に使う可能性がある場合にはランダムな文字列を生成する方が良いでしょう。

```jsx
// ファイル名生成（タイムスタンプを利用）
const d = new Date();
const fileName = `record_${d.getTime()}.${audioExtension}`;

```

### NCMBへのファイルアップロード

最後にファイルアップロードを行います。

```jsx
// NCMBへアップロード
const file = await ncmb.File.upload(fileName, audioBlob);

```

### 結果を配列に追加する

返ってきたファイルオブジェクトを配列に追加して、画面を更新します（一覧表示については次回の記事で解説します）。

```jsx
// 一覧に追加
files.push(file);
// 描画更新
$update();

```

`_stop` 関数の全体像です。

```jsx
// 停止処理
const _stop = async (e) => {
  // 録音データをBlobに変換
  const audioBlob = new Blob(audioData);
  // ファイル名生成（タイムスタンプを利用）
  const d = new Date();
  const fileName = `record_${d.getTime()}.${audioExtension}`;
  // NCMBへアップロード
  const file = await ncmb.File.upload(fileName, audioBlob);
  // 一覧に追加
  files.push(file);
  // 描画更新
  $update();
}
```

## 画面が表示された際の処理

Frameworkでは画面が表示された（マウントされた）際に `$onMounted` 関数が呼ばれます。それを使ってファイルストアからレコーディングされたファイルをダウンロードします。

この時、ファイル名 `fileName` に対して `regularExpressionTo` を指定しています。これは正規表現を使って検索する機能です。すべてのレコーディングデータは `record_` ではじまっていますので、それに該当するデータだけを抽出しています。

```jsx
// 画面が表示された際の処理
$onMounted(async () => {
	// NCMBのファイルストアから録音データをダウンロード
	files = await ncmb.File
		.regularExpressionTo('fileName', 'record_.*') // recordからはじまるファイルを対象に検索
		.fetchAll();
	// 描画更新
	$update();
});

```

## 一覧表示する

`files` が更新されると、画面に録音データとして一覧表示されます。

```html
<!-- 記述済み -->
<div class="list simple-list">
	<!-- 既存の録音データを一覧表示する -->
	<ul>
		${ files.map(file => $h`
			<li @click="${() => listen(file)}">
				${ file.fileName }
			</li>
		`)}
	</ul>
</div>

```

![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/7a8ada2e-4bb8-fc9f-6fc5-765ce0d735f0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/7a8ada2e-4bb8-fc9f-6fc5-765ce0d735f0.png)

## 音声ファイルを選択した際の処理

音声ファイルを選択した際の処理 `listen` 関数を修正します。元々の内容は以下のようになっているでしょう。

```jsx
// 記述済み
const listen = async (file) => {
}

```

以下はすべて `listen` 関数の内容です。まずファイルをダウンロードします。

```jsx
// ファイルストアからダウンロード
const blob = await ncmb.File.download(file.fileName, 'blob');

```

そして、BlobからArrayBufferに変更します。

```jsx
// BlobからArrayBufferに変換
const arrayBuffer = await blob.arrayBuffer();

```

このArrayBufferをAudioBufferに変換します。

```jsx
// Web Audio APIのコンテキストを作成
window.AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
// ArrayBufferからAudioBufferに変換
const audioBuffer = await (new Promise((res, rej) => {
  context.decodeAudioData(arrayBuffer, res, rej);
}));

```

続けて、再生するソースとして適用します。

```jsx
// ソースを使って再生
const source = context.createBufferSource();
source.buffer = audioBuffer;
```

最後の再生を開始して終了です。

```jsx
source.connect(context.destination);
source.start(0);
```

`listen` 関数の内容は次の通りです。

```jsx
// 録音データを選択した際の処理
const listen = async (file) => {
  // ファイルストアからダウンロード
  const blob = await ncmb.File.download(file.fileName, 'blob');
  // BlobからArrayBufferに変換
  const arrayBuffer = await blob.arrayBuffer();
  // Web Audio APIのコンテキストを作成
  window.AudioContext = window.AudioContext || window.webkitAudioContext;
  const context = new AudioContext();
  // ArrayBufferからAudioBufferに変換
  const audioBuffer = await (new Promise((res, rej) => {
    context.decodeAudioData(arrayBuffer, res, rej);
  }));
  // ソースを使って再生
  const source = context.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(context.destination);
  source.start(0);
}

```

これでファイルストアからダウンロードした音声を直接再生できます。audioタグのように再生ボタンを押す必要はありません。

![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/a5baa805-638a-040c-668f-efd1477f1402.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/a5baa805-638a-040c-668f-efd1477f1402.jpeg)

## まとめ

今回のハンズオンでは、NCMBの以下の機能を利用しました。

- ファイルストア
    - アップロード
    - ダウンロード
    - 検索

NCMBには他にもデータストアや認証、ソーシャル連携、スクリプト、プッシュ通知などの機能があります。ぜひそれらの機能も使って、素晴らしいアプリ開発にチャレンジしてください。