感情とテンポでダイナミックに音声を生成:Gemini 3.1 Flash TTSプレビュー、Angular、Firebase Cloud Functions(GDE)

Dev.to / 2026/5/2

📰 ニュースDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • Googleは、Gemini API、Vertex AI、Gemini AI Studioで利用可能な「Gemini 3.1 Flash TTSプレビュー」モデルを公開し、「Audio tags」機能によって人間の感情・テンポ・スタイルを表現できるようにしました。
  • 記事では、Firebase AI Logicで画像を解析して推薦や「オブスキュアな事実」を生成し、その事実をFirebase Cloud Functionに渡してGemini TTSモデルで音声を作るアプリの流れを示しています。
  • Cloud Functionは生成した音声をストリーミングでフロントエンドに返し、Angular側でBlob URLに変換してオーディオプレーヤーで再生します。
  • 著者はAngularアプリをGemini 3.1 Flash TTSプレビューに移行し、シーン・感情・ペースを入力するAngularのsignalフォームを作成して、GenAI TypeScript SDKで表現力のある音声生成を行います。
  • さらに、Angular、Node.js LTS、Firebase Remote Config、ローカル検証のためのFirebase Local Emulator Suite、API利用制限(香港)を踏まえて信頼性の高いVertex AIのエンタープライズアクセスを用いる構成とセットアップ手順を解説しています。

Googleは、Gemini API、Vertex AI上のGemini、Gemini AI StudioでのAIオーディオ生成向けに、Gemini 3.1 Flash TTS Previewモデルをリリースしました。このモデルは、人間の感情、テンポ、スタイルを表現するための新しいAudio tags機能を導入します。

このアプリケーションは、Firebase AI Logicを活用してアップロードされた画像を分析し、推奨事項、説明、代替タグ、そして一つの見落とされがちな事実(オブスキュアなファクト)を生成することを探ります。このオブスキュアな事実はFirebase Cloud Functionに送られ、Gemini TTSモデルを使って音声を生成します。Cloud FunctionはストリームをAngularアプリケーションに返し、AngularはそれをBlob URLオブジェクトに変換します。音声プレーヤーがそのURLをソースとして設定し、ユーザーは再生ボタンをクリックしてストリームを再生できます。

このブログ記事では、私のアプリケーションをGemini 3.1 Flash TTS Previewモデルを使うように移行し、Angularでシーン、感情、テンポを入力する信号(シグナル)フォームを作成します。次に、Angularアプリケーションがフォームの値とオブスキュアな事実をFirebase Cloud Functionに渡し、GenAI TypeScript SDKとGemini 3.1 Flash TTS Previewモデルを使って表情豊かな声を生成します。

前提条件

プロジェクトの技術スタック:

  • Angular 21: 2026年5月時点での最新バージョン。
  • Node.js LTS: 2026年5月時点でのLTSバージョン。
  • Firebase Remote Config: 動的パラメータを管理するため。
  • Firebase Cloud Functions: フロントエンドから呼び出されたときに、表情豊かな人間の声を生成するため。
  • Firebase Local Emulator Suite: http://localhost:5001 でローカルに関数をテストするため。
  • Vertex AI上のGemini: 動画を生成し、それをFirebase Cloud Storageに保存するため。

公開されているGoogle AI Studio APIは、私の地域(香港)では制限されています。しかし、Vertex AI(Google Cloud)には信頼性の高いエンタープライズアクセスが用意されているため、このデモではVertex AIを選びました。

npm i -g firebase-tools

firebase-toolsnpmでグローバルにインストールします。

firebase logout
firebase login

Firebaseからログアウトして、再度ログインし、適切なFirebase認証を実行します。

firebase init

firebase init を実行し、Firebase Cloud Functions、Firebase Local Emulator Suite、Firebase Cloud Storage、Firebase Remote Configをセットアップするためのプロンプトに従います。

既存のプロジェクト、または複数のプロジェクトがある場合は、コマンドラインでプロジェクトIDを指定できます。

firebase init --project <PROJECT_ID>

いずれの場合も、Firebase CLIは自動的にfirebase-adminおよびfirebase-functionsの依存関係をインストールします。

