基于 Python 的 DTMF 双音多频信号识别实验

一、实验目的

掌握 DTMF(双音多频)信号的基本原理,利用 Python 实现信号的生成与识别,理解 Goertzel 算法相比 FFT 在特定频点检测上的优势。

二、实验原理

1. DTMF 编码规则

双音多频信号 (英语:Dual-Tone Multi-Frequency,简称:DTMF),电话系统中电话机交换机之间的一种信令,最常用于拨号时发送被叫号码。不过双音多频的发明,除了缩短拨号时间,也扩展了拨号之外的功能,例如自动总机交互式语音应答

在双音多频信号普及之前,电话系统中使用一连串的断续脉冲来传送被叫号码,称为脉冲拨号。脉冲拨号需要电信局中的操作员手工完成长途接续。

参考网页:https://zh.wikipedia.org/wiki/%E5%8F%8C%E9%9F%B3%E5%A4%9A%E9%A2%91

2. 识别方法

本实验分别使用 FFT 全频谱分析法 和 Goertzel 指定频点检测法 对 DTMF 信号进行识别,并比较两种方法的特点。

2.1 方法一:FFT 全频谱识别法

FFT 法的思路是:

对信号做 FFT,得到整个频谱;

在低频范围 650Hz 到 1000Hz 内找最大峰;

在高频范围 1100Hz 到 1700Hz 内找最大峰;

把检测到的频率贴近到标准 DTMF 频率;

查表得到按键。

它的特点是:

先算完整频谱,再从频谱中找目标频率。

2.2 方法二:Goertzel 指定频点识别法

FFT 会算出所有频点,但 DTMF 只关心 8 个固定频率:697, 770, 852, 941,1209, 1336, 1477, 1633,所以我们其实没必要算完整频谱。

Goertzel 算法可以理解成:

给某一个指定频率做一个检测器,只判断这个频率强不强。

对 8 个标准频率分别计算能量:然后:低频组取最大;高频组取最大;查表得到按键。

它的特点是:

不算完整频谱,只算关心的几个频率。这就比 FFT 更适合 DTMF 这种"目标频率固定"的任务。

2.3 方法三: 直接算 8 个 DFT 点

DFT 公式,"可以只计算 8 个点",:DFT 的每一个频点 𝑋(𝑘) 是可以单独算的,不是非要一次性全算完。

(1).计算八个点:

DFT 公式一眼看出来:

频率和K:

FFT 频谱里大概就在 k=385 和 k=668 附近有峰。

(2) Goertzel 和"只算 8 个 DFT 点"是什么关系?

(3)运算量

(4)嵌入式系统

嵌入式系统,比如单片机、电话机芯片、小型语音设备,通常有几个限制:RAM 很小;CPU 不强;要实时处理信号;

从 DFT 公式可以看出,DTMF 信号的频率是固定的,只需要检测 697、770、852、941、1209、1336、1477、1633 这 8 个频率。因此没有必要计算完整频谱,只需要计算这 8 个目标频率上的能量即可。

Goertzel 算法可以看作是对单个 DFT 频点的高效递推计算。它不需要保存完整频谱,只需要为每个目标频率保存两个状态变量和一个系数,因此特别适合内存和算力有限的嵌入式系统。

(5)方法比较

有些 DTMF 频率不一定刚好落在整数 k

3.加窗

实际 DTMF 信号是有限长度的。比如只截取 0.5 秒,这相当于把原始连续信号"硬切"了一段下来。硬切会带来频谱泄漏。

本实验在识别前加入汉宁窗。由于实际信号是有限长度截取的,截断会带来频谱泄漏。汉宁窗可以减小信号两端的突变,从而降低旁瓣泄漏,使频率检测更加稳定。

加窗后信号幅度会有所下降,但本实验主要比较不同频率能量的相对大小,因此不影响按键识别结果。

三.任务拆分:

观察按键的频率,从而解出按键的内容

任务 1:合成一个 DTMF 信号

任务 2:对它做 FFT,画频谱

目标:把任务 1 的信号送进 FFT,画出频谱图。

需要回答的问题:频率轴怎么算?要不要加窗?要不要只画正频率?

1.频率轴

freq = np.fft.fftfreq(N, 1/f_s)

或者手写:

freq = np.arange(N) * f_s / N

2.要不要加窗?

DTMF 频率都是固定的,但你的信号长度大概率不是这些频率的整数周期,会有泄漏,建议加汉宁窗。

