使用 Web Speech API 进行多语言翻译

Avatar of Steven Estrella
Steven Estrella

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

从科幻小说的早期开始,我们就幻想过与我们对话的机器。如今这已经司空见惯。即便如此,使网站能够说话的技术仍然非常新颖。

我们可以使用 Web Speech API 中的 SpeechSynthesis 部分使我们的网页说话。这仍然被认为是一项实验性技术,但在最新版本的 Chrome、Safari 和 Firefox 中得到了很好的支持。

对我来说,有趣的部分是将这项技术用于外语。为此,Mac OSX 和大多数 Windows 安装在所有浏览器上都提供了很好的支持。Chrome 远程加载一组语音,因此,如果您的操作系统没有安装国际语音,只需使用 Chrome 即可。我们将逐步介绍一个三步过程,以创建一个能够用多种语言说出相同文本的页面。一些基本代码源自 此处提供的文档,但最终产品添加了一些有趣的功能,您可以在我的 Polyglot CodePen 这里 查看。

已完成的 Polyglot 应用的屏幕截图,其中包含一个语言菜单。

步骤 1:简单开始

让我们创建一个基本的页面,其中包含一个<textarea> 用于我们希望页面朗读的文本,并包含一个按钮,单击该按钮以触发语音。

<div id="wrapper">
  <h1>Simple Text To Speech</h1>
  <p id="warning">Sorry, your browser does not support the Web Speech API.</p>  
  <textarea id="txtFld">I love the sound of my computer-generated voice.</textarea>
  <label for="txtFld">Type text above. Then click the Speak button.</label>
  <div>
    <button type="button" id="speakBtn">Speak</button>
    <br>
    <p>Note: For best results on a Mac, use the latest version of Chrome, Safari, or FireFox. On Windows, use Chrome.</p>
  </div>
</div>

如果 JavaScript 检测到不支持 Web Speech API,则仅显示 ID 为warning 的段落。此外,请注意textareabutton 的 ID 值,因为我们将在 JavaScript 中使用它们。

随意以任何您喜欢的方式设置 HTML 样式。您也可以使用我创建的演示进行操作

查看 Steven Estrella 的 CodePen 上的笔
文本转语音第 1 部分
(@sgestrella)
CodePen 上。

为按钮的禁用状态添加样式规则是一个好主意,可以避免少数仍然使用不兼容浏览器(如现在已经过时的 Internet Explorer)的用户感到困惑。此外,让我们使用样式规则默认隐藏警告,以便我们可以控制何时真正需要它。

button:disabled {
  cursor: not-allowed;
  opacity: 0.3;
}

#warning {
  color: red;
  display: none;
  font-size: 1.4rem;
}

现在开始 JavaScript!首先,我们添加两个变量作为对触发语音的“Speak”按钮和<textarea> 元素的引用。代码底部的事件监听器告诉文档在 DOM 元素加载后等待,然后再调用init() 函数。我使用了一个名为“qs”的便捷实用程序函数,该函数定义在代码底部。它是document.querySelector 的快捷替代方案,它选择我传递给它的任何选择器值并返回对象引用。然后,我们将向speakBtn 对象添加一个事件监听器,以使按钮调用talk() 函数。

talk() 函数创建 Web Speech API 中一部分的SpeechSynthesisUtterance 对象的新实例。它将来自<textarea>(使用 ID txtFld)的文本添加到 text 属性中。然后,将发音传递到 window 对象的speechSynthesis 方法,然后我们听到朗读的文本。您听到的具体语音因浏览器和操作系统而异。例如,在我的 Mac 上,我的默认语言设置为美式英语,英语的默认语音是 Alex。在步骤 2 中,我们将添加代码以创建菜单以帮助用户选择所有可用语言的语音。

let speakBtn, txtFld;

function init() {
  speakBtn = qs("#speakBtn");
  txtFld = qs("#txtFld");
  speakBtn.addEventListener("click", talk, false);
  if (!window.speechSynthesis) {
    speakBtn.disabled = true;
    qs("#warning").style.display = "block";
  }
}

