☰
DTMF电话按键音识别九键:dtmf2num频点判决与去抖实战
2026/11/4 17:36:55 网站建设 项目流程

简介:面向电话按键音识别与音频解码场景,这份资料提供轻量级 DTMF 双音多频检测工具,可用于九键按键识别、PCM 波形解析及 MF/DTMF 音调分析,适合具备 C 语言与数字信号处理基础的学习者参考。压缩包共 6 个文件,仅 28KB,以 C 源码、头文件、可执行程序和 Makefile 为主,便于编译运行、阅读算法或裁剪移植。其支持多种位深与声道配置的 WAV 文件及原始 PCM 数据,并包含直流偏置调整、归一化等自动优化处理,对理解音频预处理、重采样和频点检测流程较有价值。目前已有 1072 人学习下载,可作为课程设计、嵌入式音频小工具或信号处理入门实践的参考资料。

1. 电话按键音不是玄学:dtmf2num.zip 先把“听音辨键”拆成频点问题

dtmf2num.zip 这个包瞄准的不是语音转写,而是电话按键音的按键识别九键问题:听到一声按键音,判断用户按的是 1 还是 7。电话按键音背后是双音多频,两个频率同时出现,九键里的每个数字对应一组高低频组合。它适合做客服录音质检、老式话机测试、嵌入式音频调试,也适合课程设计里需要一个能跑通的音频识别小项目。判断这份资源值不值得拆,不看包多大,看它有没有把频点表、分帧判决和九键映射串成一条能复现的链路。

2. 双音多频到九键映射:先定频点表,再谈识别链路

2.1 DTMF 的频点组合与九键数字映射

电话按键识别九键,第一步不是写代码,而是把频点表钉死。双音多频把键盘拆成行频组和列频组:行频通常是 697 Hz、770 Hz、852 Hz、941 Hz,列频通常是 1209 Hz、1336 Hz、1477 Hz、1633 Hz。按下某个键时,设备同时发出一个行频和一个列频,接收端只要分别找到行、列两个峰值,就能反推出按键。九键数字只用到前三列,第四列 1633 Hz 主要对应 A、B、C、D 四个特殊键,很多业务录音里不会出现。

行频 / 列频1209 Hz1336 Hz1477 Hz1633 Hz
697 Hz123A
770 Hz456B
852 Hz789C
941 Hz*0#D

这张表看着简单,落地时最容易翻车的地方在“频率对不上”。有些录音经过电话线路、免提喇叭、手机麦克风二次采集,频点会偏移十几赫兹;有些压缩格式会把高频削掉,导致列频能量变弱。常见做法是不要只检测一个精确频点,而是给每个目标频率留一小段搜索范围,比如中心频率上下 1.5% 到 2%,取范围内最大能量。九键里的 0 在 941 Hz 和 1336 Hz 上,* 在 941 Hz 和 1209 Hz 上,# 在 941 Hz 和 1477 Hz 上,这三个键的行频相同,靠列频区分,列频判错就会把 0 认成 * 或 #。

双频组合的好处是抗误判。语音里的元音、环境噪声、键盘敲击声都可能在某一个频点上有能量,但同时命中一组行频和列频的概率低很多。电话按键的标称持续时间常见为 40 ms 以上,这个数值决定了分帧窗长不能太短:窗太短,频率分辨率不够,697 Hz 和 770 Hz 容易混;窗太长,又会把相邻两个按键糊在一起。我一般先按 30 到 40 ms 窗、10 ms 帧移起步,再根据实际录音调整。

2.2 识别链路:分帧、找峰、判决、去抖

一条能复现的链路可以拆成六步。第一步读音频,统一成单声道、浮点采样;第二步按固定窗长切帧,帧与帧之间保留重叠;第三步对每一帧计算 8 个目标频点的能量,行频 4 个、列频 4 个;第四步在行频组和列频组里各找最大峰;第五步做判决,要求行峰、列峰在总能量里占足够比例,且总能量高于静音门限;第六步把连续帧的判决结果合并成按键序列,过滤掉太短的抖动。