任务 3:自动找出两个峰值频率

目标:写代码自动找出频谱上最高的两个尖峰。

思路

在低频段(500~1000Hz)找最大的峰 → 这是"行频率"

在高频段(1100~1700Hz)找最大的峰 → 这是"列频率"

为什么要分两段找?因为 DTMF 的设计就是"一个低频 + 一个高频",分段找不会找错。

任务 4:把找到的频率匹配到最近的标准频率

目标:因为 FFT 有误差,找出来可能是 769.5Hz,要"贴近"到 770Hz。

思路

标准低频: [697, 770, 852, 941]

标准高频: [1209, 1336, 1477, 1633]

low_match = 距离 low_freq 最近的标准低频

high_match = 距离 high_freq 最近的标准高频

任务 5:查表反推按键

目标:从 (770, 1336) 反推出 "5"。

思路

用一个二维字典:

DTMF_TABLE = {

(697, 1209): '1', (697, 1336): '2', (697, 1477): '3', (697, 1633): 'A',

(770, 1209): '4', (770, 1336): '5', (770, 1477): '6', (770, 1633): 'B',

(852, 1209): '7', (852, 1336): '8', (852, 1477): '9', (852, 1633): 'C',

(941, 1209): '*', (941, 1336): '0', (941, 1477): '#', (941, 1633): 'D',

}

按键 = DTMF_TABLE[(low_match, high_match)]

任务6:Goertzel 指定频点识别法

观察DFT定义,老师提示"只需要检测 8 个标准频率",意思是:不用看完整频谱,只要问:这 8 个频率里,哪两个最明显?

Goertzel 算法是用来检测某个指定频率成分强不强的。DTMF 只需要检测 8 个标准频率,所以不用算完整 FFT。

公式:

准备公式是根据目标频率生成检测器参数;递推公式是递推处理信号;结尾公式是把最后两个状态换算成该频率上的能量。

1.准备公式:

coeff 是后面递推公式里用的系数。它的作用可以理解成:coeff 决定这个检测器主要对哪个频率敏感。检测 697Hz,有一个 coeff。检测 770Hz,又是另一个 coeff。

2.递推公式:

它每次读一个新样本 𝑥(𝑛),然后更新一个中间变量 𝑠𝑛。

这个递推公式里有三个东西:𝑥(𝑛),当前新来的信号点,𝑠𝑛−1,上一次的状态,𝑠𝑛−2,上上次的状态,所以它有"记忆"。它不是只看当前一个点,而是一边读信号,一边把前面的影响累计起来。

如果输入信号里真的有目标频率,比如 770Hz,而你现在的检测器也是 770Hz,那么这个递推过程会越来越"对上节奏",最后数值会变大。如果输入信号里没有 770Hz,那它就不太能积累起来,最后数值较小。

3.结尾公式

这一步是把递推过程中留下的两个状态:𝑠𝑁−1,𝑠𝑁−2,转换成这个频率上的"强度分数"。这个值越大,说明这个频率越明显。

注意,它不是整段信号的能量,而是:目标频率 𝑓上的能量/强度。

说明:

为什么最后算能量,不直接算 X(k)?

因为识别 DTMF 只关心频率强弱,不关心相位。X(k) 是复数,包含幅度和相位;我们只需要幅度平方,所以直接算 |X(k)|^2,还省掉开根号和复数计算。

四.加窗实验:

用一个两端平滑归零的窗函数替代矩形窗,使信号两端自然过渡到零,消除截断处的突变,从而压低旁瓣、减少泄漏。

1.概念梳理

理想情况------录一段永远不停的 20kHz 正弦波:

频谱,一根直立的"针",正好立在 20kHz 处,其它地方全是 0。

现实------只能录有限段,

真实情况是:你只录了 0.1ms 这一段:等价的数学操作是:原信号 × 一个矩形

时域相乘⟺频域卷积,而矩形窗的频谱:

特征:主瓣:中间那个高大的尖峰,旁瓣:两边像波浪一样的小山丘,永远不会衰减到 0,会一直延伸出去

综上,

频谱泄漏:原本只该出现在 20kHz 的能量,"漏"到了周围的频率上。

2.实验代码

复制代码
import numpy as np
from matplotlib import pyplot as plt

# ========== 参数 ==========
F_SMP = 1e6            # 采样率 1MHz
F_SIG = 20e3           # 信号 20kHz
T_SIM = 2.25 / F_SIG   # 非整周期:2.25个周期
T_SMP = 1 / F_SMP

