返回博客首页
← 所有文章

NativeScript 预览 - 文字转语音 🗣️

2022年9月28日 — 作者:技术指导委员会 (TSC)

专业软件开发人员的一个共同特征是,他们能够避免将工作的实现细节“神化”。

很容易沉迷于某个特定的框架或技术,而忘记我们编程的最初目的。我们编程并非仅仅因为 React、Angular、Vue、Svelte、Ionic、NativeScript、TypeScript 等存在。我们编程是因为这些技术赋予我们能力。我们编程是因为它是将某种东西交付到他人手中,解决他们遇到的实际问题,让他们的生活更轻松,甚至可能为他们的世界带来快乐的最佳选择。

NativeScript 预览可以显著减少将想法转化为有价值的功能所需的时间和认知投入。

让我们探索一个有趣且具有非凡意义的文字转语音示例。

演示

要了解 NativeScript 预览的工作原理,您可以点击此处了解更多信息。

简而言之,您可以安装 NativeScript 预览应用,扫描 StackBlitz 上的二维码,将应用推送到您的设备。在您开发过程中,您的更改将实时推送到您的手机。非常酷!

我们想要演示的示例在下面的链接中,让我们开始吧!

演示

NativeScript 组件

首先,我们将 speech 组件分割到其自己的目录中,并遵循 NativeScript 社区中常用的模式。NativeScript 编译器将生成特定于平台的 JavaScript 包,因此您可以在技术上将 iOS 和 Android 代码混合在一个文件中,知道非目标平台代码将被为您删除。但是,更清晰的方法是完全分离 iOS 和 Android 代码,并针对一个类型定义进行编程,该类型定义确定该组件的 API,而无论平台如何。

您可以在下图中看到典型文件结构的样子。

您会注意到目录中还有一个 common.ts 文件,我们用它来抽象通用元素,例如枚举和接口。例如,在下面的代码中,我们定义了一个 Languages 枚举来控制我们可以使用的语言,以及一个 SpeakOptions 接口来确保类型安全。

//app/speech/common.ts
export enum Languages {
  American = 'en-US',
  British = 'en-GB',
  Spanish = 'es-ES',
  Australian = 'en-AU',
}

export interface SpeakOptions {
  text: string;
  language?: string;
  finishedCallback?: Function;
}

在我们的 index.d.ts 文件中,我们将定义 TextToSpeech 类的形状,我们将以此来指导每个平台的实现。最后,我们将定义五个基本函数,这些函数将在 index.ios.tsindex.android.ts 文件中实现。

//app/speech/index.d.ts
export * from './common';

export declare class TextToSpeech {
  speak(options: SpeakOptions): void;

  stop(): void;

  destroy(): void;
}

iOS

在示例中,我们有一个 iOS 和 Android 实现,但为了避免冗余,我们将只关注 iOS 实现。描述的相同原则适用于 NativeScript 支持的所有平台。

原生组件

我们将从 TextToSpeech 类的简化版本开始我们的讲解,以便您在逐个浏览每个函数并讨论其工作原理之前,先了解高级部分。需要注意的最重要的一点是,此类实现了我们之前在类型定义中定义的五个方法。

//app/speech/index.ios.ts
import { SpeakOptions } from './common';
export * from './common';

export class TextToSpeech {
  utterance: AVSpeechUtterance;
  speech: AVSpeechSynthesizer;
  delegate: MySpeechDelegate;
  options: SpeakOptions;

  speak(options: SpeakOptions) { }

  stop() { }

  destroy() { }

  private init() { }
}

utterancespeech 实例用于进行语音合成,而 delegateoptions 成员用于与外部世界通信。

我们稍后将详细介绍 delegate 的实现。如果您不熟悉此概念,它是在 iOS 开发中使用的一种常见模式,用于使用委托来处理具体实例的特定任务,例如事件分派。您可以点击此处了解更多信息

utterance: AVSpeechUtterance;
speech: AVSpeechSynthesizer;
delegate: MySpeechDelegate;
options: SpeakOptions;

核心方法

speak 方法非常简单明了。我们初始化一些内容,然后调用 this.speech.speakUtterance 并传入一个 utterance 实例。但是……这里到底发生了什么?

speak(options: SpeakOptions) {
  this.init();
  this.options = options;
  this.utterance = AVSpeechUtterance.alloc().initWithString(options.text);
  this.utterance.voice = AVSpeechSynthesisVoice.voiceWithLanguage(options.language);

  this.speech.speakUtterance(this.utterance);
}