这里不需要对整段音频做全谱分析。目标频点只有 8 个,逐点递推的单频点检测就够用,计算量小,也方便嵌到脚本里。它的思路是:对某一个目标频率,用一个二阶递推滤波器累积能量,最后看这个频率上的响应有多强。相比把整段频谱算出来再查表,这种做法在长录音上更省时间,参数也更直观。

判决环节要有“相对能量”的概念。只看某个频点的绝对幅度,遇到音量忽大忽小的录音就会失效。更稳的做法是算两个比例:行峰能量除以 8 个频点总能量,列峰能量除以总能量。两个比例都超过阈值,才认为当前帧是一个有效按键。阈值不能拍脑袋,常见起点是 0.15 到 0.25,再拿样本调。静音段还要加一个总能量门限,否则极小的底噪也可能被比例放大成假按键。

去抖是另一个关键。按键按下时,相邻多帧会连续判成同一个键;按键松开时,可能有一两帧掉到静音或误判成别的键。处理方式是:连续相同键合并成一个,短于最小持续时间的片段丢弃,两个键之间间隔超过设定值才分键。九键输入里长按可能代表重复,但录音质检场景通常只需要知道“按过哪个键”,所以我会把长按合并成一个,避免输出一长串重复数字。

2.3 资源怎么接:dtmf2num.zip 的定位与最小验证

拿到 dtmf2num.zip,先别急着批量跑。先看压缩包结构,确认入口脚本、示例音频和说明文件在哪。不同资源的组织方式不一样,有的是一个命令行脚本,有的是带函数库的目录,有的只给核心代码。先解压到独立工作目录,避免把当前工程弄乱。下面这几条命令只做一件事:把包看清楚,不修改原文件。

# 查看压缩包结构,先看前 40 行,避免输出刷屏 unzip -l dtmf2num.zip | sed -n '1,40p' # 解压到独立目录,保持原包不动 mkdir -p dtmf2num_work unzip -q dtmf2num.zip -d dtmf2num_work # 列出两层以内的文件,找入口脚本和样例音频 find dtmf2num_work -maxdepth 2 -type f | sort

解压后先找三类东西:可执行入口、示例 WAV、参数说明。入口可能是 Python 脚本,也可能是编译好的命令;示例音频用来验证环境,参数说明用来判断默认采样率和窗长。先跑一个最短的样例,看输出是逐帧标签还是已经合并好的按键序列。如果输出是逐帧标签,说明后面还要自己做去抖;如果直接输出数字串,就要确认它有没有把 0、*、# 映射对。没有示例音频时,用手机录一段按键音也能起步,但要注意录音格式和采样率,别把 44.1 kHz 立体声直接塞进只认 8 kHz 单声道的脚本。

提示:先验证单样本,再谈批量。单样本跑通只能说明环境没错,批量跑稳定才说明参数和去抖逻辑站得住。

3. 跑通最小解码:从 WAV 读取到第一串数字

3.1 音频入口和格式检查

最小解码从读 WAV 开始。很多识别不准的问题,根源不在算法,而在输入格式:双声道被当成单声道交错读取,16 bit 被当成 8 bit,采样率标错,或者文件是浮点 WAV 而读取代码只认整型 PCM。先写一个只做检查的函数,把通道数、采样位宽、采样率、时长打印出来,再决定要不要转单声道、要不要重采样。电话按键识别九键的目标频率最高 1633 Hz,8 kHz 采样率已经够用,16 kHz 更稳,44.1 kHz 也能算,只是窗长对应的点数更多。

import wave import numpy as np def read_wav_mono(path): """读取 8bit/16bit PCM WAV,返回单声道浮点信号和采样率。""" with wave.open(path, "rb") as wf: n_channels = wf.getnchannels() sampwidth = wf.getsampwidth() fs = wf.getframerate() n_frames = wf.getnframes() raw = wf.readframes(n_frames) if sampwidth == 2: x = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0 elif sampwidth == 1: # 8bit PCM 通常是无符号,中心在 128 x = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0 else: raise ValueError("这里只演示 8bit/16bit PCM,浮点 WAV 先转成 16bit") if n_channels > 1: # 多声道取平均,电话录音常见双声道重复,取一轨也可以 x = x.reshape(-1, n_channels).mean(axis=1) return x, fs

