Tech Blog
  • HOME
  • Blog
  • 【初心者向け】EdgeとChromeからAmiVoice APIを実行してみた Chrome拡張機能編

【初心者向け】EdgeとChromeからAmiVoice APIを実行してみた Chrome拡張機能編

公開日:2023.11.30 最終更新日:2024.01.24

はじめに

こんにちは。AmiVoice APIインフラチームメンバーDです。
本記事では、EdgeとChromeからAmiVoice APIのWebSocket音声認識APIを実行するChrome拡張機能のサンプルとその作り方を紹介します。

対象

できること

  • 任意のWebページ上でマイクとシステム音の音声認識の実行と結果の表示。

Chrome拡張機能のサンプル

ストアには公開にしていません。manifest.jsonを含むフォルダ、ファイル一式をダウンロードし、読み込んでください。

https://github.com/advanced-media-inc/acp-javascript-sample-applications/tree/main/speech-recognition-chrome-extension

GitHubリポジトリのmainブランチのソースコードのアーカイブを下記のリンクからダウンロードすることもできます。
Download ZIP

AmiVoice API Chrome拡張機能サンプル

注意点

  • 音声認識にはAmiVoice APIを利用します。
  • 音声認識を実行するとAmiVoice APIの利用料がかかります。利用料金をご確認ください。
  • Windows 10のMicrosoft EdgeとGoogle Chromeでのみテストしています。
  • APPKEYを平文でローカルに保存しています。有効期限を短期間に設定し、可能であればIP制限を行ったワンタイムAPPKEYをご利用ください。また使わないときは無効化や削除を行ってください。
  • Webページのポリシーによって利用が制限される場合があります。
  • 外部のライブラリ Opus Recorder v8.0.5を利用しています。lib/opus-recorder/encoderWoker.min.jsはOpus Recorderのファイルです。
  • 上記以外のファイルのJavaScriptのコードのご利用に制限はありませんが、保証は一切おこなっていません。利用者ご自身の責任でご利用ください。問い合わせにも対応できません。

仕様について

  • マイクとシステム音は、16kHzのDVI/IMA ADPCM(独自ヘッダ付き)またはOgg Opusに変換後、AmiVoice APIのサーバーに送信されます。
  • Webページのポリシーによって利用が制限される場合があります。制限によってOgg Opusへの変換が行えない場合は、DVI/IMA ADPCM(独自ヘッダ付き)に変換されます。
  • 音声フォーマットの変換を行っているため、AmiVoice APIを直接実行した場合と結果が異なる可能性があります。
  • マイクとシステム音の録音と音声認識は最大1時間までです。これは、AmiVoice APIの制限ではありません。

使い方

Microsoft Edge
  1. 「拡張機能の管理」画面を表示します。
  2. 「開発者モード」を有効にします。
  3. 「展開して読み込み」をクリックし、manifest.jsonが置かれたフォルダを選択します。
  4. 拡張機能「AmiVoice API Speech Recognition Sample」の設定画面を表示します。
  5. AmiVoice APIの「APPKEY」を設定し、「保存」をクリックします。(期限付きのAPPKEYを推奨。)
  6. 「https://」で始まる任意のWebページを開きます。
  7. 「AmiVoice API Speech Recognition Sample」のポップアップ画面を表示し、「音声認識開始」をクリックします。
Google Chrome
  1. 「拡張機能」画面を表示します。
  2. 「パッケージ化されていない拡張機能を読み込む」をクリックし、manifest.jsonが置かれたフォルダを選択します。
  3. 拡張機能「AmiVoice API Speech Recognition Sample」の設定画面を表示します。
  4. AmiVoice APIの「APPKEY」を設定し、「保存」をクリックします。(期限付きのAPPKEYを推奨。)
  5. 「https://」で始まる任意のWebページを開きます。
  6. 「AmiVoice API Speech Recognition Sample」のポップアップ画面を表示し、「音声認識開始」をクリックします。

作り方

Chrome拡張機能を使うと、他人が作ったWebページに自分が作ったJavaScriptやCSSを挿入し、Webページの表示や挙動を変更することができます。
ただし、サーバーにある他人のページに何かできるわけではなく、できるのは自分のブラウザ上での表示や動作を変更することだけです。
AmiVoice APIのWebSocket音声認識APIを実行し、Chromeの自動字幕起こし風の画面を表示するChrome拡張機能を作ります。
ちなみに「Chrome拡張機能」という名称ではありますが、Microsoft Edgeでも動作します。
Chrome拡張機能を作るときのポイントは下記のとおりです。

  • Chrome拡張機能でWebページに挿入したコンテンツスクリプトともとのWebページのスクリプトは、互いに相手のJavaScript変数を見ることはできません。
  • Chrome拡張機能でHTML DOMに挿入した内容は、WebページのJavaScriptから読み取り可能なので読まれて困る内容は挿入しないでください。HTML要素のidや名前の衝突にも注意が必要です。
  • ブラウザ上で動作するJavaScript全般にいえることですが、HTMLやJavaScriptのコードと変数はユーザーから丸見えです。認証キーなどを埋め込むと簡単に取り出されてしまいます。
  • コンテンツスクリプトからChrome拡張機能のファイルを参照する場合は、chrome.runtime.getURL()を呼ぶ必要があります。
  • コンテンツスクリプトは、WebページのCSPの影響を受けます。可能であれば動的なスクリプトのような制限を受ける可能性がある実装は避けてください。

