06 通话录音
作者:拓毅恒 | 最后修改:2026-05-18
一、通话录音概述
- 通话录音:在通话过程中实时录制通话音频数据并保存到存储设备的功能,可用于通话记录保存、语音取证等场景。
- PCM 格式:原始音频数据格式,未经压缩,音质无损,但需要专用播放器播放。
- AMR 格式:支持将录音文件保存 amr 格式,压缩率高,文件小,手机播放器可直接播放。
- 上行数据:本地麦克风采集的声音数据,包含本地说话声音和对方声音数据。
- 下行数据:从网络接收的对方声音数据。
二、演示功能概述
本章节将演示如何使用 Air8000 实现呼入自动接听和通话录音功能:
- 呼入自动接听:来电响铃 2 声后自动接听
- 通话录音:通话接通后自动开始录音,对方挂断后自动停止
- SD 卡存储:录音文件保存到 SD 卡,支持自动挂载和空间检测
- AMR 转码(可选):通话结束后可将 PCM 文件离线转码为 AMR 格式,压缩率约 5-10%
三、准备硬件环境
参考:Air8000 硬件环境清单,准备好硬件环境,下面 3.1 和 3.2 两种环境二选一即可。
3.1 Air8000 整机开发板
Air8000 开发板提供了丰富的音频接口资源,可通过开发板上的音频接口进行连接和测试。
由于通话录音文件大小一般都比较大,无法存入内存中,所以测试此 demo 必须接入 SD 卡。

3.2 Air8000 核心板 + AirAUDIO_1010 配件板 + AirMICROSD_1010 配件板
Air8000 核心板集成了移动网络通信功能。

AirAUDIO_1010 配件板

注意:如果搭配 AirAUDIO_1010 扩展板测试,需将 AirAUDIO_1010 扩展板中 PA 开关拨到 OFF,让软件控制 PA,避免 pop 音
Air8000 核心板和 AirAUDIO_1010 配件板的硬件接线方式为:
| Air8000核心板 | AirAUDIO_1010配件板 |
| 22/I2S_MCLK | I2S_MCLK |
| 18/I2S_BCK | I2S_BCK |
| 19/I2S_LRCK | I2S_LRCK |
| 20/I2S_DIN | I2S_DIN |
| 21/I2S_DOUT | I2S_DOUT |
| 80/I2C_SCL | I2C_SCL |
| 81/I2C_SDA | I2C_SDA |
| 82/GPIO17 | PA_EN |
| 83/GPIO16 | 8311_EN |
| VBAT | VCC |
| GND | GND |
AirMICROSD_1010 配件板
由于通话录音文件大小一般都比较大,无法存入内存中,所以测试此 demo 必须接入 SD 卡。使用核心板测试需要外挂 AirMICROSD_1010 配件板来使用 SD 卡