x = np.arange(0, T_SIM, T_SMP)
y = np.sin(2 * np.pi * F_SIG * x)
N = len(x)

# ========== 图1:时域四种窗对比 ==========
fig1, ax1 = plt.subplots(2, 2, figsize=(10, 6))

ax1[0][0].plot(x*1000, y)
ax1[0][1].plot(x*1000, y * np.hamming(N))
ax1[1][0].plot(x*1000, y * np.blackman(N))
ax1[1][1].plot(x*1000, y * np.hanning(N))

ax1[0][0].set_title("Original (Rectangular)")
ax1[0][1].set_title("Hamming")
ax1[1][0].set_title("Blackman")
ax1[1][1].set_title("Hanning")

for a in ax1.flat:
    a.set_xlabel("Time (ms)")
    a.set_ylabel("Amplitude")

fig1.tight_layout()

# ========== 图2:频域四种窗对比 ==========
NFFT = 8192  # 零填充到 8192 点,让频谱更细腻

fig2, ax2 = plt.subplots(2, 2, figsize=(10, 6))

windows = [
    ("Rectangular", np.ones(N)),
    ("Hamming",     np.hamming(N)),
    ("Blackman",    np.blackman(N)),
    ("Hanning",     np.hanning(N)),
]

for i, (name, w) in enumerate(windows):
    y_win = y * w
    Y = np.abs(np.fft.fft(y_win, n=NFFT))  # 零填充到 NFFT 点
    freq = np.arange(NFFT) * F_SMP / NFFT
    half = NFFT // 2

    # 转 dB(以最大值为参考)
    Y_db = 20 * np.log10(Y[:half] / np.max(Y[:half]) + 1e-12)

    row, col = divmod(i, 2)
    ax2[row][col].plot(freq[:half] / 1e3, Y_db)
    ax2[row][col].set_title(f"{name}")
    ax2[row][col].set_xlabel("Freq (kHz)")
    ax2[row][col].set_ylabel("Magnitude (dB)")
    ax2[row][col].set_xlim(0, 60)
    ax2[row][col].set_ylim(-80, 5)
    ax2[row][col].axvline(x=20, color='r', linestyle='--', alpha=0.5, label='20kHz')
    ax2[row][col].legend()
    ax2[row][col].grid(True)

fig2.tight_layout()
plt.show()

3.实验结果分析

左上:矩形窗(Rectangular)

① 主瓣峰在红色虚线(20kHz)处

频谱的最高点确实在 20kHz 附近。这说明"信号确实是 20kHz"。

② 主瓣很宽(大约从 10kHz 到 30kHz)

这是因为你只有 2.25 个周期!信号太短 → 主瓣就胖。

信号越短 → 主瓣越胖 → 频率越"看不准"

③ 主瓣两边有波浪------旁瓣

看 0~10kHz 和 30~60kHz 区域,有很多小波浪,大约在 -20dB 左右。

-20dB 的旁瓣 = 主瓣的 1/10 能量。这就是泄漏------本不该有的频率成分。

核心结论

压低旁瓣(减少泄漏)⟷主瓣变宽(分辨率降低),这是一个不可兼得的取舍。

​需要分辨两个接近的频率 → 选矩形窗(主瓣窄)

需要检测微弱信号(不被强信号的旁瓣淹没) → 选布莱克曼窗(旁瓣低)

通用场景 → 选汉宁窗(折中)

4.问题记录

五.DTMF 双音多频信号生成与识别实验

本实验完成了 DTMF 信号的生成与识别。生成时根据按键表查出对应的低频和高频,将两个正弦波叠加得到按键信号。识别时没有使用完整 FFT,而是采用 Goertzel 算法,只检测 8 个 DTMF 标准频率上的能量。

相比 FFT,Goertzel 更适合这种只关心少数固定频率的场景。检测前加入汉宁窗可以减小有限长截取带来的频谱泄漏,使识别更加稳定。

1.代码

复制代码
#!/usr/bin/env python3
import numpy as np

# ============================================================
# 1. DTMF 频率表
# ============================================================

FS = 8000
#每秒采 8000 个点。这是电话系统标准

LOW_FREQS  = [697, 770, 852, 941]
HIGH_FREQS = [1209, 1336, 1477, 1633]
#行 = 低频,列 = 高频