まず、manifest.jsonを作ります。

{
  "name": "AmiVoice API Speech Recognition Sample",
  "action": {
    "default_title": "AmiVoice API Speech Recognition Sample",
    "default_popup": "popup.html"
  },
  "manifest_version": 3,
  "version": "0.1.0",
  "description": "AmiVoice API Speech Recognition Sample",
  "permissions": [
    "activeTab",
    "scripting",
    "storage"
  ],
  "options_page": "options.html",
  "content_scripts": [
    {
      "matches": [
        "https://*/*"
      ],
      "js": [
        "scripts/opus-encoder-wrapper.js",
        "lib/wrp/recorder.js",
        "lib/wrp/wrp.js",
        "scripts/view.js"
      ]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": [
        "lib/wrp/processor.js",
        "lib/opus-recorder/encoderWorker.min.js"
      ],
      "matches": ["https://*/*"]
    }
  ]
}
キー説明
manifest_versionマニフェストバージョン。現在の最新が「3」です。
default_popup「Chrome拡張機能」を実行したときに表示されるポップアップ画面です。
options_page「Chrome拡張機能」のオプションページ。サンプルでは、パラメーターの設定画面です。
content_scripts「Chrome拡張機能」実行時にWebページに挿入されるJavaScriptやcssを指定します。このサンプルではJavaScriptのみです。
web_accessible_resources「Chrome拡張機能」から呼び出されるリソース。画像ファイルなどです。AudioWorkletProcessorWeb Workerもここで指定します。
matchesWebページにコンテンツスクリプトやリソースを追加する条件です。このサンプルは、httpsでのみ利用可能なため、”https://*/*”を指定しています。
permissionsChrome拡張機能のAPIを実行するためのアクセス許可の宣言です。
このサンプルでは、activeTabscriptingstorageの3つが必要になります。

次に設定画面であるoptions.htmlとそのJavaScriptコードのoptions.jsを作ります。
まずは、options.htmlです。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Settings for AmiVoice API Chrome extensions Sample</title>
  <link rel="stylesheet" href="./styles/style.css">
</head>

<body>
  <div>
    <h1>AmiVoice API Speech Recognition Sample</h1>
    <table>
      <tbody>
        <tr>
          <td colspan="2">
            <h2>WebSocket音声認識APIの設定</h2>
          </td>
        </tr>
        <tr>
          <td><label for="appkey">APPKEY(※ 暗号化せずにローカルに保存。取り扱いに注意。)</label></td>
          <td>
            <input type="password" id="appkey" spellcheck="false" autocomplete="off">
          </td>
        </tr>
        <tr>
          <td><label for="grammarFileNames">接続エンジン<label></td>
          <td>
            <select name="grammarFileNames" id="grammarFileNames">
              <option value="-a-general">会話_汎用</option>
              <option value="-a-general-input">音声入力_汎用</option>
              <option value="-a-medgeneral">会話_医療</option>
              <option value="-a-medgeneral-input">音声入力_医療</option>
              <option value="-a-bizmrreport">会話_製薬</option>
              <option value="-a-bizmrreport-input">音声入力_製薬</option>
              <option value="-a-medkarte-input">音声入力_電子カルテ</option>
              <option value="-a-bizinsurance">会話_保険</option>
              <option value="-a-bizinsurance-input">音声入力_保険</option>
              <option value="-a-bizfinance">会話_金融</option>
              <option value="-a-bizfinance-input">音声入力_金融</option>
              <option value="-a-general-en">英語_汎用</option>
              <option value="-a-general-zh">中国語_汎用</option>
            </select>
          </td>
        </tr>
        <tr>
          <td><label for="loggingOptOut">サービス向上のための音声と認識結果の提供を行わない(ログ保存なし)</label></td>
          <td><input type="checkbox" id="loggingOptOut" checked></td>
        </tr>
        <tr>
          <td><label for="keepFillerToken">フィラー単語を保持するかどうか</label></td>
          <td><input type="checkbox" id="keepFillerToken"></td>
        </tr>
        <tr>
          <td><label for="profileWords">ユーザー登録単語</label></td>
          <td><input type="text" id="profileWords" spellcheck="false" autocomplete="off"
              title="{表記1}{半角スペース}{読み1}|{表記2}{半角スペース}{読み2}のように指定します。例:AmiVoice あみぼいす|猫 きかい"></td>
        </tr>
        <tr>
          <td colspan="2">
            <h2>サンプルプログラムの設定</h2>
          </td>
        </tr>
        <tr>
          <td><label for="useDisplayMedia">システムまたはブラウザの出力音声を使用する</label></td>
          <td>
            <input type="checkbox" id="useDisplayMedia">
          </td>
        </tr>
        <tr>
          <td><label for="useUserMedia">マイクの入力音声を使用する</label></td>
          <td>
            <input type="checkbox" id="useUserMedia">
          </td>
        </tr>
        <tr>
          <td><label for="useTrace">ブラウザのコンソールにトレースログを出力する</label></td>
          <td>
            <input type="checkbox" id="useTrace">
          </td>
        </tr>
        <tr>
          <td><label for="useNoTranslate">ブラウザの翻訳機能を有効にしたときに認識結果の原文、<br>翻訳結果が両方見られるように認識結果を2回出力する</label></td>
          <td>
            <input type="checkbox" id="useNoTranslate">
          </td>
        </tr>
        <tr>
          <td><label for="useTimestamp">認識結果出力時に現在時刻を出力</label></td>
          <td>
            <input type="checkbox" id="useTimestamp">
          </td>
        </tr>
        <tr>
          <td><label for="useSpoken">認識結果を出力するときに「読み」も一緒に出力する</label></td>
          <td>
            <input type="checkbox" id="useSpoken">
          </td>
        </tr>
        <tr>
          <td><label for="useOpusRecorder">音声データをサーバーに送信する前にOgg Opus形式に圧縮する</label></td>
          <td><input type="checkbox" id="useOpusRecorder" checked></td>
        </tr>
        <tr>
          <td colspan="2">
            <div style="font-size: smaller; margin-left: 20px;">
              <div>Ogg Opus形式への圧縮には下記のプログラムを使用しています。</div>
              <div>
                Opus Recorder License (MIT)<br>
                Original Work Copyright © 2013 Matt Diamond<br>
                Modified Work Copyright © 2014 Christopher Rudmin<br>
                <a href="https://github.com/chris-rudmin/opus-recorder/blob/v8.0.5/LICENSE.md" target="_blank"
                  rel="noopener noreferrer">https://github.com/chris-rudmin/opus-recorder/blob/v8.0.5/LICENSE.md</a>
              </div>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    <button id="saveButton" type="button">保存</button>
  </div>
  <script src="./scripts/options.js"></script>