Air8000 核心板和 AirMICROSD_1010 配件板的硬件接线方式为:
| **Air8000核心板** | **AirMICROSD_1010** |
| GND(任意) | GND |
| VDD_EXT | 3V3 |
| GPIO12/SPI1_CS | spi_cs |
| SPI1_CLK | spi_clk,时钟 |
| SPI1_MOSI | spi_mosi,主机输出,从机输入 |
| SPI1_MISO | spi_miso,主机输入,从机输出 |
四、准备软件环境
4.1 工具 + 内核固件 + 脚本
1、Luatools 下载调试工具
2、本demo开发测试时使用的固件为Air8000 V2016 版本固件(请选择支持 codec功能固件),所以你如果要测试本demo时,可以直接使用最新版本支持 codec 功能的内核固件;如果发现最新版本的内核固件测试有问题,可以使用我们开发本demo时使用的内核固件版本来对比测试;
3、luatos 需要的脚本和资源文件
- 脚本和资源文件点我浏览所有文件
- 准备好软件环境之后,接下来查看如何使用 LuaTools 烧录软件,将本篇文章中演示使用的项目文件烧录到 Air8000 核心板中,或者查看 Air8000 系列整机开发板使用手册 V2.0,将本篇文章中演示使用的项目文件烧录到 Air8000 开发板中。
4、合宙 LuatIO 工具(GPIO 复用初始化配置)使用说明。
5、lib 脚本文件:使用 Luatools 烧录时,勾选 添加默认 lib 选项,使用默认 lib 脚本文件;
4.2 API 介绍
这里仅介绍本篇文档所使用的 API,详情请查看:API - cc
cc.record(enable, up1, up2, down1, down2)
启用或关闭通话录音功能
cc.on(event, callback)
注册通话事件回调函数
cc.lastNum()
获取来电号码
cc.accept(id)
接听来电
fatfs.mount(type, path, spi_id, cs_pin, speed)
挂载 SD 卡
五、代码演示
5.1 通话录音应用
--[[
@module cc_record_save
@summary 通话录音功能模块(PCM格式,可选AMR转码)
@version 1.1
@date 2026.5.15
@author 拓毅恒
@usage
本模块提供以下功能:
1. 实现呼入自动接听功能(响2声后自动接听)
2. 支持通话录音功能,保存为PCM格式
3. 可选:通话结束后将PCM转码为AMR格式(需手动开启)
4. 录音文件仅支持SD卡存储
功能说明:
- 来电自动接听:响铃2声后自动接听来电
- 通话录音:自动开始录音,对方挂断后停止录音
- AMR转码(可选):默认关闭,如需AMR格式请取消注释相关代码
录音功能特性:
- 录音文件保存为PCM格式:/sd/record_call.pcm
- 可选AMR转码:/sd/record_call.amr(需手动开启)
- 只保存上行数据(包含本地声音和网络回声)
- 下行数据自动跳过,避免重复存储
- 支持SD卡自动挂载和空间检测
- 根据通话质量自动选择AMR-NB(8KHz)或AMR-WB(16KHz)
使用方法:
1. 默认只生成PCM文件:/sd/record_call.pcm
2. 如需AMR格式,找到 stop_call_recording() 函数,取消 convert_pcm_to_amr() 的注释
注意事项:
1. 本模块仅支持呼入自动接听功能
2. 录音文件仅支持SD卡存储,必须插入SD卡才能使用录音功能
3. PCM文件为原始音频格式,可用Audacity等工具播放
4. AMR文件为通用格式,手机播放器可直接播放
]]
-- 引入音频设备模块
local audio_drv = require "audio_drv"
local exaudio = require "exaudio"
local codec = require "codec"
-- ====================== 配置区域 ======================
-- 全局状态变量
local call_counter = 0 -- 响铃计数器
local caller_number = "" -- 来电号码
-- SD卡挂载路径和录音文件保存路径
local SD_MOUNT_PATH = "/sd"
local RECORD_FILE_PATH = SD_MOUNT_PATH .. "/record_call.pcm" -- 录音文件路径(PCM原始格式)
local RECORD_AMR_FILE_PATH = SD_MOUNT_PATH .. "/record_call.amr" -- 转码后的AMR文件路径
-- Air8000整机开发板上TF卡的的pin_cs为gpio20,spi_id为1.请根据实际硬件修改
local spi_id = 1
local pin_cs = 20
-- Air8000核心板CS为IO12
-- local pin_cs = 12
-- 录音功能相关函数
local is_recording_to_file = false -- 录音状态标志:true表示正在录音到文件
local record_file = nil -- 录音文件句柄
local record_start_time = 0 -- 录音开始时间戳(毫秒)
local record_duration = 0 -- 录音时长(秒)
local record_sample_rate = 8000 -- 录音采样率
-- 注意:缓冲区大小必须是640的倍数
-- 原因:VoLTE通话音频数据以20ms为帧单位,8KHz采样率每帧320字节,16KHz采样率每帧640字节
-- 640是两者的最小公倍数,确保缓冲区能整除存放整数个音频帧
--
-- 当前配置计算:
-- BUFFER_SIZE = 48000 字节
-- 16KHz模式:48000 / 640 = 75 帧 = 75 * 20ms = 1500ms = 1.5 秒
-- 8KHz模式:48000 / 320 = 150 帧 = 150 * 20ms = 3000ms = 3 秒
--
-- 双缓冲机制:满时触发回调,处理完用:del()清空
local BUFFER_SIZE = 48000 -- 缓冲区大小不能太小,否则保存过程中有可能会溢出造成死机
-- ====================== sd卡挂载函数 ======================
-- 挂载SD卡
local function mount_sd_card()
log.info("SD卡", "开始挂载SD卡")
-- 检查SD卡是否已挂载
if io.exists(SD_MOUNT_PATH) then
log.info("SD卡", "SD卡已挂载:", SD_MOUNT_PATH)
return true
end
-- 初始化SPI接口
-- 打开ch390供电脚(使用开发板需要打开此注释)
gpio.setup(140, 1, gpio.PULLUP)
--上拉ch390使用spi的cs引脚避免干扰(使用开发板需要打开此注释)
gpio.setup(12,1)
-- 初始化SPI接口
spi.setup(spi_id, nil, 0, 0, 8, 2000000)
-- 设置片选引脚为高电平
gpio.setup(pin_cs, 1)
-- 尝试挂载SD卡
local mount_ok, mount_err = fatfs.mount(fatfs.SPI, SD_MOUNT_PATH, spi_id, pin_cs, 24 * 1000 * 1000)
if mount_ok then
log.info("SD卡", "SD卡挂载成功:", SD_MOUNT_PATH)
-- 获取SD卡空间信息
local data, err = fatfs.getfree(SD_MOUNT_PATH)
if data then
log.info("SD卡空间信息", json.encode(data))
else
log.warn("获取SD卡空间信息失败", err)
end
return true
else
log.error("SD卡", "SD卡挂载失败:", mount_err)
return false
end
end
-- ====================== 录音功能 ======================
-- 创建音频数据缓冲区
local up1 = zbuff.create(BUFFER_SIZE,0) -- 上行数据保存区1
local up2 = zbuff.create(BUFFER_SIZE,0) -- 上行数据保存区2
local down1 = zbuff.create(BUFFER_SIZE,0) -- 下行数据保存区1
local down2 = zbuff.create(BUFFER_SIZE,0) -- 下行数据保存区2
-- 打开录音文件
local function open_record_file()
-- 先挂载SD卡
if not mount_sd_card() then
log.error("录音文件", "SD卡挂载失败,无法进行录音")
return false
end
log.info("录音文件", "SD卡挂载成功,录音文件将保存到SD卡")
-- 关闭已打开的文件
if record_file then
record_file:close()
record_file = nil
end
-- 删除旧录音文件
if io.exists(RECORD_FILE_PATH) then
os.remove(RECORD_FILE_PATH)
log.info("录音文件", "删除旧录音文件:", RECORD_FILE_PATH)
end
-- 创建录音文件
record_file = io.open(RECORD_FILE_PATH, "wb")
if record_file then
log.info("录音文件", "创建录音文件成功:", RECORD_FILE_PATH)
record_start_time = mcu.ticks()
is_recording_to_file = true
return true
else
log.error("录音文件", "创建录音文件失败:", RECORD_FILE_PATH)
return false
end
end
-- 计算时间差(毫秒)
local function calc_time_diff_ms(start_tick, end_tick)
-- 检查溢出:Lua中超过0x7fffffff会变成负数
if (start_tick > 0 and end_tick < 0) or (start_tick < 0 and end_tick > 0) then
log.warn("时间计算", "mcu.ticks()溢出,无法准确计算时长")
return nil
end
local diff_ticks = end_tick - start_tick
local hz = mcu.hz()
if hz == 0 then
hz = 1000 -- 默认1ms一个tick
end
return (diff_ticks * 1000) / hz
end
-- 关闭录音文件
local function close_record_file()
if record_file then
record_file:close()
record_file = nil
local file_size = io.fileSize(RECORD_FILE_PATH)
local duration_ms = calc_time_diff_ms(record_start_time, mcu.ticks())
if duration_ms then
record_duration = duration_ms / 1000 -- 转换为秒
log.info("录音文件", "录音完成", "文件大小:", file_size, "字节", "录音时长:", string.format("%.1f", record_duration), "秒", "路径:", RECORD_FILE_PATH)
else
log.info("录音文件", "录音完成", "文件大小:", file_size, "字节", "录音时长: 溢出无法计算", "路径:", RECORD_FILE_PATH)
end
is_recording_to_file = false
record_start_time = 0
record_duration = 0
end
end
-- 写入录音数据到文件
local function write_record_data(buff, is_downlink)
if not record_file or not is_recording_to_file then
return false
end
-- 保存数据
if not is_downlink then
local data_size = buff:used()
if data_size > 0 then
local start_time = mcu.ticks()
-- 写入数据到文件
record_file:write(buff:query())
local end_time = mcu.ticks()
local write_time_ms = calc_time_diff_ms(start_time, end_time)
if write_time_ms and write_time_ms > 0 then
local write_speed = data_size / (write_time_ms / 1000) -- 字节/秒
log.info("录音写入",
"数据大小:", data_size, "字节,",
"写入耗时:", string.format("%.2f", write_time_ms), "ms,",
"写入速度:", string.format("%.2f", write_speed / 1024), "KB/s")
else
log.info("录音写入",
"数据大小:", data_size, "字节,",
"写入耗时: 溢出无法计算")
end
return true
end
else
-- 下行数据不保存,只记录日志
-- 写入下行数据会导致文件内有回声
local data_size = buff:used()
if data_size > 0 then
log.info("录音写入", "下行数据跳过", "数据大小:", data_size, "字节")
end
end
return false
end
-- 音频数据回调函数
local function recordCallback(is_dl, point)
if is_dl then
log.info("录音", "下行数据,位于缓存", point+1, "缓存1数据量", down1:used(), "缓存2数据量", down2:used())
-- 处理下行数据
if point == 0 then
write_record_data(down1, true)
down1:del() -- 清空缓冲区
else
write_record_data(down2, true)
down2:del() -- 清空缓冲区
end
else
log.info("录音", "上行数据,位于缓存", point+1, "缓存1数据量", up1:used(), "缓存2数据量", up2:used())
-- 处理上行数据
if point == 0 then
write_record_data(up1, false)
up1:del() -- 清空缓冲区
else
write_record_data(up2, false)
up2:del() -- 清空缓冲区
end
end
-- 获取通话质量,用于后续AMR转码
local quality = cc.quality()
if quality then
-- 根据通话质量确定采样率
if quality.amr_wb and quality.amr_wb == 1 then
record_sample_rate = 16000 -- AMR-WB模式,16KHz
else
record_sample_rate = 8000 -- AMR-NB模式,8KHz
end
log.info("通话质量", quality)
end
end
-- 启用通话录音
local function enableRecording()
cc.record(true, up1, up2, down1, down2)
cc.on("record", recordCallback)
log.info("cc_app", "通话录音已启用")
end
-- 开始通话录音到文件
local function start_call_recording()
if open_record_file() then
log.info("通话录音", "开始录音到文件:", RECORD_FILE_PATH)
return true
else
log.error("通话录音", "无法开始录音到文件,请检查SD卡")
return false
end
end
-- 将PCM录音文件转码为AMR格式
-- 注意:此函数在通话结束后异步执行,避免阻塞主线程
local function convert_pcm_to_amr()
log.info("AMR转码", "开始将PCM转码为AMR格式...")
-- 检查PCM文件是否存在
if not io.exists(RECORD_FILE_PATH) then
log.error("AMR转码", "PCM文件不存在:", RECORD_FILE_PATH)
return false
end
-- 删除旧的AMR文件
if io.exists(RECORD_AMR_FILE_PATH) then
os.remove(RECORD_AMR_FILE_PATH)
log.info("AMR转码", "删除旧AMR文件")
end
-- 创建AMR编码器
local amr_coder = codec.create(codec.AMR, record_sample_rate, 4)
if not amr_coder then
log.error("AMR转码", "创建AMR编码器失败")
return false
end
-- 打开PCM文件
local pcm_file = io.open(RECORD_FILE_PATH, "rb")
if not pcm_file then
log.error("AMR转码", "无法打开PCM文件:", RECORD_FILE_PATH)
codec.release(amr_coder)
return false
end
-- 创建AMR输出文件
local amr_file = io.open(RECORD_AMR_FILE_PATH, "wb")
if not amr_file then
log.error("AMR转码", "无法创建AMR文件:", RECORD_AMR_FILE_PATH)
pcm_file:close()
codec.release(amr_coder)
return false
end
-- 写入AMR文件头
if record_sample_rate == 16000 then
amr_file:write("#!AMR-WB\n") -- AMR-WB文件头
log.info("AMR转码", "使用AMR-WB格式(16KHz)")
else
amr_file:write("#!AMR\n") -- AMR-NB文件头
log.info("AMR转码", "使用AMR-NB格式(8KHz)")
end
-- 计算帧大小
local frame_samples = record_sample_rate == 16000 and 640 or 320 -- AMR-WB: 640 samples, AMR-NB: 320 samples
local frame_bytes = frame_samples * 2 -- 16bit = 2 bytes per sample
-- 创建缓冲区
local read_buff = zbuff.create(frame_bytes)
local amr_out = zbuff.create(1024)
local total_encoded = 0
local start_time = mcu.ticks()
local pcm_size = io.fileSize(RECORD_FILE_PATH)
-- 读取并编码PCM数据
while true do
local data = pcm_file:read(frame_bytes)
if not data or #data < frame_bytes then
break -- 文件读取完毕或数据不足一帧
end
-- 将数据复制到zbuff
read_buff:del()
read_buff:copy(nil, data)
-- 使用pcall保护编码操作
local ok, result = pcall(function()
return codec.encode(amr_coder, read_buff, amr_out)
end)
if ok and result then
-- 写入编码后的数据
amr_file:write(amr_out:query())
total_encoded = total_encoded + amr_out:used()
else
log.warn("AMR转码", "编码失败,跳过当前帧")
end
end
-- 清理资源
read_buff:del()
amr_out:del()
codec.release(amr_coder)
pcm_file:close()
amr_file:close()
local end_time = mcu.ticks()
local cost_time_ms = calc_time_diff_ms(start_time, end_time)
if cost_time_ms then
local cost_time_sec = cost_time_ms / 1000
log.info("AMR转码", "转码完成",
"AMR大小:", total_encoded, "字节,",
"压缩比:", string.format("%.1f%%", total_encoded / pcm_size * 100), ",",
"耗时:", string.format("%.1f", cost_time_sec), "秒,",
"路径:", RECORD_AMR_FILE_PATH)
else
log.info("AMR转码", "转码完成",
"AMR大小:", total_encoded, "字节,",
"压缩比:", string.format("%.1f%%", total_encoded / pcm_size * 100), ",",
"耗时: 溢出无法计算",
"路径:", RECORD_AMR_FILE_PATH)
end
return true
end
-- 停止通话录音到文件
local function stop_call_recording()
close_record_file()
log.info("通话录音", "停止录音到文件")
-- 如需AMR格式,取消下面这行的注释
-- 转码在通话结束后异步执行,避免阻塞主线程
-- convert_pcm_to_amr()
end
-- 获取所有缓冲区
local function getRecordingBuffers()
return {
up1 = up1,
up2 = up2,
down1 = down1,
down2 = down2
}
end
-- 获取录音文件信息
local function get_record_file_info()
if io.exists(RECORD_FILE_PATH) then
local file_size = io.fileSize(RECORD_FILE_PATH)
return {
path = RECORD_FILE_PATH,
size = file_size,
duration = record_duration,
exists = true
}
else
return {
path = RECORD_FILE_PATH,
size = 0,
duration = 0,
exists = false
}
end
end
-- 呼入自动接听,等待对方挂断
local function handle_scenario(status)
if status == "INCOMINGCALL" then
-- 获取来电号码
caller_number = cc.lastNum() or "未知号码"
call_counter = call_counter + 1
log.info("收到来电,号码:", caller_number, "响铃次数:", call_counter)
-- 响铃2声后自动接听
if call_counter >= 2 then
log.info("自动接听来电")
cc.accept(0)
call_counter = 0 -- 重置计数器
end
elseif status == "SPEECH_START" then
-- 语音通话真正开始
log.info("电话已接通,电话号码:", caller_number)
-- 开始通话录音到文件
start_call_recording()
elseif status == "DISCONNECTED" then
-- 对方挂断通话
log.info("通话结束对方挂断")
-- 停止通话录音到文件
stop_call_recording()
call_counter = 0 -- 重置计数器
end
end
-- ====================== 主事件处理器 ======================
sys.subscribe("CC_IND", function(status)
log.info("CC状态", status)
handle_scenario(status)
-- 需要处理的通用状态
if status == "READY" then
sys.publish("CC_READY") -- 发布系统就绪事件
elseif status == "HANGUP_CALL_DONE" or status == "MAKE_CALL_FAILED" or status == "DISCONNECTED" then
exaudio.pm(audio.SHUTDOWN) --主动进入低功耗模式
end
end)
-- ====================== 电话系统初始化 ======================
local function init_cc()
-- 先尝试挂载SD卡
mount_sd_card()
-- 初始化音频设备
audio_drv.initAudioDevice()
-- 等待电话系统就绪
sys.waitUntil("CC_READY")
-- 初始化电话功能
cc.init(audio_drv.getMultimediaId())
-- 启用通话录音(录音功能在cc_app中)
enableRecording()
log.info("cc_app", "电话系统初始化完成")
end
-- 启动初始化任务
sys.taskInit(init_cc)
六、功能演示
6.1 选择功能
首先注释掉 main.lua 中默认通话功能模块,打开通话录音功能模块