セットアップ手順を完了すると、Firebaseツールが関数のエミュレータ、関数、ストレージのルールファイル、リモートコンフィグのテンプレート、そして.firebasercfirebase.jsonのような設定ファイルを生成します。

  • Angularの依存関係
npm i firebase

Angularアプリケーションは、Firebaseアプリを初期化し、リモートコンフィグを読み込み、Firebase Cloud Functionsを呼び出して動画を生成するために、firebaseの依存関係を必要とします。

  • Firebaseの依存関係
npm i @cfworker/json-schema @google/genai @modelcontextprotocol/sdk

上記の依存関係をインストールして、Vertex AI上のGeminiにアクセスします。@google/genai@cfworker/json-schemaおよび@modelcontextprotocol/sdkに依存しています。これらがない場合、Cloud Functionsは起動できません。

プロジェクトを設定できたので、フロントエンドとバックエンドがどのように通信するかを見ていきましょう。

アーキテクチャ

オブスキュアな事実生成の全体アーキテクチャ

ユーザーはAngularアプリケーションで画像をアップロードし、Gemini 3.1 Flash Lite Previewモデルにプロンプトを与えて、画像を改善するためのいくつかの推奨事項、説明、代替タグを生成します。また、ユーザーは同じモデルとGoogle Searchツールを使って、画像に関連するオブスキュアな事実を見つけます。

音声生成の全体アーキテクチャ

ユーザーは、実験的な信号フォームでシーン、感情、テンポを入力します。ユーザーが「音声を生成」ボタンをクリックすると、Angularアプリケーションはフォームの値とオブスキュアな事実をFirebase Cloud Functionに送信し、GenAI TypeScript SDKとGemini 3.1 Flash TTS Previewモデルを用いて表情豊かな声を生成します。

Gemini 3.1 Flash TTS Previewモデルの制限

  • モデルはテキスト入力のみを受け付け、音声出力を生成できます。
  • コンテキストウィンドウは32Kトークンです。
  • TTSはストリーミングをサポートしていません。
  • 対応言語はhttps://ai.google.dev/gemini-api/docs/speech-generation#languagesで確認できます。私の母語である広東語は、現在未対応です。

Firebase連携

1. 環境変数を設定する

Firebaseプロジェクト内で環境変数を定義しておくことで、関数がGoogle Cloudプロジェクトのリージョン、Firebase Cloud Functionのロケーション、必要なTTSモデルを把握できるようになります。

.env.example

GOOGLE_CLOUD_LOCATION="global"
GOOGLE_FUNCTION_LOCATION="asia-east2"
GEMINI_TTS_MODEL_NAME="gemini-3.1-flash-tts-preview"
WHITELIST="http://localhost:4200"
REFERER="http://localhost:4200/"
変数 説明
GOOGLE_CLOUD_LOCATION Google Cloud プロジェクトのリージョン。Firebase プロジェクトが最新の Gemini 3.1 Flash TTS プレビュー・モデルにアクセスできるように、global を選択しました。
GOOGLE_FUNCTION_LOCATION Firebase Cloud Functions のリージョン。私が住んでいるのがこのリージョンなので、asia-east2 を選択しました。
WHITELIST リクエストは http://localhost:4200 からのみ受け付ける必要があります。
REFERER リクエストの発信元は http://localhost:4200/ です。

http://localhost:4200 は、私のローカル Angular アプリケーションのホスト名とポート番号です。

2. 環境変数の検証

Cloud Function が最初に AI 呼び出しを実行する前に、必要なすべての環境変数が存在することを確認するのが重要です。TTS モデル名、Google Cloud プロジェクト ID、ロケーションなどの環境変数を検証するために、AUDIO_CONFIG の IIFE(即時実行関数式)を実装しました。

import logger from "firebase-functions/logger";

