HTML5实现DTMF(电话拨号按键信号)解码、编码,代码简单易于移植
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代码,移植到别的语言非常方便。
涉及到三个源码,个个小巧:
- FFT:lib.fft.js 111行(代码+空行+注释)
- DTMF解码:dtmf.decode.js 192行(代码+空行+注释)
- 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 的频率信号,相当于44100
比8000
多了 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混音。
最后来个动图收尾吧:
= 完 =