function talk() {
  let u = new SpeechSynthesisUtterance();
  u.text = txtFld.value;
  speechSynthesis.speak(u);
}

// Reusable utility functions
function qs(selectorText) {
  // Saves lots of typing for those who eschew jQuery
  return document.querySelector(selectorText);
}

document.addEventListener('DOMContentLoaded', function (e) {
  try {init();} catch (error) {
    console.log("Data didn't load", error);
  }
});

步骤 2:国际语音菜单

如果我们想使用默认语言和语音以外的任何内容,则必须添加更多代码。所以这就是我们接下来要解决的问题。

我们将添加一个select 元素以保存语音选项菜单

<h1>Multilingual Text To Speech</h1>
<div class="uiunit">
  <label for="speakerMenu">Voice: </label>
  <select id="speakerMenu"></select> speaks <span id="language">English.</span>
  <!-- etc. -->
</div>

在创建用于填充菜单选项的代码之前,我们应该处理有助于我们将语言代码与其对应名称关联的代码。每种语言都由一个两位代码标识,例如英语为“en”,西班牙语为“es”。我们将获取这些代码及其对应语言的简单列表,并创建一个以下形式的对象数组:{"code": "pt", "name": "Portuguese"}。然后,我们需要一个实用程序函数来帮助我们在对象的数组中搜索给定属性的值。我们将在几分钟后使用它来快速查找与所选语音的语言代码匹配的语言名称。复制下面的代码,以便这两个函数正好位于// 通用实用程序函数 注释的上方和下方。

function getLanguageTags() {
  let langs = ["ar-Arabic","cs-Czech","da-Danish","de-German","el-Greek","en-English","eo-Esperanto","es-Spanish","et-Estonian","fi-Finnish","fr-French","he-Hebrew","hi-Hindi","hu-Hungarian","id-Indonesian","it-Italian","ja-Japanese","ko-Korean","la-Latin","lt-Lithuanian","lv-Latvian","nb-Norwegian Bokmal","nl-Dutch","nn-Norwegian Nynorsk","no-Norwegian","pl-Polish","pt-Portuguese","ro-Romanian","ru-Russian","sk-Slovak","sl-Slovenian","sq-Albanian","sr-Serbian","sv-Swedish","th-Thai","tr-Turkish","zh-Chinese"];
  let langobjects = [];
  for (let i=0;i<langs.length;i++) {
    let langparts = langs[i].split("-");
    langobjects.push({"code":langparts[0],"name":langparts[1]});
  }
  return langobjects;
}

// Generic Utility Functions
function searchObjects(array, prop, term, casesensitive = false) {
  // Searches an array of objects for a given term in a given property
  // Returns an array of only those objects that test positive
  let regex = new RegExp(term, casesensitive ? "" : "i");
  let newArrayOfObjects = array.filter(obj => regex.test(obj[prop]));
  return newArrayOfObjects;
}

现在,我们可以使用 JavaScript 为select 元素构建选项。我们需要在 JavaScript 的顶部声明变量以保存对#speakerMenu select 元素、#language span 元素、合成语音数组 (allVoices)、用于识别语言的代码数组 (langtags) 以及用于跟踪当前所选语音 (voiceIndex) 的位置的引用。将它们添加到我们在步骤 1 中创建的两个变量声明之后。

let speakBtn, txtFld, speakerMenu, language, allVoices, langtags;
let voiceIndex = 0;

更新后的init() 函数设置了对#speakerMenu#language span 的一些其他引用,并将所有语言代码放入名为langtags 的对象数组中。代码的功能检测部分也在这里发生了变化。如果支持 Web Speech API,则会调用setUpVoices() 函数。此外,对于 Chrome,我们必须侦听加载的语音的变化,并在需要时重复设置。每次您在 Chrome 的远程语音(在 Chrome 中列出的以 Google 为前缀的语音)与存储在用户操作系统中的所有其他语音之间切换时,Chrome 都会轮询可用的语音。

