Firebase AI Logic 的文字轉語音(Text-to-Speech)功能新增了對串流播放的支援,並將二進位資料以區塊(Chunk)形式回傳。然而,我們需要一種以程式化方式在 Angular 應用程式中播放這些區塊的方法。
全球資訊網協會(W3C)維護著主流瀏覽器廠商皆必須支援的 Web Audio API。因此,我們將在 Angular 應用程式中使用此 API 來處理即時音訊播放。
const AUDIO_NORMALIZATION_BASE = 32768.0;
const MIN_SAFETY_CLAMP = -1.0;
const MAX_SAFETY_CLAMP = 1.0;
const DEFAULT_GAIN = 1.0;
/**
* Normalizes raw 16-bit linear PCM byte buffers (Uint8Array) into Float32Array samples
* scaled between -1.0 and 1.0, ensuring safe even-byte boundaries.
*/
export function normalizePcmSamples(rawBytes: Uint8Array, gain = DEFAULT_GAIN): Float32Array<ArrayBuffer> {
const byteLength = rawBytes.byteLength % 2 === 0 ? rawBytes.byteLength : rawBytes.byteLength - 1;
const int16Data = new Int16Array(rawBytes.buffer, rawBytes.byteOffset, byteLength / 2);
const float32Data = new Float32Array(int16Data.length) as Float32Array<ArrayBuffer>;
for (let i = 0; i < int16Data.length; i = i + 1) {
const normalized = (int16Data[i] / AUDIO_NORMALIZATION_BASE) * gain;
float32Data[i] = Math.max(MIN_SAFETY_CLAMP, Math.min(MAX_SAFETY_CLAMP, normalized));
}
return float32Data;
}
音訊的 MIME 類型為 audio/l16,而 normalizePcmSamples 函式會將資料規格化為介於 -1.0 到 1.0 之間的 Float32 陣列。
import { normalizePcmSamples } from '@/core/utils/pcm.util';
import { DestroyRef, inject, Service, signal } from '@angular/core';
import { EmptyError, interval, lastValueFrom, map, takeWhile } from 'rxjs';
@Service()
export class AudioPlayerService {
#audioCtx: AudioContext | undefined = undefined;
#nextStartTime = 0;
#activeSources: AudioBufferSourceNode[] = [];
#playbackRate = signal(24000);
playbackRate = this.#playbackRate.asReadonly();
stopAll(): void {
this.#activeSources.forEach((s) => {
try {
s.stop();
s.disconnect();
} catch {
// Safe swallow for nodes already stopped
}
});
this.#activeSources = [];
this.#nextStartTime = 0;
if (this.#audioCtx) {
try {
this.#audioCtx.close();
} catch {
// Safe swallow
}
this.#audioCtx = undefined;
}
}
constructor() {
this.#destroyRef$.onDestroy(() => this.stopAll());
}
}
#audioCtx 為 AudioContext,允許開發人員建構要處理的音訊節點圖(Graph of audio nodes)。每個節點都包含可以以特定播放速度播放的音訊資料。
#activeSources 是儲存記憶體內音訊資料的音訊緩衝區來源節點(Audio buffer source nodes)列表。它們以先進先出(FIFO)的順序播放,以便我們能從頭到尾完整聆聽語音。
stopAll 方法透過停止播放所有節點並將其從音訊上下文圖中移除來進行清理。被移除的節點會釋放資源並防止記憶體流失(Memory leak)。因此,它會在建構子中 destroyRef$ 的 onDestroy 回呼中被叫用。在現代 Angular 架構中,我使用 DestroyRef 來進行清理,而不是在 OnDestroy 生命週期掛鉤(Lifecycle hook)方法中進行。
initialize(sampleRate = 24000, playbackRate = 1): void {
this.stopAll();
this.#audioCtx = new AudioContext({ sampleRate });
this.#nextStartTime = this.#audioCtx.currentTime;
this.#playbackRate.set(playbackRate);
}
當使用者點擊播放按鈕以播放語音時,將會呼叫 initialize 方法。該方法會停止上一個音訊、重建音訊上下文,並設定下一次開始時間與播放速率。
processChunk(rawBytes: Uint8Array): void {
if (!this.#audioCtx) {
return;
}
const float32Samples = normalizePcmSamples(rawBytes);
if (float32Samples.length === 0) {
return;
}
const buffer = this.#audioCtx.createBuffer(1, float32Samples.length, this.#audioCtx.sampleRate);
buffer.copyToChannel(float32Samples as unknown as Float32Array<ArrayBuffer>, 0);
const sourceNode = this.#audioCtx.createBufferSource();
sourceNode.buffer = buffer;
sourceNode.playbackRate.value = this.#playbackRate();
sourceNode.connect(this.#audioCtx.destination);
this.#activeSources.push(sourceNode);
const playTime = Math.max(this.#nextStartTime, this.#audioCtx.currentTime);
sourceNode.start(playTime);
const duration = buffer.duration / this.#playbackRate();
this.#nextStartTime = playTime + duration;
sourceNode.onended = () => this.#activeSources = this.#activeSources.filter((s) => s !== sourceNode);
}
processChunk 會為區塊建立一個新的來源節點,並將其連接到音訊上下文圖。此外,該節點會被附加到 activeSources 中以追蹤作用中的節點。
當節點播放完畢後,它會變為非作用中,並從 #activeSources 列表中移除。
readonly #playbackCheck$ = interval(PLAYBACK_POLL_INTERVAL).pipe(
map(() => (this.#audioCtx ? this.#nextStartTime - this.#audioCtx.currentTime : 0)),
takeWhile((remainingTime) => remainingTime > 0),
);
async awaitPlaybackComplete(): Promise<void> {
if (!this.#audioCtx) {
return;
}
try {
await lastValueFrom(this.#playbackCheck$);
} catch (e) {
if (e instanceof EmptyError) {
return;
}
throw e;
}
}
#playbackCheck$ 是一個 Observable,用於輪詢所有音訊節點是否已播放完畢。
awaitPlaybackComplete 方法使用 lastValueFrom RxJS 運算子(Operator)來等待最後一個音訊節點結束並回傳 Promise。這對於通知 UI 語音已播放完畢,以便進行下一步操作非常有用。
private async consumeStream(stream: AsyncGenerator<RawAudioBinary | Blob | undefined>) {
let finalBlob: Blob | undefined = undefined;
let isInitialized = false;
for await (const chunk of stream) {
if (chunk instanceof Blob) {
finalBlob = chunk;
} else if (chunk) {
isInitialized = await this.processStreamChunk(isInitialized, this.#playbackRate(), chunk);
}
}
return finalBlob;
}
private async handleStream(promptArgs: FactConfig) {
let createdUrl: string | undefined = undefined;
try {
const { prompt, voice, shouldWait = false } = promptArgs;
this.#playbackRate.set(1);
const stream = this.speechService.synthesizeStream({ text: prompt, voice: voice, shouldWait });
const finalBlob = await this.consumeStream(stream);
if (shouldWait && !abortSignal.aborted) {
await this.audioPlayerService.awaitPlaybackComplete();
createdUrl = this.setAudioUrl(finalBlob);
}
} catch (e) {
if (!abortSignal.aborted) {
this.handlePlaybackError(e, createdUrl);
throw e;
}
}
}
在 Angular 應用程式中,可以增強 TextToSpeechView 服務以等待 awaitPlaybackComplete 並更新 #audioUrl 訊號(Signal)。接著,UI 元件即可顯示 HTML Audio 元素,以允許使用者重新播放語音。
讓我們先在這裡暫停。明天,我們將實作供使用者上傳圖片的元件,並叫用 VisionService 進行分析以生成文字回應。
Firebase AI Logic 官方文件
Web Audio API 官方文件