我们正在检测我们是否拥有 speech 的实例,这将引导我们走入两条路径之一。如果我们有实例,则无需重新实例化语音合成器;相反,我们只需要停止可能正在进行的任何语音合成。如果我们没有实例,我们将实例化 AVSpeechSynthesizer 的一个实例并将其分配给 speech。我们还将 MySpeechDelegate 的一个实例分配给 delegate 并将委托连接到语音实例。

private init() {
  if (!this.speech) {
    this.speech = AVSpeechSynthesizer.alloc().init();
    this.delegate = MySpeechDelegate.initWithOwner(new WeakRef(this));
    this.speech.delegate = this.delegate;
  } else {
    this.stop();
  }
}

stop 方法检测语音合成是否正在进行,如果是,则指示 AVSpeechSynthesizer 实例立即停止语音合成。

stop() {
  if (this.speech?.speaking) {
    this.speech.stopSpeakingAtBoundary(AVSpeechBoundary.Immediate);
  }
}

通过将 speech 设置为 null,我们确保 AVSpeechSynthesizer 实例被清理。

destroy() {
  this.speech = null;
}

委托

要了解有关 iOS 委托的更多信息,此资源是一个不错的选择

简而言之,“委托是一种设计模式,它使类或结构能够将其某些职责委托给另一种类型的实例。”

委托类可以根据您尝试实现的目标采用多种形式,并由您实现的接口定义。在我们的例子中,我们正在实现 AVSpeechSynthesizerDelegate 接口,这要求我们实现下面看到的这些方法。NativeScript 通过 static ObjCProtocols 属性进行实际连接,该属性可以接收任意数量的 iOS 协议的集合。

让我们花点时间讨论一下这个委托是如何被实例化的。

@NativeClass
class MySpeechDelegate extends NSObject implements AVSpeechSynthesizerDelegate {
  owner: WeakRef<TextToSpeech>;

  static ObjCProtocols = [AVSpeechSynthesizerDelegate];

  static initWithOwner(owner: WeakRef<TextToSpeech>) {
    const delegate = <MySpeechDelegate>MySpeechDelegate.new();
    delegate.owner = owner;
    return delegate;
  }

  speechSynthesizerDidStartSpeechUtterance() {}

  speechSynthesizerDidFinishSpeechUtterance() {}

  speechSynthesizerDidPauseSpeechUtterance() {}

  speechSynthesizerDidContinueSpeechUtterance() {}

  speechSynthesizerDidCancelSpeechUtterance() {}
}

static initWithOwner 模式在 iOS 开发中也很常见,因为它大量使用了委托设计模式。

我们正在定义对 TextToSpeech 实例的 WeakRef 引用,并将其分配给委托上的 owner 属性。通过使用 WeakRef,我们可以持有对另一个对象的弱引用,而不会阻止该对象被垃圾回收。您可以在 WeakRef MDN 文档 中阅读更多有关此功能的信息。

委托本身是在静态 initWithOwner 方法中创建的,该方法接收一个 owner 参数,在我们调用 MySpeechDelegate.new() 后将其分配给委托实例。这里的一个独特的 NativeScript 结构是 .new() 的用法,它仅仅是 NativeScript 运行时添加到 JavaScript 语言中的一个便利功能,有助于实例化您可能无法调用特定初始化程序的原生类。一旦委托创建并分配了所有者,我们将委托返回给所有者,以供将来委托相关的职责使用。

在我们的委托中,将执行“实际工作”的一个方法是 speechSynthesizerDidFinishSpeechUtterance,它不仅与 SpeechToText 实例交互,还与 Angular 应用通过 SpeakOptions 对象传入的回调方法交互。我们将尝试通过调用 this.owner?.deref(); 获取对所有者的引用,如果我们有实例,我们将调用 stop 和来自语音选项对象的 finishCallBack 方法,该对象由 Angular 应用定义。

speechSynthesizerDidFinishSpeechUtterance(
  synthesizer: AVSpeechSynthesizer,
  utterance: AVSpeechUtterance
) {
  const owner = this.owner?.deref();
  if (owner) {
    owner.stop();
    owner.options.finishedCallback();
  }
}

其他四个方法将简单地记录事件和 utterance.speechString。我们可以根据用户需求稍后填写这些内容。

speechSynthesizerDidStartSpeechUtterance(
  synthesizer: AVSpeechSynthesizer,
  utterance: AVSpeechUtterance
) {
  console.log(
    'speechSynthesizerDidStartSpeechUtterance:',
    utterance.speechString
  );
}