function init() {
  speakBtn = qs("#speakBtn");
  txtFld = qs("#txtFld"); 
  speakerMenu = qs("#speakerMenu");
  language = qs("#language");
  langtags = getLanguageTags();
  speakBtn.addEventListener("click", talk, false);
  speakerMenu.addEventListener("change", selectSpeaker, false);
  if (window.speechSynthesis) {
    if (speechSynthesis.onvoiceschanged !== undefined) {
      // Chrome gets the voices asynchronously so this is needed
      speechSynthesis.onvoiceschanged = setUpVoices;
    }
    setUpVoices(); // For all the other browsers
  } else{
    speakBtn.disabled = true;
    speakerMenu.disabled = true;
    qs("#warning").style.display = "block";
  }
}

setUpVoices() 函数通过调用speechSynthesis 对象的getVoices() 方法获取称为SpeechSynthesisVoice 对象的数组。这在我们的代码中使用getAllVoices() 函数完成。不幸的是,我发现speechSynthesis.getVoices() 方法有时会在列表中返回重复项,因此我专门用九行代码来消除这些重复项。最后,在getAllVoices() 的末尾,我为每个SpeechSynthesisVoice 对象添加了一个唯一的标识符编号。这将在步骤 3 中帮助我们,当我们需要过滤语音列表以仅显示给定语言的语音时。完成后,allVoices 数组将包含如下所示的对象。每个对象都具有idvoiceURInamelang 属性。localService 属性指示语音的代码是存储在用户的计算机上还是远程存储在 Google 的服务器上。请注意lang 属性。该值由一个两位语言代码(例如,西班牙语为“es”)后跟一个连字符和一个区域代码(例如,墨西哥为“MX”)组成。这标识了每种语音的语言和区域口音。

{id:48, voiceURI:"Paulina", name:"Paulina", lang: "es-MX", localService:true},
{id:52, voiceURI:"Samantha", name:"Samantha", lang: "en-US", localService:true},
{id:72, voiceURI:"Google Deutsch", name:"Google Deutsch", lang: "de-DE", localService:false}

setUpVoices() 的最后一行调用一个函数以创建将在#speakerMenu select 元素中显示的选项列表。每种语音的id 属性的值都放置在选项的value 属性中。namelang 属性是在每个选项中显示的可见文本项,以及那些在某些操作系统和浏览器上被标记为“高级”的语音。

function setUpVoices() {
  allVoices = getAllVoices();
  createSpeakerMenu(allVoices);
}

function getAllVoices() {
  let voicesall = speechSynthesis.getVoices();
  let vuris = [];
  let voices = [];

  voicesall.forEach(function(obj,index) {
    let uri = obj.voiceURI;
    if (!vuris.includes(uri)) {
      vuris.push(uri);
      voices.push(obj);
    }
  });

  voices.forEach(function(obj,index) {obj.id = index;});
  return voices;
}

function createSpeakerMenu(voices) {
  let code = ;

  voices.forEach(function(vobj,i) {
    code += `<option value=${vobj.id}>`;
    code += `${vobj.name} (${vobj.lang})`;
    code += vobj.voiceURI.includes(".premium") ? ' (premium)' : ;
    code += `</option>`;
  });

  speakerMenu.innerHTML = code;
  speakerMenu.selectedIndex = voiceIndex;
}

您可能还记得,在init() 函数中,我们设置了一个事件监听器,以便每当speakerMenu 更改时都调用selectSpeaker()selectSpeaker() 函数存储#speakerMenu select 元素的selectedIndex。接下来,它获取所选项目的 value,该 value 将是一个整数,对应于该语音在allVoices() 数组中的索引。因此,现在我们已经检索到了我们想要的SpeechSynthesisVoice。然后,我们获取lang 属性的前两位字符(例如,“en”、“es”、“ru”、“de”、“fr”),并使用该代码搜索langtags 语言对象数组以查找相应的语言名称。searchObjects() 函数返回一个数组,该数组可能只有一个条目。无论如何,第一个条目 (langcodeobj[0]) 就是我们需要的所有内容。最后,我们将该名称分配给language spaninnerHTML 属性,它会按预期显示在屏幕上。