这段代码的输入是文件路径,输出是x和fs。x已经归一化到大致 -1 到 1,后面算能量时不用关心原始幅度。sampwidth为 2 时按小端 16 bit 读,为 1 时按无符号 8 bit 读;遇到 24 bit 或浮点 WAV,先转格式,别在识别脚本里硬扛。多声道取平均是为了省事,如果左右声道内容不同,取平均可能让按键音变弱,这时改成取能量更大的一轨更稳。

3.2 单帧频点能量检测代码

下面这个函数只做一件事:给一帧信号和一个目标频率,返回该频率上的能量。它用升余弦窗降低频谱泄漏,用二阶递推累积目标频率响应。代码里没有调用重型频域库,方便看清每一步在做什么。窗长n由外部传入,目标频率freq和采样率fs决定递推系数。

def tone_energy(frame, fs, freq): """计算一帧信号在目标频率上的归一化能量。""" n = len(frame) if n < 8: return 0.0 # 升余弦窗,减少截断带来的频谱泄漏 idx = np.arange(n) win = 0.5 - 0.5 * np.cos(2.0 * np.pi * idx / (n - 1)) x = frame * win coeff = 2.0 * np.cos(2.0 * np.pi * freq / fs) s_prev = 0.0 s_prev2 = 0.0 for sample in x: s = sample + coeff * s_prev - s_prev2 s_prev2 = s_prev s_prev = s power = s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2 # 除以 n*n,让不同窗长的能量大致可比 return float(power / (n * n))

frame是一维浮点数组,fs是采样率,freq是目标频点。coeff由目标频率和采样率算出来,循环里的s是递推状态,最后用s_prev和s_prev2组合出能量。power / (n * n)是归一化,避免窗长从 20 ms 换到 40 ms 后阈值全部失效。实际工程里可以把循环改成向量化或编译扩展,但调参阶段保留这个写法更直观。

注意:目标频率必须低于采样率的一半。8 kHz 采样率下 1633 Hz 没问题,但如果后面要检测更高频点,先把采样率提上去。

3.3 把帧结果拼成按键序列

单帧只能告诉你“这一帧像不像某个键”,要得到数字串,还得把帧拼起来。下面的decode_dtmf做了分帧、8 频点检测、行峰列峰判决、连续键合并和最短持续时间过滤。它把 A、B、C、D 也留在映射表里,业务侧如果只做九键,把 A 到 D 过滤掉即可。参数从外部传入,方便后面做网格搜索。

ROW_FREQS = [697, 770, 852, 941] COL_FREQS = [1209, 1336, 1477, 1633] KEYS = [ ["1", "2", "3", "A"], ["4", "5", "6", "B"], ["7", "8", "9", "C"], ["*", "0", "#", "D"], ] def decode_dtmf(path, win_ms=35, hop_ms=10, min_key_ms=40, gap_ms=80, row_ratio=0.18, col_ratio=0.18, total_floor=1e-7): x, fs = read_wav_mono(path) win = int(fs * win_ms / 1000) hop = int(fs * hop_ms / 1000) frames = [] for start in range(0, max(1, len(x) - win + 1), hop): frame = x[start:start + win] if len(frame) < win: frame = np.pad(frame, (0, win - len(frame))) energies = [tone_energy(frame, fs, f) for f in ROW_FREQS + COL_FREQS] row_e = energies[:4] col_e = energies[4:] total = sum(energies) + 1e-12 ri = int(np.argmax(row_e)) ci = int(np.argmax(col_e)) row_peak = row_e[ri] col_peak = col_e[ci] active = ( total > total_floor and row_peak / total >= row_ratio and col_peak / total >= col_ratio ) frames.append((start, KEYS[ri][ci] if active else None)) # 合并连续相同键,短片段丢弃,间隔够大才分键 seq = [] current = None start_ms = None last_ms = None for start, key in frames: t = start * 1000.0 / fs if key is None: if current is not None and last_ms is not None and t - last_ms > gap_ms: if last_ms - start_ms >= min_key_ms: seq.append(current) current = None elif current == key: last_ms = t + win_ms else: if current is not None and last_ms is not None: if last_ms - start_ms >= min_key_ms: seq.append(current) current = key start_ms = t last_ms = t + win_ms if current is not None and last_ms is not None and last_ms - start_ms >= min_key_ms: seq.append(current) return "".join(seq)