</body>

</html>

options.jsです。

/**
 * 設定情報をロードします。
 */
function loadOptions() {
  chrome.storage.local.get(null, (options) => {
    if (typeof options.authorization === 'undefined') {
      options.authorization = "";
    }
    if (typeof options.grammarFileNames === 'undefined') {
      options.grammarFileNames = "-a-general";
    }
    if (typeof options.loggingOptOut === 'undefined') {
      options.loggingOptOut = true;
    }
    if (typeof options.useTrace === 'undefined') {
      options.useTrace = false;
    }
    if (typeof options.useUserMedia === 'undefined') {
      options.useUseMedia = false;
    }
    if (typeof options.useDisplayMedia === 'undefined') {
      options.useDisplayMedia = true;
    }
    if (typeof options.keepFillerToken === 'undefined') {
      options.keepFillerToken = false;
    }
    if (typeof options.profileWords === 'undefined') {
      options.profileWords = "";
    }
    if (typeof options.useTimestamp === 'undefined') {
      options.useTimestamp = false;
    }
    if (typeof options.useSpoken === 'undefined') {
      options.useSpoken = false;
    }
    if (typeof options.useOpusRecorder === 'undefined') {
      options.useOpusRecorder = true;
    }

    document.getElementById('appkey').value = options.authorization;
    document.getElementById('grammarFileNames').value = options.grammarFileNames;
    document.getElementById('loggingOptOut').checked = options.loggingOptOut;
    document.getElementById('useTrace').checked = options.useTrace;
    document.getElementById('useDisplayMedia').checked = options.useDisplayMedia;
    document.getElementById('useUserMedia').checked = options.useUserMedia;
    document.getElementById("useNoTranslate").checked = options.useNoTranslate;
    document.getElementById("keepFillerToken").checked = options.keepFillerToken;
    document.getElementById("profileWords").value = options.profileWords;
    document.getElementById("useTimestamp").checked = options.useTimestamp;
    document.getElementById("useSpoken").checked = options.useSpoken;
    document.getElementById("useOpusRecorder").checked = options.useOpusRecorder;
  });
}

/**
 * 設定情報を保存します。
 */