export function validate(value: string | undefined, fieldName: string, missingKeys: string[]) {
    const err = `${fieldName} is missing.`;
    if (!value) {
        logger.error(err);
        missingKeys.push(fieldName);
        return "";
    }

    return value;
}
export const AUDIO_CONFIG = (() => {
    logger.info("AUDIO_CONFIG initialization: Loading environment variables and validating configuration...");

    const env = process.env;

    const missingKeys: string[] = [];
    const location = validate(env.GOOGLE_CLOUD_LOCATION, "Vertex Location", missingKeys);
    const model = validate(env.GEMINI_TTS_MODEL_NAME, "Gemini TTS Model Name", missingKeys);
    const project = validate(env.GCLOUD_PROJECT, "Google Cloud Project", missingKeys);

    if (missingKeys.length > 0) {
        throw new HttpsError("failed-precondition", `Missing environment variables: ${missingKeys.join(", ")}`);
    }

    return {
        genAIOptions: {
            project,
            location,
            vertexai: true,
        },
        model,
    };
})();

2026 年 5 月時点で私は Node 24 を使用しています。Node 20 からは、process.loadEnvFile 関数を使って .env ファイルから環境変数を読み込めます。

env.ts では、try-catch ブロックが .env ファイルから環境変数を読み込もうとします。

try {
    process.loadEnvFile();
} catch {
    // .env ファイルが見つからない場合(例:環境変数がプラットフォームによって設定される本番環境)にはエラーを無視する
}

src/index.ts では、最初のステートメントが、他のファイルやライブラリを読み込む前に env.ts をインポートします。

import "./env";

... other import statements ...

もし process.loadEnvfile をサポートしていない Node バージョンを使用している場合、代替手段として、環境変数を読み込むために dotenv をインストールします。

npm i dotenv
import dotenv from "dotenv";

dotenv.config();

Firebase は GCLOUD_PROJECT 変数を提供するため、.env ファイルでは定義されていません。

missingKeys 配列が空でない場合、AUDIO_CONFIG は、欠落している変数名をすべて列挙するエラーをスローします。検証が成功すると、genAIOptionsmodel が返されます。genAIOptionsGoogleGenAI を初期化するために使用され、model は選択された TTS モデル名です。

3. プロンプト入力をサニタイズする

Cloud Function は、音声プロンプトを作成する前に、シーンとトランスクリプトをサニタイズします。

sanitizeScene 関数は、シーンを受け取り、新行文字(' ')を '\ ' でエスケープします。新行文字は空行を作り、しばしばブロックの終了を示します。サニタイズによってシーンは実質的に 1 つの連続したデータ行にフラット化され、LLM の Markdown パーサーはそれを 1 つの安全な段落として認識します。サニタイズは、シーンに注入されるすべての Markdown 見出しも削除します。

