Firebase AI Logic 在八月新增了文字轉語音(Text-to-Speech)功能,這簡化了在我的 Angular 應用程式中從文字生成語音的架構。
當 Firebase AI Logic 還不支援 TTS 時,我實作了一個因應方案:建立一個 Firebase Cloud Function 來呼叫 Gemini TTS API。舊的示範版本包含兩個部分:Angular 用戶端與無伺服器(Serverless)函式。這也意味著我必須同時部署前端與後端。
Firebase AI Logic 的 TTS 功能幫助我省去了 Firebase Cloud Function。當我將 Angular 應用程式部署到 Firebase App Hosting 時,它同時提供了圖片轉文字(Image-to-text)與文字轉語音(Text-to-speech)任務。
讓我來演示在 Angular 服務(Service)中實作 Firebase AI Logic TTS API 有多麼簡單。
請記得安裝 firebase 相依套件。
npm install --save-exact firebase
使用者可以從表單中選擇語音名稱(Voice name),並用它來生成語音。因此,每次進行文字轉語音請求時,都會建立用於 TTS 的生成式 AI 模型(Generative AI model)。
我們注入 AI_BACKEND 注入權杖(Injection token)以取得 Firebase AI 後端,並將該後端傳遞給 getGenerativeModel 函式,以建立用於 TTS 的生成式 AI 模型。
const AI_BACKEND = new InjectionToken('AI_BACKEND');
#aiBackend = inject(AI_BACKEND);
#configService = inject(ConfigService);
#modelName = this.#configService.appConfig.geminiTTSModelName;
該應用程式使用 gemini-3.1-flash-tts-preview 模型,其值是從 Firebase Remote Config 取得。
private createModel(voiceName: string) {
return getGenerativeModel(this.#aiBackend, {
model: this.#modelName,
generationConfig: {
responseModalities: [ResponseModality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName },
},
languageCode: 'en-US',
},
},
});
}
TTS 模型的 responsibilities 必須為 ResponseModality.AUDIO,否則會拋出錯誤。speechConfig 指定了語音名稱和語言代碼。由於產生的文字為英文,因此發音語言被寫死為英文。en-US 代碼可協助 TTS 模型產生正確的語音回應。
我的應用程式有兩個文字轉語音的使用案例。第一個使用案例會下載完整的音訊,將其轉換為 WAV 格式,並將結果指定給 HTML audio 元素的來源。之所以需要進行 WAV 轉換,是因為 Firebase AI Logic 回傳的音訊格式為 l16,而 HTML audio 元素並不支援此格式。
第二個使用案例是串流播放(Stream)音訊,並在每個區塊(Chunk)到達時立即播放,無需進行緩衝。第二個使用案例具有更好的使用者體驗,因為使用者可以比第一個案例更早聽到語音。
下一節將展示第一個使用案例所需的關鍵 WAV 轉換公用函式。
export function parseMimeType(mimeType: string): ParsedMimeType {
const parts = mimeType.split(';');
const baseType = parts[0]?.trim() || 'audio/l16';
let sampleRate = 24000;
let numChannels = 1;
/* the parseBitPerSample returns the bits per sample or default to 16 */
const bitsPerSample = parseBitsPerSample(baseType);
for (const part of parts.slice(1)) {
const [key, value] = part.split('=').map((s) => s.trim());
if (key === 'rate') {
sampleRate = parseInt(value, 10) || sampleRate;
} else if (key === 'channels') {
numChannels = parseInt(value, 10) || numChannels;
}
}
return { baseType, sampleRate, numChannels, bitsPerSample };
}
export function convertToWav(rawData: Uint8Array, mimeType: string): Blob {
const options = parseMimeType(mimeType);
/* the createWavHeader returns a Wav header, treat it as a black box */
const wavHeader = createWavHeader(rawData.length, options);
return new Blob([wavHeader, rawData], { type: 'audio/wav' });
}
parseMimeType 函式會解析 MIME 類型,以回傳音訊 MIME 類型、取樣率(Sample rate)、聲道數(Number of channels)以及每個取樣的位元數(Bits per sample)。
convertToWav 函式會將原始二進位資料(Raw binary data)轉換為 HTML audio 元素支援的 Blob。
實作完公用函式後,我們就可以開始處理第一個使用案例。
@Service()
export class TextToSpeechService {
readonly #aiBackend = inject(AI_BACKEND);
readonly #configService = inject(ConfigService);
readonly #modelName = this.#configService.appConfig.geminiTTSModelName;
private extractValidChunkData(chunk: GenerateContentResponse): SpeechChunkData | null {
const { data, mimeType } = extractInlineData(chunk);
if (!data || !mimeType) {
return null;
}
return { data, mimeType };
}
private createModel(voiceName: string) { ... construct the Generative model ... }
async synthesize({ text, voice }: TextVoiceInput): Promise<Blob> {
const model = this.createModel(voice);
const result = await model.generateContent([text]);
const chunk = this.extractValidChunkData(result.response);
const { data, mimeType } = chunk;
/* the decodeBase64 method converts the base64 data to the raw binary data */
return convertToWav(decodeBase64(data), mimeType);
}
}
在 同步(Synchronous) 方法中,await model.generateContent([text]) 會回傳包含完整 l16 音訊的回應。
它會從區塊中擷取資料與 MIME 類型,並將這些值傳遞給 convertToWav 以回傳完整的 WAV Blob。
function revokeBlobURL(blobUrl: string | undefined) {
if (blobUrl && isValidBlobUrl(blobUrl)) {
console.log('Revoking blob URL');
URL.revokeObjectURL(blobUrl);
}
}
@Injectable()
export class TextToSpeechViewService {
speechService = inject(TextToSpeechService);
#destroyRef$ = inject(DestroyRef);
#audioUrl = signal<string | undefined>(undefined);
audioUrl = this.#audioUrl.asReadonly();
constructor() {
this.#destroyRef$.onDestroy(() => revokeBlobURL(this.#audioUrl()));
}
async generateSpeech(mode: GenerateSpeechMode, promptArgs: FactConfig) {
if (!promptArgs.fact || this.#loadingMode() !== 'idle') {
return;
}
revokeBlobURL(this.#audioUrl());
this.#audioUrl.set(undefined);
switch (mode) {
case 'sync':
const blob = await this.speechService.synthesize({ text: promptArgs.prompt, voice: promptArgs.voice });
this.#audioUrl.set(URL.createObjectURL(blob));
break;
... other modes ...
}
}
}
TextToSpeechViewService 封裝了文字轉語音生成和音訊播放的邏輯。它在元件層級(Component level)提供,以避免元件銷毀時發生記憶體流失(Memory leak)。當元件被銷毀時,建構子中的 #destroyRef$ 會在 onDestroy 的回呼中銷毀已生成的音訊資料。它具有 generateSpeech 方法,可根據模式、文字和語音名稱來生成語音。當模式為 sync 時,它會使用 TextToSpeech 服務來回傳 WAV 資料。
Angular 元件會將 Blob 指定給 HTML Audio 元素的來源,使用者即可點擊播放按鈕來聆聽語音。
這是 TextToSpeechComponent 的程式碼片段,使用者點擊按鈕生成語音,並在音訊資料可用時顯示 HTML Audio 元素。
<button (click)="generateSpeech('sync')" [disabled]="isLoading()">
Play Speech (Sync)
</button>
@if (audioUrl(); as url) {
<figure>
<figcaption>Listen to the Gemini-TTS:</figcaption>
<audio controls [src]="url" [playbackRate]="'1.25'"></audio>
</figure>
}
@Component({
... template, css, and selector ...
providers: [TextToSpeechViewService],
})
export class TextToSpeechComponent {
private readonly speechService = inject(TextToSpeechViewService);
interestingFact = input<string | undefined>(undefined);
audioUrl = this.speechService.audioUrl;
async generateSpeech(mode: GenerateSpeechMode) {
const fact = this.interestingFact();
if (!fact) {
return;
}
await this.speechService.generateSpeech(mode, {
prompt: this.audioPrompt(),
voice: this.voice(),
fact,
});
}
}
}
當 this.speechService.generateSpeech 完成時,元件的唯讀 audioUrl 訊號(Signal)將會更新。樣板會驗證該訊號是否包含資料,並顯示音訊元素。
async *synthesizeStream(textVoiceInput: TextVoiceInput) {
const { text, voice } = textVoiceInput;
let chunks: Uint8Array = new Uint8Array(0);
let firstMimeType = '';
let sampleRate = DEFAULT_SAMPLE_RATE;
const model = this.createModel(voice);
const responseStream = await model.generateContentStream([text]);
for await (const chunk of responseStream.stream) {
const chunkData = this.extractValidChunkData(chunk);
if (chunkData) {
const { data, mimeType } = chunkData;
const decodedData = decodeBase64(data);
if (!firstMimeType && mimeType) {
firstMimeType = mimeType;
sampleRate = parseMimeType(firstMimeType).sampleRate;
}
yield { decodedData, sampleRate };
}
}
yield undefined;
}
synthesizeStream 是一個產生器(Generator),它會產生解碼後的 base64 資料與取樣率。當所有區塊都處理完畢後,產生器會產生 undefined 並結束。
await model.generateContentStream([text]); 會串流音訊並以區塊(Chunk)形式傳送。在 for await 迴圈中,從每個區塊中擷取資料與 MIME 類型,將其解碼為 base64 格式並確定取樣率。接著,它們會被傳回給 TextToSpeechView 進行播放。
export interface FactConfig {
fact: string;
prompt: string;
voice: string;
shouldWait?: boolean;
}
@Injectable()
export class TextToSpeechViewService {
speechService = inject(TextToSpeechService);
#destroyRef$ = inject(DestroyRef);
constructor() {
this.#destroyRef$.onDestroy(() => revokeBlobURL(this.#audioUrl()));
}
private async processStreamChunk(isInitialized: boolean, playbackRate: number, chunk: RawAudioBinary) {
const audioPlayerService = await this.#asyncAudioPlayerService();
if (!isInitialized) {
audioPlayerService.initialize(chunk.sampleRate, playbackRate);
isInitialized = true;
}
audioPlayerService.processChunk(chunk.decodedData);
return isInitialized;
}
private async handleStream(promptArgs: FactConfig) {
let isInitialized = false;
const { prompt, voice } = promptArgs;
for await (const chunk of this.speechService.synthesizeStream({ text: prompt, voice })) {
if (chunk) {
isInitialized = await this.processStreamChunk(isInitialized, 1, chunk);
}
}
}
async generateSpeech(mode: GenerateSpeechMode, promptArgs: FactConfig) {
if (!promptArgs.fact || this.#loadingMode() !== 'idle') {
return;
}
revokeBlobURL(this.#audioUrl());
this.#audioUrl.set(undefined);
switch (mode) {
case 'web_audio_api':
await this.handleStream(promptArgs);
break;
... other modes ...
}
}
}
請將 audioPlayerService 視為一個負責播放區塊的黑盒子服務,我們將在明天介紹此服務的實作。
當模式為 web_audio_api 時,handleStream 會叫用 synthesizeStream 以接收區塊,並將其交給音訊播放器以立即播放語音。
<button (click)="generateSpeech('web_audio_api')" [disabled]="isLoading()">
Web Audio API
</button>
在 TextToSpeechComponent 中,一旦點擊按鈕就會立即播放語音。不需要 HTML Audio 元素,使用者也不需要進行額外操作。
讓我們先在這裡暫停。明天,我們將定義音訊播放器服務(Audio Player Service),該服務利用 Web Audio API 來以程式化方式播放語音等音訊。