function saveOptions() {
  const authorization = document.getElementById('appkey').value;
  const grammarFileNames = document.getElementById('grammarFileNames').value;
  const loggingOptOut = document.getElementById('loggingOptOut').checked;
  const useTrace = document.getElementById('useTrace').checked;
  const useDisplayMedia = document.getElementById('useDisplayMedia').checked;
  const useUserMedia = document.getElementById('useUserMedia').checked;
  const useNoTranslate = document.getElementById("useNoTranslate").checked;
  const keepFillerToken = document.getElementById("keepFillerToken").checked;
  const profileWords = document.getElementById("profileWords").value;
  const useTimestamp = document.getElementById("useTimestamp").checked;
  const useSpoken = document.getElementById("useSpoken").checked;
  const useOpusRecorder = document.getElementById("useOpusRecorder").checked;

  const options = {
    authorization: authorization.trim(),
    grammarFileNames: grammarFileNames.trim(),
    loggingOptOut: loggingOptOut,
    useTrace: useTrace,
    useDisplayMedia: useDisplayMedia,
    useUserMedia: useUserMedia,
    useNoTranslate: useNoTranslate,
    profileWords: profileWords,
    keepFillerToken: keepFillerToken,
    useTimestamp: useTimestamp,
    useSpoken: useSpoken,
    useOpusRecorder: useOpusRecorder
  };
  chrome.storage.local.set(options);
  alert("設定を保存しました。");
}

document.addEventListener('DOMContentLoaded', loadOptions);
document.getElementById('saveButton').addEventListener('click', saveOptions);

パラメーターを指定可能にして、chrome.storage.local.set()とchrome.storage.local.get()で保存と読み込みを行うだけです。
次にWebページに挿入する画面用のJavaScriptコード、view.jsを作成します。

// フォントサイズ切替の小サイズ
const AMI_RESULTVIEW_FONTSIZE_SMALL = "16px";
// フォントサイズ切替の中サイズ
const AMI_RESULTVIEW_FONTSIZE_MEDIUM = "24px";
// フォントサイズ切替の大サイズ
const AMI_RESULTVIEW_FONTSIZE_LARGE = "32px";

// 結果表示画面全体のHTML要素
let amivoiceApiSampleResultViewDialogElement = null;
// 結果表示画面の結果表示部分のHTML要素
let amivoiceApiSampleResultViewElement = null;
// 結果表示画面の認識途中結果表示部分のHTML要素
let amivoiceApiSampleResultUpdatedViewElement = null;

// 結果画面の自動スクロールの有無
const ResultViewSetting = {
    isAutoScroll: true
}

/**
 * Traceメッセージのマスク処理です。
 * @param {string} message メッセージ 
 */
function maskTraceMessage(message) {
    return message.replace(/authorization=\w+/, "authorization=XXXX");
}

/**
 * 結果表示画面全体のHTML要素を取得します。
 * @returns HTMLエレメント
 */
function getResultViewDialog() {
    return amivoiceApiSampleResultViewDialogElement;
}

/**
 * 結果表示画面の結果表示部分のHTML要素を取得します。
 * @returns HTMLエレメント
 */
function getResultViewElement() {
    return amivoiceApiSampleResultViewElement;
}

/**
 * 結果表示画面の認識途中結果表示部分のHTML要素を取得します。
 * @returns HTMLエレメント
 */
function getResultUpdatedElement() {
    return amivoiceApiSampleResultUpdatedViewElement;
}

/**
 * 結果表示画面を作成します。
 * @returns 結果表示画面全体のHTMLエレメント
 */
function createResultViewDialog() {
    amivoiceApiSampleResultViewDialogElement = document.createElement("div");
    amivoiceApiSampleResultViewDialogElement.style.backgroundColor = "rgba(0,0,0,0.7)";
    amivoiceApiSampleResultViewDialogElement.style.height = "0px";
    amivoiceApiSampleResultViewDialogElement.style.width = "96%";
    amivoiceApiSampleResultViewDialogElement.style.transform = "translateX(2%)";
    amivoiceApiSampleResultViewDialogElement.style.position = "fixed";
    amivoiceApiSampleResultViewDialogElement.style.zIndex = "99999";
    amivoiceApiSampleResultViewDialogElement.style.border = "0px";
    amivoiceApiSampleResultViewDialogElement.style.textAlign = "left";
    amivoiceApiSampleResultViewDialogElement.style.color = "white";
    amivoiceApiSampleResultViewDialogElement.style.fontSize = "24px";
    amivoiceApiSampleResultViewDialogElement.style.fontFamily = "'Hiragino Kaku Gothic ProN', 'Helvetica', 'Verdana', 'Lucida Grande', 'ヒラギノ角ゴ ProN', sans-serif";
    amivoiceApiSampleResultViewDialogElement.style.borderRadius = "10px";
    amivoiceApiSampleResultViewDialogElement.style.height = "25%";
    amivoiceApiSampleResultViewDialogElement.style.overflow = "hidden";
    amivoiceApiSampleResultViewDialogElement.style.top = "70%";
    amivoiceApiSampleResultViewDialogElement.style.resize = "both";
    amivoiceApiSampleResultViewDialogElement.style.maxWidth = "100%";
    amivoiceApiSampleResultViewDialogElement.style.maxHeight = "100%";

    const headerElement = document.createElement("div");
    headerElement.style.height = "12px";
    amivoiceApiSampleResultViewDialogElement.appendChild(headerElement);

    amivoiceApiSampleResultViewElement = document.createElement("div");
    amivoiceApiSampleResultViewElement.style.overflow = "auto";
    // ヘッダ分マイナス
    amivoiceApiSampleResultViewElement.style.height = "calc(100% - 12px)";

    amivoiceApiSampleResultUpdatedViewElement = document.createElement("div");
    amivoiceApiSampleResultUpdatedViewElement.style.textDecoration = "underline";
    amivoiceApiSampleResultUpdatedViewElement.style.textDecorationStyle = "dotted";
    amivoiceApiSampleResultUpdatedViewElement.setAttribute("translate", "no");
    amivoiceApiSampleResultViewElement.appendChild(amivoiceApiSampleResultUpdatedViewElement);

    amivoiceApiSampleResultViewDialogElement.appendChild(amivoiceApiSampleResultViewElement);

    document.body.appendChild(amivoiceApiSampleResultViewDialogElement);
    setDraggableElement(amivoiceApiSampleResultViewDialogElement, headerElement);

    return amivoiceApiSampleResultViewDialogElement;
}