speechSynthesizerDidPauseSpeechUtterance(
  synthesizer: AVSpeechSynthesizer,
  utterance: AVSpeechUtterance
) {
  console.log(
    'speechSynthesizerDidPauseSpeechUtterance:',
    utterance.speechString
  );
}

speechSynthesizerDidContinueSpeechUtterance(
  synthesizer: AVSpeechSynthesizer,
  utterance: AVSpeechUtterance
) {
  console.log(
    'speechSynthesizerDidContinueSpeechUtterance:',
    utterance.speechString
  );
}

speechSynthesizerDidCancelSpeechUtterance(
  synthesizer: AVSpeechSynthesizer,
  utterance: AVSpeechUtterance
) {
  console.log(
    'speechSynthesizerDidCancelSpeechUtterance:',
    utterance.speechString
  );
}

Angular

应用程序的 Angular 部分通过坚持惯例来维持现状。能够使用 JavaScript 与原生平台 API 交互非常棒,同样棒的是能够抽象化这些实现细节,以便 Angular 对它所处的全新世界一无所知。就 Angular 应用程序而言,它只是 Angular。

Angular 组件类

我们要做的一件事是加强 Angular 和硬件之间的连接,使用 NgZone 来确保如果触发了原生事件,我们的 Angular 绑定会更新。

import { Component, OnInit, inject, NgZone } from '@angular/core';
import { Languages, TextToSpeech, SpeakOptions } from './speech';

@Component({
  selector: 'ns-talk',
  templateUrl: './talk.component.html',
})
export class TalkComponent implements OnInit {
  TTS: TextToSpeech;
  ngZone = inject(NgZone);
  speaking = false;
  speechText = 'Thank you for exploring NativeScript with StackBlitz!';

  speakOptions: SpeakOptions = {
    text: 'Whatever you like',
    language: Languages.Australian,
    finishedCallback: () => {
      this.reset();
      console.log('Finished Speaking!');
    },
  };

  ngOnInit() {
    this.TTS = new TextToSpeech();
  }

  talk() { }

  stop() { }

  reset() { }
}

如果您还记得,在上一节中,我们在委托中触发了一个回调函数。您可以看到该函数在 speakOptions 对象上定义,它调用 reset 并记录到控制台。我们还在 ngOnInit 中实例化了一个 TextToSpeech 实例并将其分配给 TTS

talk 方法通过将 speechText 分配给 speakOptions.text 选项并将 speaking 标志设置为 true 来完成一些初步工作。最后,使用更新后的选项调用 TTS.speak

talk() {
  this.speakOptions.text = this.speechText;

  this.speaking = true;

  this.TTS.speak(this.speakOptions);
}

stop 方法调用 TTS 实例上的 stop 方法,并调用 reset

stop() {
  this.TTS.stop();
  this.reset();
}

reset 方法很有趣,因为它使用 NgZone 来管理变更检测。每次调用 reset 时,都会调用 ngZone.run,然后触发一个回调函数,该函数将 speaking 设置为 false

reset() {
  this.ngZone.run(() => {
    // since stop can be called as result of native API interaction
    // we call within ngZone to ensure view bindings update
    this.speaking = false;
  });
}

Angular 组件模板

假设您对 Angular 有基本的了解,那么关于此组件模板就没有太多需要讨论的了。我们有一个 [(ngModel)] 来更新 speechText,该文本用于在点击“说话”按钮时更新我们传递给平台原生 API 的选项。

<ActionBar title="Text to Audible Speech"> </ActionBar>

<StackLayout class="p-30">
  <Label
    text="Be sure to turn your speaker on and volume up."
    class="instruction"
    textWrap="true"
    width="250"
  ></Label>
  <TextView
    [(ngModel)]="speechText"
    hint="Type text to speak..."
    class="input"
    height="100"
  ></TextView>
  <Button
    text="Talk to me!"
    class="btn-primary m-t-20"
    (tap)="talk()"
  ></Button>
  <Button
    text="Stop"
    (tap)="stop()"
    [visibility]="speaking ? 'visible' : 'hidden'"
    class="btn-secondary"
  ></Button>
</StackLayout>

结论

当开发人员体验到从 Stackblitz 到手机的实时更新,并意识到原生 API 的强大功能时,这总是令人兴奋的时刻。我们的目标是让它感觉就像您已经编写的每个 JavaScript 应用程序一样。

希望您喜欢这个文字转语音演示。