win_ms是分析窗长,hop_ms是帧移,min_key_ms用来过滤抖动,gap_ms用来判断两个按键之间是否真的断开。row_ratio和col_ratio控制行峰、列峰在总能量里的占比,total_floor控制静音门限。代码里last_ms = t + win_ms是把整帧覆盖的时间算进去,否则 35 ms 窗可能让短按键看起来不到 40 ms。这个实现为了可读性保留逐帧循环,长录音上会慢一些,但调参足够用。

4. 把识别率拉上来:窗长、阈值、采样率与九键边界

4.1 窗长与频率分辨率:为什么 20ms 不是随便拍

窗长决定频率分辨率。频率分辨率大致是采样率除以窗内点数,换算成时间就是 1 除以窗长。20 ms 窗的频率分辨率约 50 Hz,30 ms 约 33 Hz,40 ms 约 25 ms。行频组里 697 Hz 和 770 Hz 只差 73 Hz,20 ms 窗虽然能分开,但遇到频率偏移和噪声时余量不大;30 到 40 ms 更稳。窗再长,比如 60 ms,频率分辨率更好,但一个窗可能跨过两个按键,短按键会被邻键能量污染。

窗长频率分辨率(约)适用场景风险
20 ms50 Hz按键节奏快、短促录音697/770 容易混
30 ms33 Hz通用起点短按键边界略糊
40 ms25 Hz按键持续较长、噪声大邻键粘连
60 ms17 Hz离线分析、慢速按键不适合密集按键

帧移一般取窗长的四分之一到三分之一。30 ms 窗配 10 ms 帧移,既能保证每个按键被多帧覆盖,又不会让计算量失控。窗函数用升余弦窗,别用矩形窗;矩形窗截断效应强,弱列频容易被行频泄漏压住。采样率方面,8 kHz 足够覆盖 1633 Hz,16 kHz 更稳;44.1 kHz 直接算也行,但窗内点数变多,逐点循环会慢,常见做法是先做抗混叠降采样到 8 kHz 或 16 kHz,再进入检测。

4.2 判决阈值:用相对能量而不是固定幅度

阈值是第二个容易拍脑袋的地方。固定幅度阈值换一段录音就失效,因为录音增益、距离、线路衰减都不一样。相对能量更稳:行峰除以总能量、列峰除以总能量,再加一个总能量门限。行峰和列峰都超过比例阈值,才判为有效键。电话按键的标准里还有“扭转”概念,也就是行频和列频的幅度差不能太离谱;实际录音里行频往往比列频强,所以不要要求两者相等,给一个宽容范围即可。

参数建议起点调大后的效果调小后的效果
row_ratio0.18更严,漏键增多更松,误识别增多
col_ratio0.18弱列频被丢噪声列频被收
total_floor1e-7静音更干净底噪出假键
min_key_ms40短按键被丢抖动被保留
gap_ms80连按被合并单键被拆开

调参不要一次改五个。先固定窗长和帧移,调total_floor把静音段压住;再调row_ratio、col_ratio让有效键稳定;最后调min_key_ms和gap_ms处理连按与重复。每改一轮,拿同一批样本跑一遍,记录漏键和误键,不要凭一段音频的感觉定参数。九键识别里 0、*、# 共用 941 Hz 行频,列频阈值偏松时最容易在这三个键之间串。