DTMF_TABLE = {
    (697, 1209): '1', (697, 1336): '2', (697, 1477): '3', (697, 1633): 'A',
    (770, 1209): '4', (770, 1336): '5', (770, 1477): '6', (770, 1633): 'B',
    (852, 1209): '7', (852, 1336): '8', (852, 1477): '9', (852, 1633): 'C',
    (941, 1209): '*', (941, 1336): '0', (941, 1477): '#', (941, 1633): 'D',
}

# 反向表:由按键查频率
KEY_TO_FREQ = {}
# 遍历原始字典
for freq_pair, key in DTMF_TABLE.items():
    # 键值互换,存入新字典
    KEY_TO_FREQ[key] = freq_pair


# ============================================================
# 2. 生成 DTMF 信号
# ============================================================

def generate_dtmf(key, duration=0.5, fs=FS):
    """
    生成指定按键的 DTMF 信号。

    key: 按键,例如 '5'
    duration: 信号时长,单位秒
    fs: 采样率
    """
    if key not in KEY_TO_FREQ:
        raise ValueError(f"无效按键:{key}")
#raise:主动抛出错误,ValueError:值错误(Python 标准错误类型,表示传入的值不合法),
#Python出错直接 raise 抛异常,上层 try-except 接住。

    f_low, f_high = KEY_TO_FREQ[key]

    # 采样点数
    N = int(fs * duration)

    # 每个采样点对应的时间:t[n] = n / fs
    t = np.arange(N) / fs

    # 两个正弦波叠加
    signal = np.sin(2 * np.pi * f_low * t) + np.sin(2 * np.pi * f_high * t)

    return t, signal


# ============================================================
# 3. 方法一:FFT 全频谱识别法
# ============================================================

def decode_dtmf_fft(signal, fs=FS, use_window=True):
    """
    使用 FFT 识别 DTMF 信号。
    思路:先算完整频谱,再在低频区和高频区找峰值。
    """
    signal = np.asarray(signal, dtype=float)
#把输入的信号强制变成 NumPy 数组(防止传入的不是数组),
#后面要做:FFT 快速傅里叶变换,乘法运算,频谱计算,这些只能用 NumPy 数组,普通 Python 列表做不了。
#把数据类型强制变成 浮点数 float,防止整数运算出错

    N = len(signal)

    # 加汉宁窗,减小频谱泄漏
    if use_window:
        window = np.hanning(N)
        signal = signal * window

    # 计算 FFT 幅度谱
    spectrum = np.abs(np.fft.fft(signal)) / N

    # 构造频率轴:freqs[k] = k * fs / N
    freqs = np.arange(N) * fs / N

    # 只在 DTMF 低频范围和高频范围内找峰值
    low_mask  = (freqs >= 650) & (freqs <= 1000)
    high_mask = (freqs >= 1100) & (freqs <= 1700)
    #布尔掩码 mask, low_mask  = (freqs >= 650) & (freqs <= 1000)得到一个布尔数组:
    # [False, False, ..., True, True, ..., False],表示哪些频率点在 650Hz 到 1000Hz 之间。
 
    # 在低频范围内找最大幅度对应的频率
    f_low_detected = freqs[np.argmax(spectrum * low_mask)]
    f_high_detected = freqs[np.argmax(spectrum * high_mask)]
    #np.argmax()返回最大值所在的下标。找到低频范围内频谱最大的位置,然后取出这个位置对应的频率。
    
    # 把检测到的频率贴近到标准 DTMF 频率
    f_low_match = min(LOW_FREQS, key=lambda f: abs(f - f_low_detected)) 
    f_high_match = min(HIGH_FREQS, key=lambda f: abs(f - f_high_detected))
    
    #在 LOW_FREQS 里面找一个最接近 f_low_detected 的标准频率。比如检测到:f_low_detected = 696,标准低频组是:[697, 770, 852, 941],最接近 696 的是 697。
    #所以结果是:f_low_match = 697
    #lambda f: abs(f - f_low_detected)临时小函数,类比普通函数:
    #def distance(f):    return abs(f - f_low_detected)

    # 查表得到按键
    key = DTMF_TABLE[(f_low_match, f_high_match)]

    info = {
        "f_low_detected": f_low_detected,
        "f_high_detected": f_high_detected,
        "f_low_match": f_low_match,
        "f_high_match": f_high_match,
    }

    return key, info


# ============================================================
# 4. Goertzel 算法:只计算一个目标频率的能量
# ============================================================

