嵌入式应用的语音 I/O
语音用户界面就像计算领域的圣杯。我们通过交谈来沟通,科幻故事——从《2001:太空漫游》中的 HAL 到《星际迷航》中的飞船计算机——都指向会说话的计算机是不可避免的未来。但是,创建自然且人们会使用的语音界面已被证明是困难的。语音技术经常被提供,甚至预装(如 Microsoft Windows 语音识别),但从未被使用,但仍有一些希望的曙光。进行“像样”的语音识别和语音合成的技术已经存在一段时间了,用户正在尝试使用它,至少在某些应用类别中是这样。
感觉机会已经成熟,有人可以把语音界面做好。也许您就是那个发明语音界面的人,它能使您的嵌入式应用程序像 iPhone 触摸界面刚问世时一样酷炫和独特。
在某些方面,嵌入式应用程序特别适合语音。嵌入式设备通常体积小,可能没有丰富的用户界面。几乎根据定义,嵌入式应用程序不是通用的,因此如果语音界面词汇量有限也没关系。语音可能是提供的唯一用户界面,也可能是对显示器和键盘的补充。
移动电话是一类嵌入式应用程序,语音可以在其中作为用户界面工作。“语音拨号”(“拨打家庭电话”)几乎是一个微不足道的界面,但在电话上运行良好。如果您正在开车并想发送短信,则使用手机的软键盘输入消息及其目的地是很困难的(在许多地方也是非法的)。语音识别已经足够好,移动电话也是足够强大的计算机,通过语音发送短信是人们开始采用的有效用例。
在本文中,我将研究语音合成和识别技术,并了解它们如何适应当今的嵌入式设备。作为一个示例应用程序,并且与重新发现清单作为生产力工具的趋势(感谢 Atul Gawande 的畅销书《清单革命》)保持一致,我们将构建一个简单的语音清单,您可以在下次进行手术时使用,就像 Gawande 医生一样(孩子们不要在家尝试)。
与任何其他用户界面一样,语音界面也有两个组成部分:输入和输出(或识别和合成)。这两种技术密切相关,共享技术、算法和数据模型。如前所述,语音一直是计算机研究中非常热门的课题,我无法在此涵盖所有工作,但我将快速浏览一些不同的方法,研究一些开源实现,并确定似乎非常适合嵌入式应用程序的输入和输出包。您不必成为计算机化语音专家(我当然不声称是)即可为您的嵌入式应用程序启用语音功能。
天真地想,您可能会认为“语音合成有什么难的?”您设想一个哈希映射,其中英语单词作为键,语音发音作为值。但是,这并没有那么容易。任何重要的 TTS 系统都需要能够理解文本中嵌入的日期和数字等内容,并正确地发音。而且,正如任何一年级学生都可以告诉您的那样,英语充满了发音取决于上下文的单词(“lead”应该发音为与“reed”押韵还是与“red”押韵?)。当句子或问题结束时,我们也会改变声音的音调,并且我们在从句和句子之间停顿(称为语音的韵律)。
许多聪明的人都考虑过这一点,并提出了 TTS 的基本架构
前端分析文本,用单词替换日期、数字和缩写,并发出描述发音的音素和韵律单元流。
后端或合成器,它接受发音流并将其转换为声音。
前端,有时称为文本规范化,不是一个容易的问题。它是人类容易做到而计算机难以模仿的模式事物之一。使用的算法从简单(单词替换)到复杂(统计隐马尔可夫模型)不等。对于要说的文本相对固定的应用程序(如我们的清单),大多数 TTS 系统都提供了一种标记文本的方法,以向规范化器提供有关应如何发音的提示(并且,有一种标准的语音合成标记语言可以做到这一点;请参阅资源)。
已经开发了各种方案来构建语音合成器。最流行的两种方案似乎是共振峰合成和拼接合成。
共振峰合成器可以非常小,因为它们实际上不存储任何数字化语音。相反,它们使用一组规则对语音进行建模,并为每个音素的模型存储基于时间的参数。语音的韵律方面相对容易引入模型,因此共振峰合成器以其模仿情感的能力而著称。它们也以听起来“机器人化”但非常清晰而著称。对于我们选择的应用程序,清晰度比“自然度”更重要。
拼接合成器有一个语音片段数据库,这些片段被串在一起以创建最终的声音流。片段可以是单个音素到完整句子之间的任何内容。它们以自然的语音而闻名,尽管该技术可能会产生令人分心的故障,这可能会干扰清晰度,尤其是在更高的速度下。由于大型词汇表需要大型数据库,因此它们通常也比共振峰合成器更大。如果 TTS 用于特定领域的应用程序,则可以最大限度地减少数据库,但这当然会限制其有用性。
与 TTS 相比,语音识别有一种主要的算法,即隐马尔可夫模型 (HMM)。如果您以前没有遇到过 HMM,请不要指望我在这里详细解释数学原理,因为坦率地说,我并没有完全理解它。但我确实理解 HMM 背后的思想,这比您需要了解的才能使用基于 HMM 的识别器要多得多。
如果您对语音波形进行采样(例如每 10 毫秒一次),并对生成的波形进行一些奇特的数学运算,提取频率和幅度分量,您最终可以得到一个倒谱系数向量。然后,您可以将连接的语音建模为一系列这些倒谱向量。马尔可夫模型就像一个状态机,其中特定状态转换的概率仅取决于当前状态。在我们的例子中,马尔可夫模型的每个状态都对应于一个特定的向量,并且随着马尔可夫模型以概率方式通过其状态移动,会生成一系列倒谱向量和声音。隐马尔可夫模型是一种您看不到状态转换的细节的模型,您只能看到输出向量。
诀窍是创建大量这些 HMM,每个 HMM 都经过训练以模仿来自大量人类生成的语音样本的声音。再说一次,这里的数学原理超出了我的能力范围,但过程是将训练算法暴露于所需语言的大量语音样本中。随着 HMM 海洋的训练,它们开始能够重现它们在训练样本中“听到”的声音。
为了使用 HMM 识别语音,我们使用了最后一点数学魔法。对于适当的 HMM 集,有一些算法,给定一个波形(希望是语音),可以告诉您:1) 哪些 HMM 序列可能生成了该波形,以及 2) 每个序列的概率。
因此,HMM 不会给我们关于语音代表什么词的明确答案,但它们会给我们一个列表供我们选择,并告诉我们哪个最有可能以及可能性有多大。这不是很酷吗?
有许多商业 TTS 包可用,但此处我们不关注它们。在开源方面,仍然有很多候选者,其中一些似乎更受欢迎
eSpeak 是 Ubuntu 和其他几个 Linux 发行版附带的 TTS 包。它是共振峰风格的,因此体积小(约 1.4MB),具有通常的机器人共振峰声音。如果需要,eSpeak 规范化器也可以与双音素合成器 (MBROLA) 一起使用,但我们不会在此处的清单示例中利用这一点。
Flite 是 Festival 的嵌入式版本,Festival 是源自爱丁堡大学的开源语音合成包,Flite 是在卡内基梅隆大学完成的。它是基于双音素拼接的,正如您所期望的那样,它具有更自然的声音。CMU 还提供一套用于开发新声音的脚本和工具,称为 FestVox。
大多数语音识别软件包都是适用于 Windows 和 Mac OS X 的商业软件。我研究了两个开源语音识别软件包,都来自卡内基梅隆大学的 Sphinx 组
Sphinx-4 是一个语音识别器和框架,可以使用多种识别方法,用 Java 编写。它主要用于服务器应用程序和研究。
PocketSphinx 是一个从 Sphinx 派生的语音识别器,用 C 语言编写。因此,它比 Sphinx 小得多(但对于中等词汇量仍然约为 20MB),并且它可以在小型处理器上实时运行,即使是那些没有浮点硬件的处理器。
PocketSphinx 是两个实现之间的显而易见的选择,因此这就是我们将在此处使用的。
为了灵活性和速度,我为示例程序选择了一个相当高端的嵌入式平台。Genesi LX 是一款迷你台式电脑,对于嵌入式设备来说配置相当慷慨
Freescale i.MX515 (ARM Cortex-A8 800MHz)。
3D 图形处理单元。
WXGA 显示支持 (HDMI)。
多格式高清视频解码器和 D1 视频编码器。
512MB 内存。
8GB 内部 SSD。
10/100Mbit/s 以太网。
802.11 b/g/n Wi-Fi。
SDHC 卡读卡器。
2 个 USB 2.0 端口。
用于耳机的音频插孔。
内置扬声器。