4.3 九键映射与特殊键:0、*、#、A-D 怎么处理

九键业务通常只关心 1 到 9、0、、#。资源如果输出 A 到 D,不要直接当成数字用,先按业务规则过滤或映射。0 在 941 Hz 行、1336 Hz 列;在 941 Hz 行、1209 Hz 列;# 在 941 Hz 行、1477 Hz 列。行频相同,列频只差 127 Hz 到 141 Hz,窗长太短时列频峰值容易偏。A 到 D 在 1633 Hz 列上,很多电话录音里被低通滤掉,检测不到是正常的,不要为了检出 A 到 D 把阈值放得很松,否则九键会先乱。

KEY_TO_FREQ = { "1": (697, 1209), "2": (697, 1336), "3": (697, 1477), "4": (770, 1209), "5": (770, 1336), "6": (770, 1477), "7": (852, 1209), "8": (852, 1336), "9": (852, 1477), "*": (941, 1209), "0": (941, 1336), "#": (941, 1477), "A": (697, 1633), "B": (770, 1633), "C": (852, 1633), "D": (941, 1633), } def only_nine_key(seq): # 业务侧只保留九键和 *、0、# return "".join(ch for ch in seq if ch in "1234567890*#")

这段映射表和前面的KEYS要一致。only_nine_key是业务过滤,不参与检测。长按重复的处理要看场景:如果是输入法式九键,长按可能代表连续输入同一个数字;如果是按键记录,长按只算一次。我的习惯是检测层先输出“按键事件”,业务层再做去重和长按语义,别把两件事混在一个阈值里。

4.4 端到端参数模板与验证方法

把参数收进一个配置对象,后面做批量时不会到处改魔法数。下面的模板只保留检测需要的字段,实际脚本可以按资源里的入口再包一层。验证时至少准备三类样本:干净合成音、手机近距离录音、电话线路或免提录音。每类抽十条,人工标好数字串,跑完统计漏键、误键、顺序错误。顺序错误往往不是频点判错,而是去抖参数把相邻键合并了。

from dataclasses import dataclass @dataclass class DtmfConfig: win_ms: int = 35 hop_ms: int = 10 min_key_ms: int = 40 gap_ms: int = 80 row_ratio: float = 0.18 col_ratio: float = 0.18 total_floor: float = 1e-7 CFG = DtmfConfig() print(CFG)

这份配置可以直接传给前面的decode_dtmf,把参数从函数签名里挪出来。验证不要只看整体准确率,要把“漏键”“多键”“错键”“顺序错”分开记。漏键多,先看min_key_ms和总能量门限;多键多,先看静音门限和gap_ms;错键集中在 0、*、#,先看列频阈值和窗长。把错误分型之后,调参才有方向。

5. 常见问题排查:误识别、漏键、串音与采样率陷阱

5.1 静音段一直出数字

现象:一段没有按键的录音,输出里出现 0、# 或 1,且位置随机。原因通常不是频点表错,而是总能量门限太低,底噪在归一化之后被比例放大;另一种情况是判决只看了行峰和列峰的比例,没有要求双音同时存在,弱噪声也可能凑出一组峰。解决时先把total_floor抬高一个数量级,观察静音段是否干净;再把判决条件改成行峰、列峰都超过比例阈值,并额外要求行峰加列峰占总能量的一半以上。若仍有零星假键,加最小持续时间过滤,短于 30 到 40 ms 的片段直接丢。不要用固定幅度阈值一刀切,不同录音的底噪差很多。

5.2 短按键漏识别

现象:人工听得到“嘀”声,输出里没有这个数字,尤其是快速按下的键。原因常见有三类:窗长比按键还长,整帧被前后静音稀释;min_key_ms设得比实际按键持续时间大;总能量门限太高,弱录音被整段压掉。解决时先把窗长降到 25 到 30 ms,帧移保持 10 ms,确保一个短按键至少覆盖两到三帧;再把min_key_ms降到 30 ms 试跑,确认不是过滤逻辑吃掉;最后看该按键的行峰、列峰比例,如果比例够但总能量低,降低total_floor而不是降低比例阈值。