def goertzel(signal, f_target, fs=FS):
    """
    计算 signal 在目标频率 f_target 上的能量。
    """
    omega = 2 * np.pi * f_target / fs
    coeff = 2 * np.cos(omega)

    s_prev = 0.0
    s_prev2 = 0.0

    for x in signal:
        s = x + coeff * s_prev - s_prev2
        s_prev2 = s_prev
        s_prev = s

    power = s_prev2**2 + s_prev**2 - coeff * s_prev * s_prev2

    return power


# ============================================================
# 5. 方法二:Goertzel 指定频点识别法
# ============================================================

def decode_dtmf_goertzel(signal, fs=FS, use_window=True):
    """
    使用 Goertzel 算法识别 DTMF 信号。
    思路:只计算 8 个标准 DTMF 频率上的能量。
    """
    signal = np.asarray(signal, dtype=float)
    N = len(signal)

    # 加汉宁窗,减小截断造成的泄漏
    if use_window:
        window = np.hanning(N)
        signal = signal * window

    # 计算低频组 4 个频率的能量
    low_powers = np.array([goertzel(signal, f, fs) for f in LOW_FREQS])
#[表达式  for 变量 in 可迭代对象],快速生成列表,每次循环都调用函数,传入当前遍历到的频率f,计算这个频率的信号能量。每一轮返回一个能量数值。
#整个中括号运行完得到一个普通 Python 列表,存 4 个低频能量

    # 计算高频组 4 个频率的能量
    high_powers = np.array([goertzel(signal, f, fs) for f in HIGH_FREQS])

    # 找到能量最大的低频和高频
    f_low_match = LOW_FREQS[np.argmax(low_powers)]
    f_high_match = HIGH_FREQS[np.argmax(high_powers)]

    # 查表得到按键
    key = DTMF_TABLE[(f_low_match, f_high_match)]

    info = {
        "low_powers": low_powers,
        "high_powers": high_powers,
        "f_low_match": f_low_match,
        "f_high_match": f_high_match,
    }

    return key, info


# ============================================================
# 6. 测试
# ============================================================

if __name__ == '__main__':

    test_keys = ['1', '5', '0', '#']

    for test_key in test_keys:
        t, signal = generate_dtmf(test_key)

        decoded_fft, info_fft = decode_dtmf_fft(signal, use_window=True)
        decoded_goertzel, info_goertzel = decode_dtmf_goertzel(signal, use_window=True)

        print("\n==============================")
        print(f"原始按键:{test_key}")

        print(
            f"FFT 法识别:{decoded_fft},"
            f"检测频率约为 {info_fft['f_low_detected']:.1f} Hz + "#字典取值,['f_low_detected'] 取字典里的检测到的低频值
            f"{info_fft['f_high_detected']:.1f} Hz,"
            f"匹配到 {info_fft['f_low_match']} Hz + {info_fft['f_high_match']} Hz"
        )
        print(
            f"Goertzel 法识别:{decoded_goertzel},"
            f"匹配到 {info_goertzel['f_low_match']} Hz + "
            f"{info_goertzel['f_high_match']} Hz"
        )

        if decoded_fft == test_key and decoded_goertzel == test_key:
            print("✓ 两种方法均识别成功")
        else:
            print("✗ 存在识别失败")

2.运行结果

说明:关于时间轴和频率轴

相关推荐
wuxinyan1232 小时前
工业级大模型学习之路012:RAG 零基础入门教程(第七篇):高级检索架构(解决分块不合理问题)
人工智能·学习·rag
xuhaoyu_cpp_java3 小时前
SpringMVC学习(五)
java·开发语言·经验分享·笔记·学习·spring
炽烈小老头3 小时前
【每天学习一点算法 2026/05/15】被围绕的区域
学习·算法·深度优先
秋雨梧桐叶落莳3 小时前
iOS——ZARA仿写项目
学习·macos·ios·objective-c·cocoa
KKei16383 小时前
Flutter for OpenHarmony 学习视频播放器技术文章
学习·flutter·华为·音视频·harmonyos
weixin_428005305 小时前
C#调用 AI学习从0开始-第1阶段(基础与工具)-第2天Prompt工程基础
人工智能·学习·c#·prompt
爱喝水的鱼丶5 小时前
SAP-ABAP:新手入门篇——从0到1写出你的第一个ABAP Hello World程序并完成调试运行
运维·服务器·数据库·学习·sap·abap
red_redemption5 小时前
自由学习记录(186)
学习
人力资源分享库6 小时前
华恒智信助力国有行业完成重构价值分配体系
学习