iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

Angular v22 穩定版的 Signal 表單(Signal Form)被用來建置一個音訊標籤表單,供使用者輸入場景描述、情感、說話速度,並選擇語音名稱。接著,當點擊其中一個 Play Speech 按鈕時,表單資料與冷知識會被提交給 TextToSpeechService 以產生語音。

在此之前,ObscureFactComponent 是一個用於顯示冷知識的展示型元件(presentational component)。它將轉變為一個由音訊標籤元件與文字轉語音元件所組成的容器型元件(container component)。

冷知識元件 (Obscure Fact Component)

<div class="obscure-fact-container">
  <app-audio-tags #audioTags />
  <h3 class="obscure-fact-title">A surprising or obscure fact about the tags</h3>
  @if (interestingFact()) {
    <p class="obscure-fact-text">{{ interestingFact() }}</p>
    @let voice = audioTags.audioPromptModel().voiceOption;
    <app-text-to-speech
      [voice]="voice"
      [interestingFact]="interestingFact()"
      [audioPrompt]="audioPrompt()"
    />
  }
</div>

HTML 範本中新增了兩個元件,分別是 <app-audio-tags /> 與 <app-text-to-speech />。

export function buildAudioPrompt(data: AudioPrompt): string {
  return `## Scene:
${data.scene}

## Transcript:
"""
[${data.emotion}][${data.pace}]${data.transcript}
"""
`;
}

@Component({
  selector: 'app-obscure-fact',
  templateUrl: './obscure-fact.component.html',
  styleUrl: './obscure-fact.component.css',
  imports: [TextToSpeechComponent, AudioTagsComponent],
})
export class ObscureFactComponent {
  interestingFact = input<string | undefined>(undefined);

  audioTags = viewChild.required(AudioTagsComponent);

  audioPrompt = computed(() =>
    buildAudioPrompt({
      ...this.audioTags().audioPromptModel(),
      transcript: this.interestingFact() || '',
    }),
  );
}

ObscureFactComponent 使用 viewChild 函式來存取 AudioTagsComponent 的表單模型。接著,表單模型與冷知識會被用來衍生出 audioPrompt 計算訊號(computed signal)。

audioPrompt 計算訊號會將場景、情感、速度和冷知識串接成一個字串。接著,該值會作為 TextToSpeechComponent 的輸入以產生語音。

讓我們來探索 AudioTagsComponent 及其訊號表單。

音訊標籤元件及其訊號表單 (Audio Tags Component and its Signal Form)

<div>
  <div>
    <!-- Scene -->
    <div class="form-field-group-full">
      <label for="scene">Scene Description</label>
      <textarea id="scene"
        [formField]="audioPromptForm.scene"
      ></textarea>
    </div>

    <!-- Emotion -->
    <div class="form-field-group">
      <label for="emotion">Vocal Emotion</label>
      <input type="text" id="emotion"
        [formField]="audioPromptForm.emotion"
      />
    </div>

    <!-- Pace -->
    <div class="form-field-group">
      <label for="pace">Speaking Pace</label>
      <input type="text" id="pace"
        [formField]="audioPromptForm.pace"
      />
    </div>

    <!-- Voice Option -->
    <app-voice-selector [selectedValue]="selectedValue()" (selectedValueChange)="onValueChange($event)" />
  </div>
</div>

formField 指令會將表單模型的屬性對應到 HTML 輸入欄位。例如,scene 屬性會對應到一個 TextArea。

export interface AudioPromptData {
  scene: string;
  emotion: string;
  pace: string;
  voiceOption: string;
}

@Component({
  selector: 'app-audio-tags',
  imports: [FormField, VoiceSelectorComponent],
  templateUrl: './audio-tags.component.html',
  styleUrl: './audio-tags.component.css',
})
export class AudioTagsComponent {
  #audioPromptModel = signal<AudioPromptData>({
    scene: 'A news anchor reading the news in a busy newsroom',
    emotion: 'professional, slightly serious',
    pace: 'moderate, clear enunciation',
    voiceOption: DEFAULT_VOICE,
  });

  audioPromptModel = this.#audioPromptModel.asReadonly();

  audioPromptForm = form(this.#audioPromptModel);

  selectedValue = computed(() => this.#audioPromptModel().voiceOption);

  onValueChange(newValues: string) {
    const voiceOption = newValues ?? DEFAULT_VOICE;
    this.#audioPromptModel.update((model) => ({
      ...model,
      voiceOption,
    }));
  }
}