/**
 * 透過率切替を実行したcolorを返します。
 * @param {string} color 
 * @returns 切替後のcolor
 */
function getToggleBackgroudColorAlpha(color) {
    if (color === 'undefined' || color === null) {
        return "rgba(0,0,0,0.7)";
    }
    if (color.startsWith("rgba")) {
        let array = color.split(",");
        if (array.length === 4) {
            let alpha = parseFloat(array[3].trim());
            alpha += 0.1;
            if (alpha > 1) {
                alpha = 0;
            }
            array[3] = alpha + ")";
            return array.join(',');
        }
    } else if (color.startsWith("rgb")) {
        let array = color.split(",");
        if (array.length === 3) {
            array[0] = array[0].replace("rgb", "rgba");
            array[2] = parseFloat(array[2].trim());
            return array.join(',') + ",0)";
        }
    }
    return "rgba(0,0,0,0.7)";
}

/**
 * HTML要素を移動できるよう設定します。
 * @param {object} element HTML要素
 * @param {object} headerElement HTML要素を移動するときにドラッグするヘッダー要素
 */
function setDraggableElement(element, headerElement) {
    let x = 0;
    let y = 0;

    headerElement.onmousedown = onDragStart;
    headerElement.style.cursor = "move";

    /**
     * 移動開始処理
     * @param {object} event 
     */
    function onDragStart(event) {
        event.preventDefault();
        // 最初のマウス位置取得
        x = event.clientX;
        y = event.clientY;

        document.addEventListener("mouseup", onDragEnd);
        document.addEventListener("mousemove", onDragMove);
    }

    /**
     * 移動処理
     * @param {object} event 
     */
    function onDragMove(event) {
        event.preventDefault();

        // 本体の要素の位置を変更
        element.style.left = (element.offsetLeft - (x - event.clientX)) + "px";
        element.style.top = (element.offsetTop - (y - event.clientY)) + "px";

        // 移動後のマウス位置取得
        x = event.clientX;
        y = event.clientY;
    }

    /**
     * 移動終了処理
     */
    function onDragEnd() {
        document.removeEventListener("mouseup", onDragEnd);
        document.removeEventListener("mousemove", onDragMove);
    }
}

HTML DOMのidやスタイルはWebページにも影響があります。そのため、なるべく影響がないようにidは使わずスタイルはJavaScriptで直接指定しています。
これでChromeの自動書き起こし画面風のHTML要素ができます。
後は、WebSocket音声認識APIの実行ボタンと実行処理を作るだけです。Chrome拡張機能のポップアップにボタンを作ります。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="./styles/style.css">
</head>

<body>
  <div>
    <h2>AmiVoice API Speech Recognition Sample</h2>
  </div>
  <div>
    <button id="startButton" type="button">音声認識開始</button>
  </div>
  <div>
    <button id="stopButton" type="button">音声認識停止</button>
  </div>
  <div>
    <button id="showResultButton" type="button">画面表示/非表示</button>
  </div>
  <div>
    <button id="toggleFontsizeButton" type="button">フォントサイズ切替</button>
  </div>
  <div>
    <button id="toggleAlphavalueButton" type="button">透過率切替</button>
  </div>
  <div>
    <button id="toggleAutoscrollButton" type="button">自動スクロール切替</button>
  </div>
  <div>
    <button id="showOptionsButton" type="button">設定</button>
  </div>

  <script src="./scripts/popup.js"></script>
</body>

</html>

ボタンが並んでいるだけです。次にpopup.jsを作成します。

/**
 * 結果画面の表示・非表示を切り替えます。
 */
function toggleResultView() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }

  const resultViewDialog = getResultViewDialog();
  if (!resultViewDialog) {
    createResultViewDialog();
    return;
  }

  if (resultViewDialog.style.display !== "none") {
    resultViewDialog.style.display = "none";
  } else {
    resultViewDialog.style.display = "";
  }
}

/**
 * 結果画面のフォントサイズを切り替えます。
 */