6.2 烧录代码
使用 Luatools 将代码烧录到 Air8000 开发板或核心板中

6.3 SD 卡挂载成功
烧录完毕后,系统会自动初始化并尝试挂载 SD 卡,日志中会显示 SD 卡挂载成功信息

6.4 电话系统初始化完成
等待电话系统初始化完成,显示"CC_READY"状态

6.5 来电响铃
当有来电时,日志会显示来电号码和响铃次数

6.6 自动接听
响铃 2 声后自动接听来电,显示"SPEECH_START"状态

6.7 开始录音
通话接通后自动开始录音,日志显示录音文件创建成功

6.8 录音进行中
通话过程中,日志会实时显示录音数据写入信息,包括数据大小、写入耗时和写入速度

6.9 通话结束
对方挂断后,通话结束,日志显示"DISCONNECTED"状态和录音完成信息

6.10 查看录音文件
录音文件保存在 SD 卡的 /sd/record_call.pcm 路径下,可以通过读卡器在电脑上查看

音频文件需要使用专用播放器(如 Audacity)播放

6.11 AMR 格式转码(可选)
如需将 PCM 录音文件转码为 AMR 格式,请按以下步骤操作:
6.11.1 开启 AMR 转码功能
首先找到 stop_call_recording() 函数,取消 convert_pcm_to_amr() 的注释。

6.11.2 查看转码日志
通话结束后,系统会自动将 PCM 文件转码为 AMR 格式,日志会显示转码信息:

6.11.3 查看 AMR 文件
转码完成后,SD 卡中会同时存在两个文件:

AMR 文件可直接用手机播放器播放,无需安装专用软件。

注意事项:
- AMR 转码在通话结束后离线执行,不影响通话过程中的录音稳定性
- 根据通话质量自动选择 AMR-NB(8KHz)或 AMR-WB(16KHz)格式
- 转码过程有 pcall 保护,即使转码失败也不会影响系统运行
七、 总结
至此,我们演示了使用 Air8000 实现呼入自动接听和通话录音的全过程:
- 呼入自动接听:来电响铃 2 声后自动接听,无需手动操作
- 通话录音:通话接通后自动开始录音,对方挂断后自动停止
- SD 卡存储:录音文件以 PCM 格式保存到 SD 卡,支持自动挂载和空间检测
- 数据优化:只保存上行数据,避免下行数据造成的回声问题
注意事项:
- 录音文件为原始 PCM 格式,需要使用专用播放器(如 Audacity)播放
- 缓冲区大小必须是 640 的倍数,否则可能导致录音异常