// Code for when the user selects a speaker
function selectSpeaker() {
  voiceIndex = speakerMenu.selectedIndex;
  let sval = Number(speakerMenu.value);
  let voice = allVoices[sval];
  let langcode = voice.lang.substring(0,2);
  let langcodeobj = searchObjects(langtags, "code", langcode);
  language.innerHTML = langcodeobj[0].name;
}

要使步骤 2 完成,剩下的唯一事情是确保当我们单击“Speak”按钮时,talk() 函数也能正常工作。修改talk() 函数,以便向发音添加属性以控制使用哪种语音和语言以及文本的朗读速度。在我的测试中,0.5 到 2 的速率范围可以可靠地工作。我发现低于 0.5 的速率没有效果。我认为 0.8 对许多语言来说都是一个不错的默认值,但正如我们将在步骤 3 中看到的,有一种简单的方法可以让用户决定。

function talk() {
  let sval = Number(speakerMenu.value);
  let u = new SpeechSynthesisUtterance();
  u.voice = allVoices[sval];
  u.lang = u.voice.lang;
  u.text = txtFld.value;
  u.rate = 0.8;
  speechSynthesis.speak(u);
}

步骤 2 到此结束!以下是我们到目前为止所做工作的成果

查看 Steven Estrella 的 CodePen 上的笔
文本转语音第 2 部分
(@sgestrella)
CodePen 上。

玩一玩。有时输入一个英语短语然后分配一个法语或德语说话者来说出它会很有趣。相反,如果您想听到您最糟糕的一年级西班牙语学生,请输入一个西班牙语短语并将其分配给英语语音朗读。

步骤 3:完整的 Polyglot

我们进入最后冲刺阶段了!此步骤中的一些操作将是对UI进行润色,但也有一些功能性操作需要完成,以确保一切正常。具体来说,我们将:

  • 创建一个可用语言选项菜单
  • 允许用户定义语音速度
  • 在文本区域中定义一个默认短语,该短语在选择语言时进行翻译

以下是我们要做的

我们将添加一个下拉菜单、语音速率设置和一个默认短语。

在 HTML 中,我们将添加一个新的<select>元素用于语言菜单,以及一个数字input(稍后将用于设置语音速率)。请注意,我们已删除了#language span,因为它在语言菜单工作后不再相关。

<div class="uiunit">
  <label for="languageMenu">Language: </label>
  <select id="languageMenu">
    <option selected value="all">Show All</option>
  </select>
</div>

<div class="uiunit">
  <label for="speakerMenu">Voice: </label><select id="speakerMenu"></select>
</div>

<div class="uiunit">
  <label for="rateFld">Speed: </label>
  <input type="number" id="rateFld" min="0.5" max="2" step="0.1" value="0.8" />
</div>

在 JavaScript 中,我们需要修改变量声明。我们将跟踪allLanguages数组中的所有方言,以及primaryLanguages数组中的主要语言。langhashlangcodehash数组将用作哈希表,以便我们可以快速获取语言名称,而我们只知道两位字母的语言代码,反之亦然。我们只需要设置一次语言菜单,因此initialSetup的布尔标志将派上用场。

let speakBtn, txtFld, speakerMenu, allVoices, langtags;
let voiceIndex = 0;
let allLanguages, primaryLanguages, langhash, langcodehash;
let rateFld, languageMenu, blurbs;
let initialSetup = true;
let defaultBlurb = "I enjoy the traditional music of my native country.";

在新init()函数中,让我们删除行language = qs("#language");,然后添加此处所示的新代码,以创建信息摘要、引用rateFld数字inputlanguageMenu select,并创建用于查找语言名称和标签的哈希表。