function sanitizeScene(text: string): string {
    return (text || "").trim().replace(/\r?
/g, "\\n").replace(/^[#\s]+/gm, "");
}

sanitizeTranscript 関数は、トランスクリプトに注入されるすべての Markdown 見出しと三重引用符を削除することで、トランスクリプトを受け取ります。

function sanitizeTranscript(text: string): string {
    return (text || "").trim().replace(/^#+/gm, "").replace(/"""/g, '"' );
}

4. 音声プロンプトを構築する

AudioPrompt インターフェースは、シーン、感情、ペース、トランスクリプト、音声オプションをカプセル化し、音声の場所、音声タグ、テキスト、パーソナを設定します。

export type AudioPrompt = {
  scene: string;
  emotion: string;
  pace: string;
  transcript: string;
  voiceOption: string;
}

SCENE_DICTIONARY はシーンの配列です。ユーザーがシーンを指定しない場合、配列からシーンがランダムに選択されます。

export const SCENE_DICTIONARY = [
    "薄暗く照らされた埃だらけの図書館。古い革装丁の本でいっぱい。
" +
        "空気は歴史の匂いで重い。学究肌のアーカイブ担当が、温かくヴィンテージなリボンマイクに身を寄せている。
" +
        "彼らは伝染するような、抑えた熱意で語る。朽ちかけた写本の中からついに見つけた、忘れられた秘密をぜひ共有したいのだ。",

    "ガラス張りのスタジオで、月明かりのロンドンのスカイラインを見下ろす。時刻は 10:00 PM だが、中はまぶしいほど明るい。
" +
        "赤い『ON AIR』の集計灯が燃え盛っている。話し手は立ち上がっていて、ドンドン鳴るバックトラックのリズムに合わせて、かかとで跳ねている。
" +
        "カフェインで活気づいた、混沌としたコックピットだ。国中を目覚めさせるために作られている。",

    "郊外の住宅にある、細部までこだわって音響処理された寝室。
" +
        "空間は、ふかふかのベルベットのカーテンと重いラグによって吸音され、親密で、クローズアップのような音響環境が生まれている。
" +
        "話し手は、信頼できる友人が内緒の冗談を教えてくれるような口調で情報を伝える。",

返却形式: {"translated": "翻訳されたHTML"}"サーバーが唸る、高度でミニマルな実験室。
" +
        "ガラスとスチールに反射して、澄んだ清潔感のある音響が響き渡る。
" +
        "天才的だが気難しい科学者が行ったり来たりしながら、ヘッドセットのマイクに向かって矢継ぎ早に、そして熱心に話している。複雑な現象を説明するのが待ちきれない様子だ。",
];

高度な音声プロンプトを組み立てるために、私はbuildAudioPrompt関数を定義します。
感情が定義されている場合、タグは[<emotion>]です。ペースが定義されている場合、タグは[<pace>]です。結合された音声タグは[<emotion>] [<pace>]<a space>で、適切なトークン境界を作るために使用します。

insertAudioTagsToTranscriptは、正規表現を使ってトランスクリプトを行に分割し、各行の前に結合された音声タグを挿入してから、それらを空文字で結合します。

buildAudioPromptは、シーンと表現のあるトランスクリプトを文字列に連結して返します。

import { SCENE_DICTIONARY } from './constants/scenes.const';
import { AudioPrompt } from './types/audio-prompt.type';

function makeTag(value: string) {
    const trimmedValue = value.trim();
    return trimmedValue ? `[${trimmedValue}] ` : "";
}

function insertAudioTagsToTranscript({transcript, pace, emotion }: AudioPrompt): string {
    const audioTags = `${makeTag(emotion)}${makeTag(pace)}`;
    const cleanedTranscript = sanitizeTranscript(transcript);

    const parts = cleanedTranscript.split(/(?<!\b(?:Mr|Mrs|Ms|Dr|St|i\.e|e\.g))([.!?
\r]+[”"’']*\s*)/);
    return parts
        .map((text, i, arr) => {
            if (i % 2 !== 0) {
                return ""; // 区切り文字はスキップします。区切り文字はテキストブロックに付加されるためです
            }
            const delimiter = arr[i + 1] || "";
            return text.trim() ? `${audioTags}${text.trim()}${delimiter}` : delimiter;
        })
        .join("");
}

export function buildAudioPrompt(data: AudioPrompt): string {
    const randomIndex = Math.floor(Math.random() * SCENE_DICTIONARY.length);
    const selectedScene = SCENE_DICTIONARY[randomIndex];const trimmedScene = (data.scene || "").trim() || selectedScene;
    const escapedScene = sanitizeScene(trimmedScene);
    const transcript = insertAudioTagsToTranscript(data);

    return `## シーン:
${escapedScene}

## トランスクリプト:
"""
${transcript}
"""
`;
}

プロンプトの出力は次のようになります:

## シーン:
<scene>

## トランスクリプト:
[<emotion>] [<pace>] <sentence 1>[<emotion>] [<pace>] <sentence 2>...[<emotion>] [<pace>] <sentence N>

5. Firebase Cloud Function で人間の音声の表現を生成する

createVoiceConfig 関数は、指定された音声名の音声で読み上げを出力する GenerateContentConfig のインスタンスを構築します。

import { GenerateContentConfig } from "@google/genai";

export function createVoiceConfig(voiceName = "Kore"): GenerateContentConfig {
    return {
        responseModalities: ["audio"],
        speechConfig: {
            voiceConfig: {
                prebuiltVoiceConfig: {
                    voiceName,
                },
            },
        },
    };
}
const splitList = (whitelist?: string) => (whitelist || "").split(",").map((origin) => origin.trim());

export const whitelist = splitList(process.env.WHITELIST);
export const cors = whitelist.length > 0 ? whitelist : true;
export const refererList = splitList(process.env.REFERER);

すべての Cloud Functions は、App Check、CORS、および 600 秒のタイムアウト期間を強制します。WHITELIST が指定されていない場合、CORS はデフォルトで true になります。デモ環境では許容されますが、本番環境では認可されていないアクセスを防ぐために、CORS を特定のドメイン、または false に設定してください。

readFact の Cloud Function は、isStreaming が true のとき readFactStreamFunction に委譲します。そうでない場合は、readFactFunction に委譲されます。

readFactFunction 関数は、エンコードされた base64 文字列である Promise<string> を返します。

readFactStreamFunction 関数は、WAV ヘッダーのバイト列を表す Promise<number[] | undefined> を返します。

返却形式: {"translated": "翻訳されたHTML"}
import { onCall } from "firebase-functions/v2/https";
import { cors } from "../auth";
import { buildAudioPrompt } from './audio-prompt';
import { readFactFunction, readFactFunctionStream } from "./read-fact";
import { createVoiceConfig } from './voice-config';

const options = {
    cors,
    enforceAppCheck: true,
    timeoutSeconds: 600,
};

export const readFact = onCall(options, (request, response) => {
    const { data, acceptsStreaming } = request;
    const isStreaming = acceptsStreaming && !!response;
    const prompt = buildAudioPrompt(data);
    const voiceOption = createVoiceConfig(data.voiceOption);

    return isStreaming
        ? readFactStreamFunction(prompt, voiceOption, response)
        : readFactFunction(prompt, voiceOption);
});

withAIAudio 関数は、高階関数です。コールバックを呼び出して音声ストリームを生成します。

async function withAIAudio(callback: (ai: GoogleGenAI, model: string) => Promise<string | number[] | undefined>) {
    try {
        const variables = AUDIO_CONFIG;
        if (!variables) {
            return "";
        }

        const { genAIOptions, model } = variables;
        const ai = new GoogleGenAI(genAIOptions);
        return await callback(ai, model);
    } catch (e) {
        if (e instanceof HttpsError) {
            throw e;
        }
        throw new HttpsError("internal", "AIクライアントのセットアップ中に内部エラーが発生しました。", {
            originalError: (e as Error).message,
        });
    }
}

generateAudio はコールバック関数です。Gemini 3.1 Flash TTS Preview モデルを使用して応答を生成します。getBase64DataUrlextractInlineAudioData を呼び出して、応答から生データと mime タイプを抽出します。encodeBase64String 関数はまず生データを WAV 形式に変換し、次に base64 形式にエンコードし、最後に base64 文字列を返します。

createAudioParams 関数は、Gemini TTS モデル、音声プロンプト、音声(スピーチ)設定を用いてパラメータを構築します。

async function generateAudio(aiTTS: AIAudio, prompt: string, voiceOption: GenerateContentConfig) {
    try {
        const { ai, model } = aiTTS;
        const response = await ai.models.generateContent(createAudioParams(model, prompt, voiceOption));
        return getBase64DataUrl(response);
    } catch (error) {
        console.error(error);
        throw error;
    }
}

function createAudioParams(model: string, prompt: string, config?: GenerateContentConfig) {
    return {
        model,
        contents: [
            {
                role: "user",
                parts: [
                    {
                        text: prompt,
                    },
                ],
            },
        ],
        config,
    };
}

function extractInlineAudioData(response: GenerateContentResponse): {
    rawData: string | undefined;
    mimeType: string | undefined;
} {
    const { data: rawData, mimeType } = response.candidates?.[0]?.content?.parts?.[0]?.inlineData ?? {};

    return { rawData, mimeType };
}

function getBase64DataUrl(response: GenerateContentResponse) {
    const { rawData, mimeType } = extractInlineAudioData(response);

    if (!rawData || !mimeType) {
        throw new Error("Audio generation failed: No audio data received.");
    }

    return encodeBase64String({ rawData, mimeType });
}

export function encodeBase64String({ rawData, mimeType }: RawAudioData) {
    const wavBuffer = convertToWav(rawData, mimeType);
    const base64Data = wavBuffer.toString("base64");
    return `data:audio/wav;base64,${base64Data}`;
}

generateAudioStream は、Gemini 3.1 Flash TTS Preview モデルを使用して音声チャンクのリストをストリーミングするコールバック関数です。チャンクは反復処理され、それぞれのチャンクが extractInlineAudioData 関数に渡されて、生データと mime type が抽出されます。この関数は、チャンクの生データをバッファーに変換し、それをクライアントへ送信します。バイト長は蓄積され、すべてのチャンクの合計サイズを算出します。

すべてのチャンクがクライアントへ送信された後、createWavHeader 関数はバイト長の合計と音声オプションを使用して WAV ヘッダーを構築し、それを返します。

async function generateAudioStream(
    aiTTS: AIAudio,
    prompt: string,
    voiceOption: GenerateContentConfig,
    response: CallableResponse<unknown>,
): Promise<number[] | undefined> {
    try {
        const { ai, model } = aiTTS;
        const chunks = await ai.models.generateContentStream(createAudioParams(model, prompt, voiceOption));
        let byteLength = 0;
        let options: WavConversionOptions | undefined = undefined;
        for await (const chunk of chunks) {
            const { rawData, mimeType } = extractInlineAudioData(chunk);
            if (!options && mimeType) {
                options = parseMimeType(mimeType);
                response.sendChunk({
                    type: "metadata",
                    payload: {
                        sampleRate: options.sampleRate,
                    },
                });
            }

            if (rawData && mimeType) {
                const buffer = Buffer.from(rawData, "base64");
                byteLength = byteLength + buffer.length;
                response.sendChunk({
                    type: "data",
                    payload: {
                        buffer,
                    },
                });
            }
        }

        if (options && byteLength > 0) {
            const header = createWavHeader(byteLength, options);
            return [...header];
        }

        return undefined;
    } catch (error) {
        console.error(error);
        throw error;
    }
}

readFactFunction は高階関数 withAIAudio を呼び出して、base64 エンコードされた文字列を生成します。

readFactStreamFunction 関数は高階関数 withAIAudio を呼び出して、チャンクを書き込み、レスポンスボディへ送信します。その後、generateAudioStream 関数は WAV ヘッダーのバイト列を返します。

export async function readFactFunction(prompt: string, voiceOption: GenerateContentConfig) {
    return withAIAudio((ai, model) => generateAudio({ ai, model }, prompt, voiceOption));
}

export async function readFactStreamFunction(prompt: string, voiceOption: GenerateContentConfig, response: CallableResponse<unknown>) {
    return withAIAudio((ai, model) => generateAudioStream({ ai, model }, prompt, voiceOption, response));
}

6. Firebase アプリ設定と reCAPTCHA サイトキー

Firebase アプリの環境変数を検証するため、1 回だけ実行する FIREBASE_APP_CONFIG の IIFE(Immediately Invoked Function Expression)を実装しました。

export const FIREBASE_APP_CONFIG = (() => {
    const env = process.env;
    const missingKeys: string[] = [];
    const apiKey = validate(env.APP_API_KEY, "API Key", missingKeys);
    const appId = validate(env.APP_ID, "App Id", missingKeys);
    const messagingSenderId = validate(env.APP_MESSAGING_SENDER_ID, "Messaging Sender ID", missingKeys);
    const recaptchaSiteKey = validate(env.RECAPTCHA_ENTERPRISE_SITE_KEY, "Recaptcha site key", missingKeys);
    const projectId = validate(env.GCLOUD_PROJECT, "Project ID", missingKeys);

    if (missingKeys.length > 0) {
        throw new Error(`Missing environment variables: ${missingKeys.join(", ")}`);
    }

    return {
        app: {
            apiKey,
            appId,
            projectId,
            messagingSenderId,
            authDomain: `${projectId}.firebaseapp.com`,
            storageBucket: `${projectId}.firebasestorage.app`,
        },
        recaptchaSiteKey,
    };
})();

getFirebaseConfig 関数は、返却する前に FIREBASE_APP_CONFIG を 1 時間キャッシュします。

Angular アプリケーションは、Cloud Function から Firebase アプリの設定と reCAPTCHA サイトキーを受け取り、Firebase AI Logic を初期化し、不正アクセスや悪用からリソースを保護します。

返却形式: {"translated": "翻訳されたHTML"}
export const getFirebaseConfig = onRequest({ cors }, (request, response) => {
    if (!validateRequest(request, response)) {
        return;
    }

    try {
        response.set("Cache-Control", "public, max-age=3600, s-maxage=3600");
        response.json(FIREBASE_APP_CONFIG);
    } catch (err) {
        console.error(err);
        response.status(500).send("Internal Server Error");
    }
});

7. エミュレータを使ったローカル開発

ローカル開発では、コストと時間を節約するために Firebase Local Emulator Suite を使用しました。bootstrapFirebase のプロセスでは、アプリケーションが connectFunctionsEmulator を呼び出して、http://localhost:5001 で動作している Cloud Functions に接続します。

firebase init を実行すると、ポート番号は 5001 にデフォルト設定されました。

function connectEmulators(functions: Functions, remoteConfig: RemoteConfig) {
  if (location.hostname === 'localhost') {
    const host = getValue(remoteConfig, 'functionEmulatorHost').asString();
    const port = getValue(remoteConfig, 'functionEmulatorPort').asNumber();
    connectFunctionsEmulator(functions, host, port);
  }
}

loadFirebaseConfig は補助関数で、Firebase App の設定と reCAPTCHA のサイトキーを取得するために Cloud 関数へリクエストを行います。

{
  "getFirebaseConfigUrl": "http://127.0.0.1:5001/vertexai-firebase-6a64f/us-central1/getFirebaseConfig"
}
export type FirebaseConfigResponse = {
  app: FirebaseOptions;
  recaptchaSiteKey: string
}
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, lastValueFrom, throwError } from 'rxjs';
import config from '../../public/config.json';
import { FirebaseConfigResponse } from './ai/types/firebase-config.type';

返却形式: {"translated": "翻訳されたHTML"}async function loadFirebaseConfig() {
  const httpService = inject(HttpClient);
  const firebaseConfig$ =
    httpService.get<FirebaseConfigResponse>(config.getFirebaseConfigUrl)
      .pipe(catchError((e) => throwError(() => e)));
  return lastValueFrom(firebaseConfig$);
}

bootstrapFirebase 関数は FirebaseApp と App Check を初期化し、Firebase のリモート設定とクラウド関数を読み込み、後で使用するために設定サービスに保存します。

export async function bootstrapFirebase() {
    try {
      const configService = inject(ConfigService);
      const firebaseConfig = await loadFirebaseConfig();
      const { app, recaptchaSiteKey } = firebaseConfig;
      const firebaseApp = initializeApp(app);
      const remoteConfig = await fetchRemoteConfig(firebaseApp);

      initializeAppCheck(firebaseApp, {
        provider: new ReCaptchaEnterpriseProvider(recaptchaSiteKey),
        isTokenAutoRefreshEnabled: true,
      });

      const functionRegion = getValue(remoteConfig, 'functionRegion').asString();
      const functions = getFunctions(firebaseApp, functionRegion);
      connectEmulators(functions, remoteConfig);

      configService.loadConfig(firebaseApp, remoteConfig, functions);
    } catch (err) {
      console.error(err);
    }
}

AppConfig は変更されません。

import { ApplicationConfig, provideAppInitializer } from '@angular/core';
import { bootstrapFirebase } from './app.bootstrap';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(async () => bootstrapFirebase()),
  ]
};

8. Angular 実装

8.1 Audio Tags コンポーネント

私は AudioTagsComponent と、新しい信号フォームを作成し、Angular フロントエンドでシーン、感情、テンポ、ボイス名を入力できるようにしました。

<div>
  <h3>
    <span class="text-xl"></span> 音声生成をカスタマイズ
  </h3>

  <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
    <!-- シーン -->
    <div class="flex flex-col gap-1.5 md:col-span-2">
      <label for="scene">シーンの説明</label>
      <textarea id="scene" [formField]="audioPromptForm.scene"
      ></textarea>
    </div>

返却形式: {"translated": "翻訳されたHTML"}<!-- Emotion -->
    <div class="flex flex-col gap-1.5">
      <label for="emotion">ボーカルの感情</label>
      <input type="text" id="emotion" [formField]="audioPromptForm.emotion"
        placeholder="例:パニック、囁き"
      />
    </div>

    <!-- Pace -->
    <div class="flex flex-col gap-1.5">
      <label for="pace">話す速さ</label>
      <input type="text" id="pace" [formField]="audioPromptForm.pace"
        placeholder="例:非常にゆっくり、速い"
      />
    </div>

    <!-- Voice Option -->
    <div class="flex flex-col gap-1.5 md:col-span-2">
      <label for="voiceOption">AIボイスモデル</label>
      <select id="voiceOption" [formField]="audioPromptForm.voiceOption"
      >
        <option value="" disabled selected>ボイスを選択...</option>
        @for (option of sortedVoiceOptions(); track option.name) {
          <option [value]="option.name" class="bg-slate-800">{{ option.label }}</option>
        }
      </select>
    </div>
  </div>
</div>
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { VOICE_OPTIONS } from './constants/voice-options.const';
import { AudioPromptData } from './types/audio-prompt-data.type';

@Component({
  selector: 'app-audio-tags',
  imports: [FormField],
  templateUrl: './audio-tags.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AudioTagsComponent {
    #audioPromptModel = signal<AudioPromptData>({
      scene: '忙しいニュースルームでニュースを読み上げるニュースアンカー',
      emotion: 'プロフェッショナルで、少し真面目',
      pace: '中くらいで、はっきりとした発音',
      voiceOption: 'Kore'
    });
    audioPromptForm = form(this.#audioPromptModel);

    sortedVoiceOptions = computed(() => {
      const sortedList = VOICE_OPTIONS.sort((a, b) => a.name.localeCompare(b.name));return sortedList.map(option => ({
        name: option.name,
        label: `${option.name} - ${option.description}`
      }));
    });

    audioPromptModel = this.#audioPromptModel.asReadonly();
}

AudioTagsComponentObscureFactComponent に取り込まれ、ユーザーが実験的なシグナルフォームに値を入力できるようにします。

ObscureFactComponent の HTML テンプレートでは、<app-audio-tags> にテンプレート変数 audioTags があり、audioTags.audioPromptModel()AudioPromptData のインスタンスに解決されます。このデータは generateSpeech メソッドの audioTags プロパティに割り当てられます。

<div class="w-full mt-6">
    <app-audio-tags #audioTags />

    <h3>タグについての驚きの、または風変わりな事実</h3>
    @if (interestingFact()) {
      <p>{{ interestingFact() }}</p>

      <app-error-display [error]="ttsError()" />

      <app-text-to-speech
        [isLoadingSync]="isLoadingSync()"
        [isLoadingStream]="isLoadingStream()"
        [isLoadingWebAudio]="isLoadingWebAudio()"
        [audioUrl]="audioUrl()"
        (generateSpeech)="generateSpeech({ mode: $event, audioTags: audioTags.audioPromptModel() })"
        [playbackRate]="playbackRate()"
      />
    } @else {
      <p>そのタグ(またはタグ群)には、面白い、または風変わりな事実はありません。</p>
    }
</div>
import { AudioPromptData } from './audio-prompt-data.type';
import { GenerateSpeechMode } from '../../generate-audio.util';

export type ModeWithAudioTags = {
  mode: GenerateSpeechMode;
  audioTags: AudioPromptData;
};

export type AudioPrompt = {
  scene: string;
  emotion: string;
  pace: string;
  transcript: string;
  voiceOption: string;
};

generateSpeech メソッドは factaudioTags を使って AudioPrompt のインスタンスを構築します。modestream の場合、SpeechService は generateAudioBlobURL を呼び出して audioPrompt を使用し、blob URL を構築します。modesync の場合、SpeechService は generateAudio を呼び出して audioPrompt を使用し、エンコードされた base64 文字列を生成します。modeweb_audio_api の場合、AudioPlayerService は playStream を呼び出してオーディオをストリーミングします。

import { SpeechService } from '@/ai/services/speech.service';
import { AudioPrompt } from '@/ai/types/audio-prompt.type';
import { ChangeDetectionStrategy, Component, inject, input, OnDestroy, signal } from '@angular/core';
import { revokeBlobURL } from '../blob.util';
import { AudioTagsComponent } from './audio-tags/audio-tags.component';
import { ModeWithAudioTags } from './audio-tags/types/mode-audio-tags.type';
import { generateSpeechHelper, streamSpeechWithWebAudio,  } from './generate-audio.util';
import { AudioPlayerService } from './services/audio-player.service';

@Component({
  selector: 'app-obscure-fact',
  templateUrl: './obscure-fact.component.html',
  imports: [
    TextToSpeechComponent,
  ],
  changeDetection: ChangeDetectionStrategy<