audioPromptForm 是一個訊號表單,而 #audioPromptModel 則是底層的表單模型。我們需要匯入 FormField,以便在 HTML 範本中使用 formField 指令。onValueChange 方法會處理 VoiceSelectorComponent 的 selectedValueChange 事件,並覆寫表單模型的 voiceOption。

文字轉語音元件 (Text To Speech Component)

<div>
  <div class="btn-container">
    <button (click)="generateSpeech('sync')" [disabled]="isLoading()">
      Play Speech (Sync)'
    </button>
    <button (click)="generateSpeech('stream')" [disabled]="isLoading()">
      Play Speech (Stream)
    </button>
    <button (click)="generateSpeech('web_audio_api')" [disabled]="isLoading()">
      Web Audio API
    </button>
  </div>
</div>

@if (audioUrl(); as url) {
  <figure class="playback-figure">
    <figcaption class="playback-caption">Listen to the Gemini-TTS:</figcaption>
    <audio controls [src]="url" [playbackRate]="'1.25'" class="playback-audio"></audio>
  </figure>
}

範本中有三個按鈕,可叫用 generateSpeech 方法來產生語音。當 audioUrl 儲存 WAV 資料時,系統會顯示帶有音訊播放器控制項的 HTML Audio 元素。使用者可以點擊播放按鈕,依其需要重複播放語音任意次數。

@Component({
  selector: 'app-text-to-speech',
  templateUrl: './text-to-speech.component.html',
  styleUrl: './text-to-speech.component.css',
  providers: [TextToSpeechViewService],
})
export class TextToSpeechComponent {
  private readonly speechService = inject(TextToSpeechViewService);

  interestingFact = input<string | undefined>(undefined);
  audioPrompt = input.required<string>();
  voice = input.required<string>();

  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,
    });
  }
}

TextToSpeechViewService 提供了一個 generateSpeech 方法,該方法會呼叫 Firebase AI Logic,以使用 Gemini TTS 模型為給定的冷知識、語音名稱和提示詞產生語音。

TextToSpeechViewService 的定義 (Definition of TextToSpeechViewService)

@Injectable()
export class TextToSpeechViewService {
  
  /* ...other signals, and methods ...*/

  async generateSpeech(mode: GenerateSpeechMode, promptArgs: FactConfig) {
    if (!promptArgs.fact || this.#loadingMode() !== 'idle') {
      return;
    }

    // 1. Clean up previous URL immediately before starting
    revokeBlobURL(this.#audioUrl());
    this.#audioUrl.set(undefined);

    this.#loadingMode.set(mode);
    switch (mode) {
      case 'sync':
        await this.handleSync(promptArgs);
        break;
      case 'stream':
      case 'web_audio_api':
        await this.handleStream({ ...promptArgs, shouldWait: mode === 'stream' });
        break;
      default:
        throw new Error(`Unsupported mode: ${mode}`);
    }
  }
}

generateSpeech 會根據不同的模式叫用 handleSync 和 handleStream。在 sync(同步)模式下,使用者介面會渲染 HTML Audio 元素。在 stream(串流)模式下,語音會開始播放,接著渲染 HTML Audio 元素。在 web_audio_api 模式下,語音會開始播放,且不會顯示 HTML Audio 元素。

我們已經建置了一個用於輸入音訊配置並選擇語音名稱的 Signal 表單。Firebase AI Logic 和 Gemini TTS 模型使用這些輸入來產生語音,並將 WAV 資料傳回使用者介面以供播放。

我們擁有一個可以進行圖片分析和語音產生的 Angular 應用程式。明天,我們將使用 improve-codebase-design 技能來掃描整個存放庫,並找出可改進的區域。

資源 (Resources)

Tailwind CSS 官方文件
Material Icons Outlined 字型


上一篇
Day 20 - 建立圖片轉替代文字檢視
下一篇
Day 22 - 掃描程式碼庫以尋找深化契機並以技巧深入探討
系列文
2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言