5.3 旁边说话导致串键

现象:录音里有人说话时,输出多出几个数字,且往往落在 1、4、7 或 2、5、8 上。原因是语音的基频和共振峰可能落在行频附近,单靠能量比例不能完全区分。解决时不要只做单帧判决,要求连续多帧命中同一组行频、列频才输出按键;再把行峰和列峰的能量比纳入检查,偏离太远的组合丢掉。九键场景还可以利用业务约束:如果前后文只允许数字,A 到 D 直接过滤;如果按键之间存在明显静音,用gap_ms把语音段和按键段分开。阈值不要为了压语音调到有效键都漏掉,先保证按键召回,再用连续性去假。

5.4 采样率或声道不对导致全错

现象:同一段音频在有的机器上能出数字,换一台机器全是乱码;或者输出长度明显不对。原因通常是 WAV 头里的采样率和实际数据不一致,或者双声道被交错当成单声道读取,导致波形被拉伸或撕裂。解决时先把read_wav_mono的通道数、采样位宽、采样率打印出来,和文件属性对一遍;双声道先取一轨或平均,不要直接按单声道解释;24 bit、浮点 WAV 先转 16 bit PCM。电话录音常见 8 kHz 单声道,但手机录出来可能是 44.1 kHz 双声道,进检测前统一格式,比在算法里打补丁省事。

5.5 同一个键重复输出很多次

现象:按一次 5,输出“55555”或“55”。原因是连续多帧都判成同一个键,去抖逻辑只做了“相同键合并”,但合并窗口被静音帧打断,或者gap_ms太小,按键内部的幅度起伏被当成两次按键。解决时把连续相同键的合并条件改成“中间没有超过gap_ms的静音”,而不是“必须连续帧”;再把min_key_ms提高到 40 ms 左右,过滤按键边缘的短片段。长按语义单独放在业务层,不要在检测层把长按拆成多个事件。这个坑我踩过,血泪经验是:先去抖,再谈长按,顺序反了会多出一堆后悔药。

6. 进阶验证:合成九键音频做回归,再谈批量处理的置信度

调完参数不要只拿一条录音验收。更稳的做法是用合成音把九键全排列跑一遍,确认频点表、映射和去抖没有系统性错误,再拿真实录音抽检。合成音不需要复杂库,按行频、列频叠加,加 5 ms 淡入淡出,保存成 16 bit WAV 即可。下面这段代码只合成单个按键,循环拼接就能得到“1234567890*#”的回归样本。

import numpy as np def synth_key(key, fs=8000, ms=80, amp=0.3): row, col = KEY_TO_FREQ[key] t = np.arange(int(fs * ms / 1000)) / fs x = amp * (np.sin(2 * np.pi * row * t) + np.sin(2 * np.pi * col * t)) fade = max(1, int(fs * 0.005)) x[:fade] *= np.linspace(0, 1, fade) x[-fade:] *= np.linspace(1, 0, fade) return x def synth_sequence(seq, fs=8000, key_ms=80, gap_ms=60): gap = np.zeros(int(fs * gap_ms / 1000), dtype=np.float32) parts = [] for k in seq: parts.append(synth_key(k, fs=fs, ms=key_ms).astype(np.float32)) parts.append(gap) return np.concatenate(parts)

key_ms控制按键持续时间,gap_ms控制按键间隔,amp控制幅度。合成音跑通只能证明链路没有低级错误,不能证明真实录音一定稳,所以还要看置信度:把每帧的行峰比例、列峰比例、持续帧数记下来,输出按键时带一个最小置信度。批量处理时先看低置信度片段,往往能快速定位是阈值太松还是录音质量太差。从那以后我每次改阈值,都先用合成音跑一遍九键全排列,再拿真实录音抽检;这套习惯帮我少返工很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询