function toggleResultViewFontSize() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }

  const resultViewDialog = getResultViewDialog();
  if (!resultViewDialog) {
    createResultViewDialog();
    return;
  }

  if (resultViewDialog.style.fontSize === AMI_RESULTVIEW_FONTSIZE_SMALL) {
    resultViewDialog.style.fontSize = AMI_RESULTVIEW_FONTSIZE_MEDIUM;
  } else if (resultViewDialog.style.fontSize === AMI_RESULTVIEW_FONTSIZE_MEDIUM) {
    resultViewDialog.style.fontSize = AMI_RESULTVIEW_FONTSIZE_LARGE;
  } else {
    resultViewDialog.style.fontSize = AMI_RESULTVIEW_FONTSIZE_SMALL;
  }
}

/**
 * 結果画面の透明度を切り替えます。
 */
function toggleResultViewAlphaValue() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }

  const resultViewDialog = getResultViewDialog();
  if (!resultViewDialog) {
    createResultViewDialog();
    return;
  }
  resultViewDialog.style.backgroundColor =
    getToggleBackgroudColorAlpha(resultViewDialog.style.backgroundColor);
}

/**
 * 結果画面の自動スクロールのON/OFFを切り替えます。
 */
function toggleAutoScroll() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }
  ResultViewSetting.isAutoScroll = !ResultViewSetting.isAutoScroll;
}

/**
 * WebSocket音声認識APIを開始します。
 */
