跳转至

06 通话录音

作者:拓毅恒 | 最后修改:2026-05-18

一、通话录音概述

  • 通话录音:在通话过程中实时录制通话音频数据并保存到存储设备的功能,可用于通话记录保存、语音取证等场景。
  • PCM 格式:原始音频数据格式,未经压缩,音质无损,但需要专用播放器播放。
  • AMR 格式:支持将录音文件保存 amr 格式,压缩率高,文件小,手机播放器可直接播放。
  • 上行数据:本地麦克风采集的声音数据,包含本地说话声音和对方声音数据。
  • 下行数据:从网络接收的对方声音数据。

二、演示功能概述

本章节将演示如何使用 Air8000 实现呼入自动接听和通话录音功能:

  1. 呼入自动接听:来电响铃 2 声后自动接听
  2. 通话录音:通话接通后自动开始录音,对方挂断后自动停止
  3. SD 卡存储:录音文件保存到 SD 卡,支持自动挂载和空间检测
  4. 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 需要的脚本和资源文件

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 实现呼入自动接听和通话录音的全过程:

  1. 呼入自动接听:来电响铃 2 声后自动接听,无需手动操作
  2. 通话录音:通话接通后自动开始录音,对方挂断后自动停止
  3. SD 卡存储:录音文件以 PCM 格式保存到 SD 卡,支持自动挂载和空间检测
  4. 数据优化:只保存上行数据,避免下行数据造成的回声问题

注意事项

  • 录音文件为原始 PCM 格式,需要使用专用播放器(如 Audacity)播放
  • 缓冲区大小必须是 640 的倍数,否则可能导致录音异常