图 1. Genesi EFIKA MX Smarttop
最重要的是,有一个 Ubuntu 10.10 (Meerkat) 发行版可用于加载并在系统上运行,这使得安装和测试变得容易得多。Ubuntu 的下载和安装说明在 Genesi 网站上。通过 U-boot 引导加载程序从 SD 卡安装非常简单。
eSpeak TTS 系统最初是为 Acorn RISC Machine 开发的(您能说“完全循环”吗?),它随 Ubuntu 一起提供,并包含在 Genesi 版本中,因此我们无需在此处安装。对于您的嵌入式系统来说,情况可能并非如此,但 eSpeak 的安装过程很简单,并在 eSpeak 站点上的下载的 README 中给出(请参阅资源)。当然,您需要在 Scratchbox 的上下文中进行安装,或者您用于嵌入式 Linux 的任何本机构建环境。
要安装 PocketSphinx,您首先需要安装 sphinxbase,它也可以在 PocketSphinx 站点上找到。两者都是 tarball,安装说明在 README 中给出。在像 Genesi 这样的系统上,您可以下载并使用目标来构建软件包。我确实需要设置 LD_LIBRARY_PATH,所以ld可以找到库
export LD_LIBRARY_PATH=/usr/local/lib
在较小的嵌入式系统上,您可能必须使用交叉编译器或 Scratchbox。
我们想要创建一个通用的语音清单程序,类似于 Gawande 医生书中讨论的清单。作为一个例子,让我们使用世界卫生组织手术安全清单的一部分。
让我们创建一个语音清单程序,它可以读取清单并收听每个项目的回复。我们现在只需将回复与一些有效的回复进行匹配并将其记录在文件中,但这可能是您自己创新的语音用户界面想法的跳板。
PocketSphinx 附带一个名为 pocketsphinx_continuous 的应用程序,它可以进行基本的连续语音识别并将结果打印到 stdout,以及有关它如何执行识别的大量信息。我们将创建一个小的 C 程序 atul.c,它使用 libespeak 库来说出清单项目。我们将把 pocketsphinx_continuous 管道传输到 atul,以便 atul 可以收听其 stdin 上的回复。
atul 的编译命令将因您的开发环境而异。调用是
pocketsphinx_continuous | ./atul SafeSurgery.ckl
让我们通过从文本文件中读取清单项目和命令来保持应用程序的简单性,我们将文本文件的名称作为程序的参数传递。让我们用行首的 # 标记命令。如果 # 后跟一个数字,让我们暂停该数字秒(最多 9 秒)。我们将每个项目和回复记录为文本到 stdout。
espeak 库依赖于两个开发包,您需要将它们加载到您的目标开发环境中。两者都作为 rpm 或 deb 包 readily 可用portaudio-devel和espeak-devel.
安全手术清单文件如清单 1 所示,清单 2 显示了 atul.c 的源代码。
清单 1. SafeSurgery.ckl
This is the Surgical Safety Checklist. # Before induction of anesthesia. # Has the patient confirmed his or her identity, site, procedure and consent? # yes | no Is the site marked? # yes | notapplicable Is the anesthesia machine and medication check complete? # yes Is the pulse oximeter on the patient and functioning? # yes Does the patient have a known allergy? # yes | no Does the patient have a difficult airway or aspiration risk? # no | yes Is there a risk of more than 500 milliliters of blood loss? # no | yes Thank you, that completes this portion of the checklist.
清单 2. atul.c
/* atul - a simple speech checklist for embedded systems */ #include <string.h> #include <malloc.h> #include <stdio.h> #include <speak_lib.h> espeak_POSITION_TYPE position_type; espeak_AUDIO_OUTPUT output; char *path=NULL; int BuffLen=500, Options=0; void* user_data; t_espeak_callback *SynthCallback; espeak_PARAMETER Parm; FILE *ckfp; /* Checklist file pointer */ char *ckBuf; /* Checklist item buffer */ char *mtchBuf; /* Checklist expected response buffer */ char *srBuf; /* Speech rec buffer */ char *reply; /* Trimmed reply */ int bsize=100; /* buffer length for all buffers */ int next; /* flag - true if should go to next prompt */ char Voice[] = {"default"}; unsigned int size, position=0, end_position=0, flags=espeakCHARS_AUTO|espeakENDPAUSE, *unique_identifier; void recordreply(){ /* read lines from stdin, which are piped in * from pocketsphinx_continuous. * Valid responses look like: * <9 digits>: reply (7 or 8 digits) * Returns a trimmed reply as char *reply * no spaces in return */ int i, j; while (!feof(stdin)) { getline (&srBuf, &bsize, stdin); if (srBuf[9]!= ':') continue; j=0; for (i=0; i<strlen(srBuf); i++) { if (isdigit(srBuf[i])) continue; if (srBuf[i]=='-') continue; if (srBuf[i]==':') continue; if (isspace(srBuf[i])) continue; if (srBuf[i]=='(') continue; if (srBuf[i]==')') continue; reply[j++] = srBuf[i]; } reply[j] = '\0'; break; } } int checkreply(){ /* returns true if reply matches * false if no match (try again) */ char *tryagain="Please answer "; char *ans, *spBuf; /* if template blank, just sleep 2 sec */ if (strlen(mtchBuf)==2) { sleep(2); return(1); } /* see if reply matches template */ recordreply(); printf("reply: '%s'\n", reply); if (strstr(mtchBuf, reply)==NULL){ /* no match - tell user what we're looking for */ spBuf = (char *) malloc (bsize+1); strcpy(spBuf, tryagain); if (ans=strtok(mtchBuf+2, "|")){ strcat(spBuf, ans); } ans = strtok(NULL, "|"); while (ans!=NULL){ strcat(spBuf, " or "); strcat(spBuf, ans); ans = strtok(NULL, "|"); } espeak_Synth( spBuf, size, position, position_type, end_position, flags, unique_identifier, user_data ); espeak_Synchronize( ); free(spBuf); return(0); /* repeat last prompt */ }else return(1); /* go to next prompt */ } int main(int argc, char* argv[] ) { printf("atul started.\n"); /* allocate needed buffers */ ckBuf = (char *) malloc (bsize+1); srBuf = (char *) malloc (bsize+1); mtchBuf = (char *) malloc (bsize+1); reply = (char *) malloc (bsize+1); /* open the checklist file */ if (argc < 2) { printf("Usage: atul <checklist filename>\n", argc); return 0; } ckfp = fopen(argv[1], "r"); if (ckfp == NULL) { printf("Unable to open checklist file: %s\n", argv[1]); return 0; } /* Initialize the TTS subsystem */ output = AUDIO_OUTPUT_PLAYBACK; espeak_Initialize(output, BuffLen, path, Options); espeak_SetVoiceByName(Voice); /* Initialize speech recognition * piped in from pocketsphinx_continuous */ while (!feof(stdin)) { getline (&srBuf, &bsize, stdin); if (strncmp(srBuf, "READY...", 8)==0) break; } /* Go through the checklist */ next = 1; /* advance to next prompt */ while (!feof(ckfp)) { if (next) { getline (&ckBuf, &bsize, ckfp); getline (&mtchBuf, &bsize, ckfp); } size = strlen(ckBuf)+1; espeak_Synth( ckBuf, size, position, position_type, end_position, flags, unique_identifier, user_data ); espeak_Synchronize( ); fputs(ckBuf, stdout); next = checkreply(); } fclose(ckfp); free(ckBuf); free(srBuf); free(mtchBuf); return 0; }
代码不是很复杂,尽管事后看来,它在 Python 或其他在字符串操作方面比 C 更好的语言中可能会更清晰。主例程初始化 TTS 子系统,并确保 phoenix_continuous 已准备好捕获回复并将其转发给我们。然后,它只是循环遍历清单文件,读取提示并将回复与在清单文件中找到的可接受回复进行比较。如果找不到匹配项,它会告诉用户它在寻找什么并再次询问。需要注意的一件事是,在recordreply()中的字符串修剪例程会删除所有空格,因此如果您的清单正在寻找多词响应,请务必连接列表中的单词(如我们上面清单中的“notapplicable”)。所有值得注意的内容都记录在 stdout 中,您可能希望将其重定向到日志文件。
我们仅仅触及了语音用户界面的表面,即使对于清单应用程序也是如此。根据您的嵌入式系统,您必须为用户提供一种启动和结束清单应用程序的方法,理想情况下,您应该有一种方法向用户发出应用程序正在侦听的信号。PocketSphinx 会提示“正在收听...”并且应该在说“再见”时终止(这对我不适用 — 也许是我的德州口音?)。PocketSphinx 的源代码(标记为 continuous.c)随软件包一起提供,因此您可以对其进行实验。您可以在语音识别和合成方面进行许多许多优化,使用受限词汇表、不同的语音数据库以及仅调整参数。
那么,更通用、实用的嵌入式设备语音用户界面呢?工具是可用的——您能有多大的创造力?
资源
eSpeak: espeak.sourceforge.net/index.html
卡内基梅隆大学的 Sphinx 组: cmusphinx.sourceforge.net
卡内基梅隆大学的 Flite 页面: www.speech.cs.cmu.edu/flite
Genesi EFIKA MX: www.genesi-usa.com/products/efika
世界卫生组织安全手术清单: www.who.int/patientsafety/safesurgery/ss_checklist/en/index.html
Rick Rogers 是一位拥有 30 多年经验的专业嵌入式开发人员。现在专注于移动应用程序软件,当 Rick 不靠编写软件为生时,他就在撰写书籍和杂志文章,例如这篇文章。他欢迎对本文的反馈,邮箱地址为 portmobileapps@gmail.com。