function startRecognition() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }

  let resultViewElement = getResultViewElement();
  if (!resultViewElement) {
    createResultViewDialog();
    resultViewElement = getResultViewElement();
  }

  const resultUpdatedElement = getResultUpdatedElement();
  if (!resultUpdatedElement) {
    return;
  }

  /**
   * システムログを出力します。
   * @param {string} printMessage 出力内容
   */
  const printSystemMessage = function (printMessage) {
    const date = new Date();
    const systemElement = document.createElement("div");
    const timestamp = "[" + date.toLocaleTimeString() + "] ";
    systemElement.textContent = (timestamp + printMessage);
    systemElement.style.color = "violet";
    resultViewElement.insertBefore(systemElement, resultUpdatedElement);
    resultUpdatedElement.textContent = "";

    // 最新の認識結果が見えるようにスクロールする。
    if (ResultViewSetting.isAutoScroll) {
      setTimeout(function () { resultViewElement.scrollTop = resultViewElement.scrollHeight; }, 200);
    }
  }

  if (Wrp.isActive()) {
    printSystemMessage("既に音声認識サーバーに接続中です。");
    return;
  }

  chrome.storage.local.get(null, (options) => {

    if (typeof options.authorization === 'undefined') {
      printSystemMessage("設定画面でパラメーターの設定を行ってください。");
      return;
    }

    /**
     * 文字列のHTMLエスケープを行います
     * @param {string} s 文字列
     * @returns エスケープした文字列
     */
    function sanitize_(s) {
      return s.replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/'/g, '&apos;')
        .replace(/"/g, '&quot;');
    }

    /**
     * 認識完了時の認識結果の出力を行います。
     * @param {string} printMessage 出力内容 
     * @param {string} color フォントカラー
     * @param {boolean} isHtml trueの場合はinnerHTML、それ以外はtextContentに出力
     */
    const printResultFinalized = function (printMessage, color, isHtml) {
      if (printMessage == "") {
        return;
      }

      let message = printMessage;
      if (options.useTimestamp) {
        const date = new Date();
        const timestamp = "[" + date.toLocaleTimeString() + "] ";
        message = timestamp + message;
      }

      resultUpdatedElement.textContent = "";
      const fragment = document.createDocumentFragment();

      if (options.useNoTranslate) {
        // Chromeで翻訳対象外となるようtranslate=noを設定した要素を挿入。
        const noTranslateResultFinalizedElement = document.createElement("div");
        noTranslateResultFinalizedElement.setAttribute("translate", "no");
        if (isHtml) {
          noTranslateResultFinalizedElement.innerHTML = message;
        } else {
          noTranslateResultFinalizedElement.textContent = message;
        }
        noTranslateResultFinalizedElement.style.color = color;
        fragment.appendChild(noTranslateResultFinalizedElement);
      }

      const resultFinalizedElement = document.createElement("div");
      if (isHtml) {
        if (options.useNoTranslate) {
          // ルビを削除
          resultFinalizedElement.innerHTML = message.replaceAll(/<rt>[^<]*<\/rt>/g, '').replaceAll(/<[^>]*>/g, '');
        } else {
          resultFinalizedElement.innerHTML = message;
        }
      } else {
        resultFinalizedElement.textContent = message;
      }
      resultFinalizedElement.style.color = color;
      fragment.appendChild(resultFinalizedElement);

      resultViewElement.insertBefore(fragment, resultUpdatedElement);

      // 最新の認識結果が見えるようにスクロールする。
      if (ResultViewSetting.isAutoScroll) {
        setTimeout(function () { resultViewElement.scrollTop = resultViewElement.scrollHeight; }, 200);
      }
    }

    /**
     * 認識途中結果の出力を行います。
     * @param {string} printMessage 出力内容
     * @param {string} color フォントカラー
     */
    const printResultUpdated = function (printMessage, color) {
      if (resultUpdatedElement.style.color !== color) {
        resultUpdatedElement.style.color = color;
      }
      resultUpdatedElement.textContent = printMessage;

      // 最新の認識結果が見えるようにスクロールする。
      if (ResultViewSetting.isAutoScroll) {
        setTimeout(function () { resultViewElement.scrollTop = resultViewElement.scrollHeight; }, 200);
      }
    }

    /**
     * WebSocket音声認識APIの認識結果JSONからルビ付きHTMLテキストを作成します。
     * @param {object} json 
     * @returns HTML
     */
    const toTextWithRuby = function (json) {
      if (!json.results || !json.results[0]) {
        return json.text;
      }
      let lastWritten = "";
      let resultText = "";
      for (let token of json.results[0].tokens) {
        // フィラー単語の前後の「%」を削除
        if (/^%.+%$/.test(token.written)) {
          token.written = token.written.replace(/^%(.*)%$/, "$1");
        }
        if (lastWritten.length > 0) {
          // 末尾が数字またはアルファベット、?、.、,、!の単語と先頭がアルファベットの単語の間にスペースを入れます。
          if (/[a-zA-Z0-9?.,!]$/.test(lastWritten) && /^[a-zA-Z]/.test(token.written)) {
            resultText += " ";
          }
        }
        // 読みがあり、読みと表記が異なっていて、表記がひらがな、カタカナ、半角スペース、「?」以外を含み、数字のみでない場合は、ルビを付与
        let ruby = "";
        if (typeof token.spoken !== 'undefined') {
          ruby = token.spoken.replaceAll("_", " ").replaceAll(/ {2,}/g, " ").replaceAll(".", "").trim();
        }
        let written = token.written.replaceAll("_", " ").replaceAll(/ {2,}/g, " ").trim();
        if ((ruby.length > 0) && (written !== ruby)
          && (written.search(/[^\u3040-\u309f\u30a0-\u30ff? ]/) !== -1)
          && !(/^[0-9]+$/.test(written))) {
          resultText += ('<ruby>' + sanitize_(written) + '<rt>' + sanitize_(ruby) + '</rt></ruby>');
        } else {
          resultText += sanitize_(written);
        }
        lastWritten = token.written;
      }
      return resultText;
    }

    Wrp.serverURL = "wss://acp-api.amivoice.com/v1/";
    if (options.loggingOptOut) {
      Wrp.serverURL += "nolog/";
    }
    Wrp.grammarFileNames = options.grammarFileNames;
    Wrp.authorization = options.authorization;
    Wrp.profileWords = options.profileWords;
    Wrp.keepFillerToken = options.keepFillerToken ? 1 : 0;
    Wrp.resultUpdatedInterval = 400;
    Wrp.checkIntervalTime = 600000;
    Recorder.maxRecordingTime = 3600000;
    Recorder.sampleRate = 16000;
    Recorder.downSampling = true;
    Recorder.adpcmPacking = true;
    Recorder.useUserMedia = options.useUserMedia;
    Recorder.useDisplayMedia = options.useDisplayMedia;
    Recorder.useOpusRecorder = options.useOpusRecorder;

    Wrp.TRACE = function (message) {
      if (message.startsWith("ERROR:")) {
        printSystemMessage(message);
      } else if (options.useTrace) {
        console.log(maskTraceMessage(message));
      }
    };

    Wrp.connectStarted = function () {
      printSystemMessage("音声認識サーバー接続中...");
    };

    Wrp.connectEnded = function () {
      printSystemMessage("音声認識サーバー接続完了(音声認識準備完了)。");
    };

    Wrp.disconnectStarted = function () {
      printSystemMessage("音声認識サーバー切断中...");
    };

    Wrp.disconnectEnded = function () {
      printSystemMessage("音声認識サーバー切断完了。");
    };

    Wrp.resultUpdated = function (result) {
      printResultUpdated(JSON.parse(result).text, "white");
    };

    Wrp.resultFinalized = function (result) {
      if (options.useSpoken) {
        printResultFinalized(toTextWithRuby(JSON.parse(result)), "white", true);
      } else {
        printResultFinalized(JSON.parse(result).text, "white", false);
      }
    };

    try {
      Wrp.feedDataResume();
    } catch (e) {
      printSystemMessage(e.message);
    }
  });
}

/**
 * WebSocket音声認識APIを停止させます。
 */
function stopRecognition() {
  // サイト上で実行されるスクリプト

  // ページが読み込まれた後にChrome拡張機能を更新されたり無効から有効にされると、
  // content_scriptsが使えないようなのでチェック。
  if (typeof Wrp === 'undefined') {
    alert("スクリプトが読み込まれていません。ページを再読み込みしてください。");
    return;
  }
  if (Wrp.isActive()) {
    Wrp.feedDataPause();
  }
}

/**
 * content_scriptが使用できない旨のアラートを表示します。
 */
function alertCantWorkScript() {
  alert("「https://」以外のサイトでは使用できません。");
}