function init() {
  // ...keep existing content but delete language = qs("#language");
  createBlurbs();
  rateFld = qs("#rateFld");
  languageMenu = qs("#languageMenu"); 
  languageMenu.addEventListener("change", selectLanguage, false);
  langhash = getLookupTable(langtags, "name");
  langcodehash = getLookupTable(langtags, "code");

  if (window.speechSynthesis) {
    // ...keep existing content
  } else{
    // ...keep existing content
    languageMenu.disabled = true;
  }
}

setUpVoices()函数需要一些修改,以适应新的语言菜单并触发filterVoices()函数,我们现在将使用它来填充#speakerMenu元素。此外,我们将添加新的函数:getAllLanguages()getPrimaryLanguages()。第一个函数组装了在allVoices对象数组中找到的lang属性的唯一值的数组。请注意,返回语句使用扩展运算符结合新的Set对象来确保返回的数组没有重复项。getPrimaryLanguages()函数返回一个两位字母的国家代码数组。这将创建一个较小的列表,其中仅包含主要语言,而不参考地区方言。

function setUpVoices() {
  allVoices = getAllVoices();
  allLanguages = getAllLanguages(allVoices);
  primaryLanguages = getPrimaryLanguages(allLanguages);
  filterVoices();
  if (initialSetup && allVoices.length) {
    initialSetup = false;
    createLanguageMenu();
  }
}

function getAllLanguages(voices) {
  let langs = [];
  voices.forEach(vobj => {
    langs.push(vobj.lang.trim());
  });
  return [...new Set(langs)];
}

function  getPrimaryLanguages(langlist) {
  let langs = [];
  langlist.forEach(vobj => {
    langs.push(vobj.substring(0,2));
  });
  return [...new Set(langs)];
}

setUpVoices()函数调用另外两个函数。filterVoices()函数从#languageMenu选择菜单的当前值获取两位字母的语言代码,并使用它来过滤allVoices数组,并仅返回所选语言的可用语音选项。然后,它将该数组传递给createSpeakerMenu()函数(与步骤 2 中的相同),后者使用选项填充#speakerMenu。然后filterVoices()获取与所选语言关联的信息摘要,并将其放置在文本区域中,以便可以对其进行编辑或替换。

并且,如果 Chrome 重建此菜单,则将使用存储的voiceIndex来恢复当前选择。接下来,createLanguageMenu()函数使用我们的哈希表为languageMenu select元素创建所需的菜单选项。每当用户选择语言时,都会触发selectLanguage()函数。然后,它会触发filterVoices()并将#speakerMenu设置为显示第一个可用选项。

function filterVoices() {
  let langcode = languageMenu.value;
  voices = allVoices.filter(function (voice) {
    return langcode === "all" ? true : voice.lang.indexOf(langcode + "-") >= 0;
  });
  createSpeakerMenu(voices);
  let t = blurbs[languageMenu.options[languageMenu.selectedIndex].text];
  txtFld.value = t ? t : defaultBlurb;
  speakerMenu.selectedIndex = voiceIndex;
}

function createLanguageMenu() {
  let code = `<option selected value="all">Show All</option>`;
  let langnames = [];
  primaryLanguages.forEach(function(lobj,i) {
    langnames.push(langcodehash[lobj.substring(0,2)].name);
  });
  langnames.sort();
  langnames.forEach(function(lname,i) {
    let lcode = langhash[lname].code;
    code += `<option value=${lcode}>${lname}</option>`;
  });
  languageMenu.innerHTML = code;
}

function selectLanguage() {
  filterVoices();
  speakerMenu.selectedIndex = 0;
}

在代码底部的实用函数部分,添加以下代码。此通用的小型实用程序将在您下次需要为对象数组创建查找表时为您提供帮助。在我们的例子中,我们将使用它来让我们轻松地将语言代码与其对应的语言名称以及反过来进行匹配。

function getLookupTable(objectsArray, propname) {
  return objectsArray.reduce((accumulator, currentValue) => (accumulator[currentValue[propname]] = currentValue, accumulator),{});
}

我添加了一个文本短语数组,每个短语都是“我热爱我祖国传统的音乐”的翻译。它显示的语言将与语言菜单中选择的语言相对应。

