HTML5实现DTMF电话拨号按键信号解码编码代码简单易于移植

2次阅读

共计 4577 个字符,预计需要花费 12 分钟才能阅读完成。

DTMF(Dual Tone Multi Frequency) 双音多频,由高频群和低频群组成,高低频群各包含 4 个频率;两个频率波形合成按键信号(0-9 * # A B C D)。

SIP 中检测 DTMF 信号的方法:SIPINFO、RFC2833、INBAND;至于这些是什么我这个外行纯属热闹;拿两个手机互打电话,中途按下的按键嘟嘟的声音就是直接通过话音来传输 DTMF 信号,属于 INBAND(带内检测)吧。

拿 Adobe Audition 打开手机上的电话录音文件,可以直观的肉眼看到整齐的 DTMF 信号,分析一下就能很快 GET 到此信号的解码、编码原理。

在线测试地址:在线测试

【图 1】简单粗暴合成的 PCM 信号杂波较多,但和华为手机打出来的录音信号差不多(他们杂波少点)

一、前言

1.1 HTML5 实现 DTMF 的一些动机

我的 GitHub 开源库 Recorder 功能日渐丰富,最近又有项目可能会用到 DTMF 的解码功能,所以就用 js 实现了一下,本着易于移植的目的,相关代码都是简单的纯 js 代码,移植到别的语言非常方便。

涉及到三个源码,个个小巧:

  1. FFT:lib.fft.js 111 行(代码 + 空行 + 注释)
  2. DTMF 解码:dtmf.decode.js 192 行(代码 + 空行 + 注释)
  3. DTMF 编码:dtmf.encode.js 191 行(代码 + 空行 + 注释)

自评:高性能????、准确度高????、误识别率低????;欢迎到 在线测试,下载别的一个软件 dtmf2num(命令行) 来对比伤害一下。

1.2 一些有效场景

(1) 10086

查话费请按 1,嘟(你按了一个 1),您的话费余额为 9 亿 9 千万……不能否认,这些能力的实现是建立在 DTMF 信号的编解码之上。

(2) 软电话

透过某些渠道,比如在你服务器上的程序拥有了自动拨打电话的能力,你希望通过用户按下某些按键后实现一些功能,比如输入密码,这样你的服务器端程序就需要带上 DTMF 解码功能。

(3) 小玩具

写一些小玩具把玩。嘿哈????。

二、DTMF 频率按键对照表

低频群 / 高频群 (hz) 1209 1336 1477 1633
697 1 2 3 A
770 4 5 6 B
852 7 8 9 C
941 * 0 # D

三、DTMF 信号解码 得到按键值

3.1 先学会手工解码

观察上面【图 1】,一个长的 PCM 音频中,每个按键信号频谱中都能清晰的看到两条非常亮的横线(对应此频率的信号能量非常强),Adobe Audition 中定位到需要分析的时间位置,然后点击菜单:窗口 -> 频率分析(Alt+Z),显示频率信息得到两个最高的频率;这两个最高频率就是上面频率对照表中的频率值(取最接近的值):低频 703hz 约等于 697,高频 1203hz 约等于 1209,查表可知此信号对应的按键为“1”。

3.2 了解一些原理

并非专业,看看就好。

(1) 调整 PCM 采样率基本不会干扰到 DTMF 信号

我说的。因为 DTMF 信号的最高频率是 1633hz,远低于常见的8000(频率最高 4000hz)、44100(频率最高 22050hz)采样率对应的最高识别频率。

(2) 降低采样率有利于识别 DTMF 信号

我说的。比如:8000采样率就包含了 0 -4000hz 的频率信号,44100采样率包含了 0 – 22050hz 的频率信号,相当于 441008000多了 4000 – 22050hz 的和 DTMF 信号无关的频率,而且是占大头。多出来的这些频率最直观的提现就是增大了计算量(指数级吧)。

以此类推,如果我们将 PCM 的最高频率控制在比 1633 高点,那么将会大幅减少计算量,比如限制最高 2000hz 频率,对应的采样率就是 4000,比8000 还小了一倍,把高频信号全部切掉,参考下面【图 2】。

(3) 普通话音很难刚好凑成 DTMF 信号

至少人家是这么说的。刚好有那么一个声音持续了一段时间,并且这个声音的最高两个频率刚好在 DTMF 对照表里面,概率不会太高吧。

取决于解码算法的好坏,同一段音频,可能有的解码器会错误识别出 20 个按键信号,有的可能只错误识别出 2 个按键信号(比如我写的解码器,哈????)

3.3 实现软件解码

软解码最直观的实现就是将【2.1 手工解码】按顺序用程序实现就行了,简单粗暴,不需要更多的原理和基础知识。软解 js 源码:dtmf.decode.js

(1) 降低 PCM 的采样率

为了减少计算量,和突出 DTMF 信号的频率,我们将任何 PCM 数据的采样率降低到 4000,此时的 PCM 中包含了 0 – 2000hz 的频率。可以采用最简单的重采样办法:隔几个数据抽取一个数据;比如 16000 采样率降到 4000,每 4 个采样取一个即可。此处理性能消耗忽略不计。

【图 2】4000 采样率下两个频率就非常突出了(Audition 频谱里面要到右侧刻度右键降低分辨率,不然 4000 的采样率是一坨一坨的频谱)

(2) 如何找到那两条横线

如上面【图 2】中,一个按键信号的频谱中有两个能量非常强的频率(很亮的两条横线),对应的就是 DTMF 的低频和高频,这两频率是会持续一段时间的;因此我们只要发现 PCM 内存在两个最强的频率,并且这两个频率在 DTMF 频率表中,那么我们就可以假设此时间位置可能有一个 DTMF 按键信号(注意是可能有,并非一定是一个按键信号)。

那我们现在只需要计算一下某个时间段内是否有 2 个最大频率信号在 DTMF 频率表内即可实现判断;计算方法除了用 FFT(快速傅里叶变换)外,更常用的是 Goertzel 算法,本着入门到放弃的原则,我们采用更通用的 FFT 来计算频率,Goertzel 就放弃学习了。

似乎 FFT 运算会带来性能问题,不过对于短的 PCM 计算来说,也是可以忽略不计的,并且我们已经降低了采样率(计算量指数级下降);这里给一个数据:一个 4 分 30 秒的 mp3 进行一次 DTMF 解码总消耗的时间 300ms 不到,共进行了约(4.5*60 * 1000ms) / 16ms = 16875 次 FFT 计算 (其中 16ms 是下面滑动窗口一次滑动时长距离),fftSize=256。

(3) 用 FFT 将时域信号转成频率信号

FFT 又是一个复杂的东西,还好有很多代码可以借 (copy) 鉴。参考 js 代码:lib.fft.js

FFT 需要提供一个 fftSize,越大对频率的分辨率越高,比如 fftSize=1024,分辨率为:4000/1024 = 3.90625hz(4000 是 PCM 的采样率)。FFT 计算一次后会输出Int[512] 的数组,数组内第一个点的频率就是 1 * 3.90625 = 3.90625 hz,最后一个点的频率就是 512 * 3.90625 = 2000 hz;数组内的每个值就是对应频率的信号强度值(可转换成分贝),越大信号越强。

但这个分辨率并非越大越好,因为你提供的 fftSize 越大,每次计算就需要提供同等数量的 PCM 采样数据,fftSize=1024就要提供 1024/4000*1000 = 256ms 的 PCM 数据;这样问题就产生了:我们单个 DTMF 信号音的持续时间可能就是 40 – 100 ms,256ms 覆盖的数据区间就太长了甚至可能被覆盖了两个按键信号也不一定;因此我们要调低分辨率。

调低后的折中结果就是:fftSize=256,分辨率为4000/256 = 15.625 hz(相对于 3.90625hz 分辨率降低了 4 倍),不能再低了,再低分辨率就识别不出信号到底是 DTMF 频率表中的哪个值了。此时每次计算需要的 PCM 数据时长为256/4000*1000 = 64ms,能够很好的保证区间内只有一个按键信号。

(4) 粗暴的 FFT 扫荡模式:滑动窗口,不放过任何可能的信号

我们不能简单的把 PCM 切分 N 段(256 个采样为一段),然后每段进行一次 FFT 计算,这样会大概率将一个信号拆分到两段数据中,导致检测不到这个信号。因此我们计算 FFT 时应当采用滑动窗口模式,每次将计算窗口往前滑动一点点,这样就能保证所有的数据都能被至少完整的计算一次。

可以将每次滑动大小设为窗口大小的 1 /4,即 256 个采样为窗口大小,每次 FFT 计算时往前滑动 256 / 4 = 64 个采样(64/4000*1000 = 16 ms),这样就能完美的覆盖到所有信号,看下面【图 3】。

【图 3】下面这种不停滑动的窗口,能很好覆盖所有信号区域,缺点就是 1 次计算要变成 4 次计算;上面这种虽然只要一次计算,但覆盖能力太差

(5) 连续出现的相同信号即为有效按键

只出现一次的信号不能代表这是一个有效的 DTMF 按键信号,我们累计连续出现 3 次的相同信号才判定为有效信号。因此我们能够识别到的最小按键音时长为:256/4000*1000 = 64ms , 64 / 4 = 16 ms , 16 * (3-0.999999????) ≈ 32 ms。更长的按键音时长无限制,因为连续相同的只会算一个按键信号。

另外还需要区分两个按键之间的间隙,我们定义累计出现 3 个以上没有信号的区域,下一个信号才算新的按键信号,这样就能区分多次按同一个键,因此两个信号理论上最小的间隔时长为:16 * 3 + 16 * 3 = 96 ms,但实际计结果 3 次是最小的边界,按 3 + 1 次以上才容错性更好,最佳间隔应当是 16 * 4 + 16 * 4 = 128 ms 以上,意思就是按下一个键后,下一个键要 128ms 以后再按(生成信号)。

不停的向后计算,直到 PCM 结尾,我们就能把所有 DTMF 信号找出来了,并且我们还能比较准确的转换出这些信号的位置。然后测试一下:准确度高,误识别率低,性能还可以,效果很不错(升职加薪????)。

四、DTMF 信号编码 生成按键 PCM 音频信号

并非专业,看看就好。有了解码的基础后,来编写信号生成代码就简单的了。我们只要将两个频率的波形生成出来,然后合并到一起,再按一定的间隔将多个信号摆放到 PCM 中即可;实际的代码也就是按这套逻辑写的,信号编码 js 源码:dtmf.encode.js

4.1 Mix:两个音频信号的混合

不管是生成单个按键信号,还是将按键信号混合到语音 PCM 流中,都涉及到信号的混合这种操作,似乎又是一个高深的东西;要 IFFT 计算么?先不管如何复杂,先来一个简单的混音算法来用的试试看:c = (a+b)/2 就这么简单粗暴,不过这个线性求平均值合成的声音杂音颇大。

最后采用 c = a + b - (a * b / ±0x7FFF),混音后的音质非常好,来自这篇文章,最终源码阅读上面 dtmf.encode.js 中的 Mix 函数。

4.2 生成单个按键信号

源码阅读上面 dtmf.encode.js 中的 Recorder.DTMF_Encode 函数。比如要生成“1”键的信号,查表得到低频 697 hz、高频1209 hz,然后分别生成两个频率的正弦波 PCM 信号,将两个 PCM 用Mix 函数混合到一起即可得到“1”键的信号。

这个生成代码也是出奇的简单,不过受限于 Mix 函数采用的简单混音算法,两个频率正弦波叠加后的杂波有点多,看上面【图 1】两个最大的频率两边的杂波信号也非常强,不过还好并不影响识别。

4.3 连续多个按键信号混合到语音 PCM 流中

这个才是实际实用的函数:上面 dtmf.encode.js 中的EncodeMix.prototype.mix(pcms,sampleRate,index),不管你一次性按下多少个按键,混音函数会按部就班的一个一个的混合到语音流中,并且保证按键之间的间隔能被解码程序正确识别。

这个代码也算简单,总共做了两件事:延迟 + 调用 Mix 函数,其中 Mix 调用实际是替换 PCM 并不是两个 PCM 混音。


最后来个动图收尾吧:

= 完 =

正文完
 0