// 音声認識開始ボタンのクリックイベント設定
document.getElementById("startButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: startRecognition
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// 音声認識停止ボタンのクリックイベント設定
document.getElementById("stopButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: stopRecognition
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// 画面表示/非表示ボタンのクリックイベント設定
document.getElementById("showResultButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: toggleResultView,
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// フォントサイズ切替ボタンのクリックイベント設定
document.getElementById("toggleFontsizeButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: toggleResultViewFontSize,
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// 自動スクロール切替ボタンのクリックイベント設定
document.getElementById("toggleAutoscrollButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: toggleAutoScroll,
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// 透過率切替ボタンのクリックイベント設定
document.getElementById("toggleAlphavalueButton").addEventListener("click", async () => {
  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    if (tabs[0].url.startsWith("https://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: toggleResultViewAlphaValue,
      });
    } else if (tabs[0].url.startsWith("http://")) {
      chrome.scripting.executeScript({
        target: { tabId: tabs[0].id },
        function: alertCantWorkScript
      });
    }
  });
});

// 設定ボタンのクリックイベント設定
document.getElementById("showOptionsButton").addEventListener("click", async () => {
  chrome.runtime.openOptionsPage(null);
});

ボタンクリック時の処理が並んでいるだけです。
chrome.storage.localchrome.tabs.query()chrome.scripting.executeScript()がわかれば後は問題ないかと思います。
chrome.scripting.executeScript()で現在アクティブなタブ、Webページ上でスクリプトを実行します。
chrome.scripting.executeScript()で呼び出された関数から、popup.js上で定義されている他の関数を呼び出すことができないことに注意してください。
これで基本的には完成なのですが、AmiVoice API クライアントライブラリのrecorder.jsでは、AudioWorklet.addModule()で動的なスクリプトコードを実行(文字列をスクリプトコードとして実行)しているため、Webページによっては、CSPによる制限で実行エラーになる場合があります。そのため、別のファイルに外出ししてファイルをAudioWorklet.addModule()するように変更します。
AudioWorklet.addModule()で登録している文字列をファイルに外出しして、chrome.runtime.getURL()で参照するようにするだけです。

// 各種変数の初期化
async function initialize_() {
  // 録音関係の各種変数の初期化
  audioContext_ = new AudioContext({ sampleRate: recorder_.sampleRate });
  // ファイルをaddModule()するように変更。
  /*
  await audioContext_.audioWorklet.addModule(URL.createObjectURL(new Blob([
      "registerProcessor('audioWorkletProcessor', class extends AudioWorkletProcessor {",
      "  constructor() {",
      "    super()",
      "  }",
      "  process(inputs, outputs, parameters) {",
      "    if (inputs.length > 0 && inputs[0].length > 0) {",
      "      if (inputs[0].length === 2) {",
      "        for (var j = 0; j < inputs[0][0].length; j++) {",
      "          inputs[0][0][j] = (inputs[0][0][j] + inputs[0][1][j]) / 2",
      "        }",
      "      }",
      "      this.port.postMessage(inputs[0][0], [inputs[0][0].buffer])",
      "    }",
      "    return true",
      "  }",
      "})"
  ], {type: 'application/javascript'})));
  */
  await audioContext_.audioWorklet.addModule(chrome.runtime.getURL('./lib/wrp/processor.js'));
processor.jsの中身です。
registerProcessor('audioWorkletProcessor', class extends AudioWorkletProcessor {
  constructor() {
    super();
  }
  process(inputs, outputs, parameters) {
    if (inputs.length > 0 && inputs[0].length > 0) {
      if (inputs[0].length === 2) {
        for (var j = 0; j < inputs[0][0].length; j++) {
          inputs[0][0][j] = (inputs[0][0][j] + inputs[0][1][j]) / 2;
        }
      }
      this.port.postMessage(inputs[0][0], [inputs[0][0].buffer]);
    }
    return true;
  }
});

これで完成です。
設定画面にAmiVoice APIのAPPKEYを入力、保存し、任意のWebページ上でChrome拡張機能の「音声認識開始」ボタンをクリックすると、音声認識が始まるはずです。
マイクとシステムの音声の最大録音時間は1時間です。変更したい場合は、popup.jsで設定しているRecorder.maxRecordingTimeの値を変更してください。ただし、値を変更した場合の検証は行っていません。
それからおまけとして、HTML要素の属性にtranslate=noをつけ、翻訳対象外にした認識結果も同時に表示する機能を付けました。ブラウザの翻訳機能を有効にすると翻訳前と翻訳後のテキストが確認できます。ただし、ブラウザの翻訳機能には、おそらく翻訳できる文字数や単語数に上限があり、無制限に翻訳を行えるわけではありません。

以上です。最後まで読んでいただき、ありがとうございます。

この記事を書いた人

  • AmiVoice APIインフラチームメンバーD

    駆け出しインフラエンジニア。猫派。
    プログラマーからインフラエンジニアにクラスチェンジ。
    「Webページ編」と「Chrome拡張機能編」を書きました。

APIを無料で利用開始