在这里,我们看到了 UTF-8 的强大功能。在getLanguagesTags()函数上方,让我们添加生成所有这些翻译信息摘要的代码。我只会说西班牙语、英语、一些葡萄牙语和很少的德语,所以我必须相信 Google 翻译为其他语言提供了准确的翻译。如果这些语言中有任何一种是您的母语,请随时在评论中进行更正。

function createBlurbs() {
  blurbs = {
    "Arabic" : "أنا أستمتع بالموسيقى التقليدية لبلدي الأم.",
    "Chinese" : "我喜歡我祖國的傳統音樂。",
    "Czech" : "Mám rád tradiční hudbu mé rodné země.",
    "Danish" : "Jeg nyder den traditionelle musik i mit hjemland.",
    "Dutch" : "Ik geniet van de traditionele muziek van mijn geboorteland.",
    "English" : "I enjoy the traditional music of my native country.",
    "Finnish" : "Nautin kotimaassani perinteistä musiikkia.",
    "French" : "J'apprécie la musique traditionnelle de mon pays d'origine.",
    "German" : "Ich genieße die traditionelle Musik meiner Heimat.",
    "Greek" : "Απολαμβάνω την παραδοσιακή μουσική της πατρίδας μου.",
    "Hebrew" : "אני נהנה מהמוסיקה המסורתית של מולדתי.",
    "Hindi" : "मैं अपने मूल देश के पारंपरिक संगीत का आनंद लेता हूं।",
    "Hungarian" : "Élvezem az én hazám hagyományos zenéjét.",
    "Indonesian" : "Saya menikmati musik tradisional negara asal saya.",
    "Italian" : "Mi piace la musica tradizionale del mio paese natale.",
    "Japanese" : "私は母国の伝統音楽を楽しんでいます。",
    "Korean" : "나는 내 조국의 전통 음악을 즐긴다.",
    "Norwegian Bokmal" : "Jeg liker den tradisjonelle musikken i mitt hjemland.",
    "Polish" : "Lubię tradycyjną muzykę mojego kraju.",
    "Portuguese" : "Eu gosto da música tradicional do meu país natal.",
    "Romanian" : "Îmi place muzica tradițională din țara mea natală.",
    "Russian" : "Мне нравится традиционная музыка моей родной страны.",
    "Slovak" : "Mám rád tradičnú hudbu svojej rodnej krajiny.",
    "Spanish" : "Disfruto de la música tradicional de mi país natal.",
    "Swedish" : "Jag njuter av traditionell musik i mitt hemland.",
    "Thai" : "ฉันเพลิดเพลินกับดนตรีดั้งเดิมของประเทศบ้านเกิดของฉัน",
    "Turkish" : "Ülkemdeki geleneksel müzikten zevk alıyorum."
  };
}

还有一件事:用于控制语音回放速度的数字输入。修改talk()函数以从数字输入获取语音速率,我们就完成了!

这是最终产品

function talk() {
  ...// no changes except for the rateFld.value reference
  u.rate = Number(rateFld.value);
  speechSynthesis.speak(u);
}

查看笔
多语种:多种语言的文本转语音
by Steven Estrella (@sgestrella)
CodePen 上。

一个现实世界的应用

我对这项技术的兴趣始于多年前的 1990 年,当时我在我的论文中创建了一个包含 26 个课程的课程。它使用我的第一种编程语言 HyperCard 在 Macintosh Plus 上交付,Macintosh Plus 具有一个原始的文本转语音功能。我在用户完成材料的过程中使用该功能为用户提供一些反馈。最近,在 2018 年,我创建了一个名为Buenos Verbos的免费渐进式 Web 应用,它可以帮助西班牙语学习者搜索和过滤一个包含 766 个动词的数据库。然后,所选动词将被完全共轭,用户可以点击这些形式来听到它们的语音。因此,也许网页可能希望说话,并且发挥一些想象力,你可能会找到鼓励它们说话的理由。问题是:你接下来会让你的网站说些什么?