【阿里云ASR教程】阿里云一句话识别(NLS)实战:带 Token 缓存 + WAV 自动重采样的 Python 脚本

做语音机器人项目时,经常会遇到一个很基础但又烦人的需求:

给我一个本地 WAV 文件 ,我想用阿里云智能语音交互(NLS)一句话识别,

能一键跑通、自动处理 Token、自动把音频转成 16k 单通道、最后把识别文本打出来。

本文就是把这个需求完整走一遍,最终产出一个:

  • ✅ 带 AK/SK 自动获取 Token

  • ✅ 带 Token 本地缓存 + 自动续期

  • ✅ 自动把任何常见 WAV(双声道 / 48kHz)转成 16kHz 单声道 PCM

  • ✅ 调用阿里云 nls-python-sdk 进行一句话识别

  • ✅ 打印出最终识别结果 result_text

【🚀🚀🚀如果你对人工智能的学习有兴趣可以看看我的其他博客,对新手很友好!🚀🚀🚀】

【🚀🚀🚀本猿定期无偿分享学习成果,欢迎关注一起学习!🚀🚀🚀】

一、运行环境与依赖

目录结构可以很简单,比如:

bash 复制代码
ALYasr/
├── asr.py                 # 本文的主脚本
├── .env                   # 阿里云配置(AK/SK/AppKey)
├── aliyun_token_cache.json  # 运行后自动生成的 Token 缓存
└── data/
    └── input.wav          # 用来测试的一句话音频

推荐用一个独立的 conda / venv:

bash 复制代码
conda create -n ALYasr python=3.10
conda activate ALYasr

二、下载Python SDK

  1. 下载Python SDK。

    Github获取Python SDK,或直接下载streamInputTts-github-python

  2. 安装SDK依赖。

    进入SDK根目录使用如下命令安装SDK依赖:

    复制代码
    python -m pip install -r requirements.txt
  3. 安装SDK。

    依赖安装完成后使用如下命令安装SDK:

    复制代码
    python -m pip install .
  4. 安装完成后通过以下代码导入SDK。

    复制代码
    # -*- coding: utf-8 -*-
    import nls

三、用 .env 管理 AK/SK/AppKey

避免把密钥写死在代码里,我们用一个 .env 文件:

.env 示例(注意不要泄露到 Git):

bash 复制代码
ALIYUN_AK_ID=你的AccessKeyId
ALIYUN_AK_SECRET=你的AccessKeySecret
ALIYUN_ISI_APPKEY=你的智能语音交互AppKey

(1)第一二条获取地址:

控制台首页

登录然后右上角点击头像,找到AccessKey

进入创建ID和SECRET就可以获取啦

(2)第三条获取地址:

智能语音交互控制台

创建一个app,选择仅语音就可以获取啦

脚本里通过 load_env_from_dotenv() 做了一个简易版 dotenv

bash 复制代码
def load_env_from_dotenv(env_path: Path = ENV_FILE):
    if not env_path.is_file():
        print(f"[WARN] .env 文件不存在: {env_path.resolve()},将仅使用系统环境变量")
        return

    print(f"[INFO] 从 {env_path.resolve()} 读取配置...")
    with env_path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue
            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip().strip('"').strip("'")
            # 不覆盖系统已有的环境变量
            if key and key not in os.environ:
                os.environ[key] = value

然后从环境变量里统一读取:

bash 复制代码
AK_ID = os.getenv("ALIYUN_AK_ID")
AK_SECRET = os.getenv("ALIYUN_AK_SECRET")
APPKEY = os.getenv("ALIYUN_ISI_APPKEY")

代码启动时会打印一段脱敏后的配置调试信息,方便排查是否读到了正确的配置:

bash 复制代码
========== DEBUG CONFIG ==========
[DEBUG] .env 路径: D:\work_place\ALYasr\.env
[DEBUG] AK_ID (masked): LTAI...Tq88
[DEBUG] AK_SECRET length: 30
[DEBUG] APPKEY (masked): lqqQ...XMzA
==================================

四、TokenManager:Token 自动缓存 + 过期自动续期

阿里云 NLS 的 WebSocket 需要 Token,我们用 AK/SK 调 CreateToken 得到:

  • Token.Id

  • Token.ExpireTime(秒级时间戳)

为了不每次都打 Auth 接口,脚本里设计了一个 TokenManager

python 复制代码
class TokenManager:
    """
    通过 CreateToken 获取 Token,并在本地缓存:
        {
          "token": "xxxxxxxx",
          "expire_time": 1719999999   # 秒级时间戳
        }
    只有在 Token 不存在或已过期时才会重新申请。
    """

    def __init__(self, cache_path: Path = TOKEN_CACHE_PATH):
        self.cache_path = cache_path
        self._token = None
        self._expire_time = 0

    def get_token(self) -> str:
        now_ts = int(time.time())

        # 1) 内存缓存
        if self._token and self._expire_time - now_ts > 60:
            return self._token

        # 2) 文件缓存
        if self.cache_path.is_file():
            try:
                with self.cache_path.open("r", encoding="utf-8") as f:
                    data = json.load(f)
                token = data.get("token")
                expire_time = int(data.get("expire_time", 0))

                if token and expire_time - now_ts > 60:
                    self._token = token
                    self._expire_time = expire_time
                    print(f"[INFO] 使用缓存的 Token,有效期至 {expire_time} (unix 秒)")
                    return self._token
                else:
                    print("[INFO] 缓存 Token 已过期或即将过期,准备重新获取")
            except Exception as e:
                print(f"[WARN] 读取 Token 缓存失败: {e},将重新获取")

        # 3) 重新申请
        token, expire_time = self._create_token()
        self._token = token
        self._expire_time = expire_time

        # 写入缓存
        try:
            with self.cache_path.open("w", encoding="utf-8") as f:
                json.dump(
                    {"token": token, "expire_time": expire_time},
                    f,
                    ensure_ascii=False,
                    indent=2,
                )
        except Exception as e:
            print(f"[WARN] 保存 Token 缓存失败: {e}")

        return token

第一次运行时会打一次 CreateToken

bash 复制代码
[INFO] CreateToken 响应: {"ErrMsg":"","Token":{"UserId":"1159...","Id":"54e4a8...e363","ExpireTime":1764959566}}
token = 54e4a87fa78c40b5a1829f79fe17e363
expireTime = 1764959566

之后再次运行,就是:

bash 复制代码
[INFO] 使用缓存的 Token,有效期至 1764959566 (unix 秒)

五、音频预处理:WAV → 16kHz 单声道 PCM

阿里云一句话识别推荐输入16kHz 单声道 16bit PCM

而现实中的音频文件,基本都不是这个规格(比如 48kHz 双声道)。

所以我们需要一段"很懂事"的预处理逻辑:

python 复制代码
def load_audio_as_pcm_bytes(path: Path) -> bytes:
    """
    读取音频文件,返回 16kHz 单声道 PCM bytes。
    - 如果是 .wav:使用 wave 模块读取,并自动混成单声道 + 重采样到 16k。
    - 否则按原始 PCM 读取(认为已经是 16k 单声道)。
    """
    if not path.is_file():
        raise FileNotFoundError(f"音频文件不存在: {path.resolve()}")

    suffix = path.suffix.lower()

    if suffix == ".wav":
        with wave.open(str(path), "rb") as wf:
            n_channels = wf.getnchannels()
            sampwidth = wf.getsampwidth()
            framerate = wf.getframerate()
            n_frames = wf.getnframes()

            print(f"[INFO] WAV 参数: channels={n_channels}, "
                  f"sampwidth={sampwidth * 8}bit, rate={framerate}, frames={n_frames}")

            raw = wf.readframes(n_frames)

        # 多声道混为单声道
        if n_channels != 1:
            print(f"[WARN] 检测到 {n_channels} 声道,自动混为单声道......")
            raw = audioop.tomono(raw, sampwidth, 0.5, 0.5)

        # 重采样到 16k
        if framerate != 16000:
            print(f"[WARN] 当前采样率 {framerate}Hz,将重采样为 16000Hz ......")
            raw, _ = audioop.ratecv(raw, sampwidth, 1, framerate, 16000, None)
            framerate = 16000

        if sampwidth != 2:
            print(f"[WARN] 采样位宽 {sampwidth * 8}bit,建议使用 16bit。")

        duration = len(raw) / (framerate * sampwidth)
        print(f"[INFO] 重采样后估算音频时长: {duration:.2f}s")

        return raw

    # 其他情况,认为已经是 16k 单声道原始 PCM
    print(f"[INFO] 按原始 PCM 文件读取: {path}")
    with open(path, "rb") as f:
        data = f.read()
    print(f"[INFO] PCM 数据长度: {len(data)} 字节")
    return data

实际日志示例(原音频是 48kHz 双声道):

bash 复制代码
[INFO] WAV 参数: channels=2, sampwidth=16bit, rate=48000, frames=143360
[WARN] 检测到 2 声道,自动混为单声道......
[WARN] 当前采样率 48000Hz,将重采样为 16000Hz ......
[INFO] 重采样后估算音频时长: 2.99s

六、完整源码,复制粘贴一键运行!

python 复制代码
#! /usr/bin/env python
# coding=utf-8
"""
阿里云 一句话识别(NLS)测试脚本 - 带 Token 缓存 + WAV 自动重采样

功能概述:
1. 使用 AK/SK 调用 nls-meta 的 CreateToken 接口,获取 Token + ExpireTime;
2. 将 Token 缓存在本地 aliyun_token_cache.json 中;
3. 每次使用前检查当前时间与 ExpireTime:
   - 若 Token 仍然有效(留 60s 缓冲),直接使用缓存;
   - 若 Token 已过期或即将过期,再重新调用 CreateToken 更新;
4. 使用最新的 Token + AppKey,通过 nls.NlsSpeechRecognizer
   对 ./data/input.wav 进行一句话识别,并打印最终识别文本。

配置来源优先级:
  1) ./ .env 文件中的配置(推荐)
  2) 系统环境变量

需要的键(放在 .env 或系统环境变量里):
  - ALIYUN_AK_ID         # AccessKey ID
  - ALIYUN_AK_SECRET     # AccessKey Secret
  - ALIYUN_ISI_APPKEY    # 智能语音交互项目 AppKey

依赖:
  pip install aliyun-python-sdk-core nls
"""

import os
import sys
import time
import json
import threading
import wave
import audioop
from pathlib import Path

from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest

import nls


# ============== 基本配置 ==============

REGION_ID = "cn-shanghai"
META_DOMAIN = "nls-meta.cn-shanghai.aliyuncs.com"
META_VERSION = "2019-02-28"

WS_URL = "wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1"

TOKEN_CACHE_PATH = Path("./aliyun_token_cache.json")
AUDIO_PATH = Path("./data/input.wav")    # 你也可以改成 PCM 文件路径
ENV_FILE = Path("./.env")               # 从这里读取三个配置


# ============== 从 .env 加载配置 ==============

def load_env_from_dotenv(env_path: Path = ENV_FILE):
    """
    简单解析 ./ .env:
      - 每行形如 KEY=VALUE
      - 支持 # 开头的注释行
      - 会把解析到的键值写入 os.environ(如果原来没有的话)
    """
    if not env_path.is_file():
        print(f"[WARN] .env 文件不存在: {env_path.resolve()},将仅使用系统环境变量")
        return

    print(f"[INFO] 从 {env_path.resolve()} 读取配置...")
    try:
        with env_path.open("r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if "=" not in line:
                    continue
                key, value = line.split("=", 1)
                key = key.strip()
                value = value.strip().strip('"').strip("'")
                # 不覆盖系统已有的环境变量
                if key and key not in os.environ:
                    os.environ[key] = value
    except Exception as e:
        print(f"[WARN] 解析 .env 失败: {e}")


# 先尝试从 .env 填充环境变量
load_env_from_dotenv()

# 再从环境变量读取(可能是 .env 写进去的,也可能是系统已有的)
AK_ID = os.getenv("ALIYUN_AK_ID")
AK_SECRET = os.getenv("ALIYUN_AK_SECRET")
APPKEY = os.getenv("ALIYUN_ISI_APPKEY")  # 必须配置


# ============== 调试输出:确认从 .env 读到的值 ==============

def mask_value(v: str, show: int = 4) -> str:
    """只显示前 show 位和后 show 位,中间打省略号,避免泄露完整密钥"""
    if not v:
        return "None"
    if len(v) <= show * 2:
        return v
    return v[:show] + "..." + v[-show:]


print("========== DEBUG CONFIG ==========")
print("[DEBUG] .env 路径:", ENV_FILE.resolve())
print("[DEBUG] AK_ID (masked):", mask_value(AK_ID))
print("[DEBUG] AK_SECRET length:", len(AK_SECRET) if AK_SECRET else None)
print("[DEBUG] APPKEY (masked):", mask_value(APPKEY))
print("==================================")


# ============== Token 管理:只在过期时更新 ==============

class TokenManager:
    """
    通过 CreateToken 获取 Token,并在本地缓存:
        {
          "token": "xxxxxxxx",
          "expire_time": 1719999999   # 秒级时间戳
        }
    只有在 Token 不存在或已过期时才会重新申请。
    """

    def __init__(self, cache_path: Path = TOKEN_CACHE_PATH):
        self.cache_path = cache_path
        self._token = None
        self._expire_time = 0

    def get_token(self) -> str:
        """
        获取一个"当前可用"的 Token:
          1. 若内存中有且未过期 -> 直接返回
          2. 否则尝试从缓存文件读取 -> 未过期则用缓存
          3. 否则调用 _create_token() 重新申请并写入缓存
        """
        now_ts = int(time.time())

        # 1) 内存缓存
        if self._token and self._expire_time - now_ts > 60:
            # 留 60 秒缓冲
            return self._token

        # 2) 文件缓存
        if self.cache_path.is_file():
            try:
                with self.cache_path.open("r", encoding="utf-8") as f:
                    data = json.load(f)
                token = data.get("token")
                expire_time = int(data.get("expire_time", 0))

                if token and expire_time - now_ts > 60:
                    self._token = token
                    self._expire_time = expire_time
                    print(f"[INFO] 使用缓存的 Token,有效期至 {expire_time} (unix 秒)")
                    return self._token
                else:
                    print("[INFO] 缓存 Token 已过期或即将过期,准备重新获取")
            except Exception as e:
                print(f"[WARN] 读取 Token 缓存失败: {e},将重新获取")

        # 3) 重新申请
        token, expire_time = self._create_token()
        self._token = token
        self._expire_time = expire_time

        # 写入缓存
        try:
            with self.cache_path.open("w", encoding="utf-8") as f:
                json.dump(
                    {"token": token, "expire_time": expire_time},
                    f,
                    ensure_ascii=False,
                    indent=2,
                )
        except Exception as e:
            print(f"[WARN] 保存 Token 缓存失败: {e}")

        return token

    def _create_token(self):
        """
        真正调用 CreateToken 的逻辑,只在需要更新时执行。
        """
        if not AK_ID or not AK_SECRET:
            raise RuntimeError("未配置 ALIYUN_AK_ID / ALIYUN_AK_SECRET(.env 或系统环境变量)")

        client = AcsClient(AK_ID, AK_SECRET, REGION_ID)

        request = CommonRequest()
        request.set_method('POST')
        request.set_domain(META_DOMAIN)
        request.set_version(META_VERSION)
        request.set_action_name('CreateToken')

        try:
            response = client.do_action_with_exception(request)
            # Python3 返回 bytes,需 decode
            if isinstance(response, bytes):
                resp_str = response.decode("utf-8")
            else:
                resp_str = response

            print("[INFO] CreateToken 响应:", resp_str)
            jss = json.loads(resp_str)

            if 'Token' in jss and 'Id' in jss['Token']:
                token = jss['Token']['Id']
                expire_time = int(jss['Token']['ExpireTime'])
                print("token =", token)
                print("expireTime =", expire_time)
                return token, expire_time
            else:
                raise RuntimeError(f"CreateToken 响应中未找到 Token.Id 字段: {jss}")
        except Exception as e:
            print("[ERROR] 获取 Token 失败:", e)
            raise


# ============== 音频加载:WAV -> PCM(并重采样到 16kHz) ==============

def load_audio_as_pcm_bytes(path: Path) -> bytes:
    """
    读取音频文件,返回 16kHz 单声道 PCM bytes。
    - 如果是 .wav:使用 wave 模块读取,并自动混成单声道 + 重采样到 16k。
    - 否则按原始 PCM 读取(认为已经是 16k 单声道)。
    """
    if not path.is_file():
        raise FileNotFoundError(f"音频文件不存在: {path.resolve()}")

    suffix = path.suffix.lower()

    # WAV:推荐你用这个
    if suffix == ".wav":
        with wave.open(str(path), "rb") as wf:
            n_channels = wf.getnchannels()
            sampwidth = wf.getsampwidth()
            framerate = wf.getframerate()
            n_frames = wf.getnframes()

            print(f"[INFO] WAV 参数: channels={n_channels}, "
                  f"sampwidth={sampwidth * 8}bit, rate={framerate}, frames={n_frames}")

            raw = wf.readframes(n_frames)

        # 多声道混为单声道
        if n_channels != 1:
            print(f"[WARN] 检测到 {n_channels} 声道,自动混为单声道......")
            raw = audioop.tomono(raw, sampwidth, 0.5, 0.5)

        # 若采样率不是 16000,则用 audioop.ratecv 重采样
        if framerate != 16000:
            print(f"[WARN] 当前采样率 {framerate}Hz,将重采样为 16000Hz ......")
            raw, _ = audioop.ratecv(raw, sampwidth, 1, framerate, 16000, None)
            framerate = 16000

        if sampwidth != 2:
            print(f"[WARN] 采样位宽 {sampwidth * 8}bit,建议使用 16bit。")

        duration = len(raw) / (framerate * sampwidth)
        print(f"[INFO] 重采样后估算音频时长: {duration:.2f}s")

        return raw

    # 默认走原始 PCM,认为你已经自己处理成 16k 单声道
    print(f"[INFO] 按原始 PCM 文件读取: {path}")
    with open(path, "rb") as f:
        data = f.read()
    print(f"[INFO] PCM 数据长度: {len(data)} 字节")
    return data


# ============== 一句话识别类 ==============

class TestSr:
    """
    用 token + appkey 对一段 PCM 数据做一次一句话识别
    """

    def __init__(self, tid: str, audio_bytes: bytes, token: str):
        self.__id = tid
        self.__data = audio_bytes
        self.__token = token
        self.__th = threading.Thread(target=self.__test_run)
        self.result_text = None  # 保存最终识别结果

    # 对外启动接口
    def start(self):
        self.__th.start()
        return self.__th

    # 回调函数们
    def test_on_start(self, message, *args):
        print("test_on_start:{}".format(message))

    def test_on_error(self, message, *args):
        print("on_error: message={} args={}".format(message, args))

    def test_on_close(self, *args):
        print("on_close: args=>{}".format(args))

    def test_on_result_chg(self, message, *args):
        # 中间结果(可选)
        print("test_on_chg:{}".format(message))

    def test_on_completed(self, message, *args):
        print("on_completed:args=>{} message=>{}".format(args, message))
        # 尝试解析最终结果
        try:
            data = json.loads(message)
            result = data.get("payload", {}).get("result", "")
            if result:
                self.result_text = result
                print(f"[RESULT][{self.__id}] 最终识别文本: {result}")
            else:
                print(f"[RESULT][{self.__id}] 最终结果为空字符串(可能是音频无语音或太短)")
        except Exception as e:
            print("[ERROR] 解析 on_completed JSON 失败:", e)

    # 真正跑识别的线程函数
    def __test_run(self):
        print("thread:{} start..".format(self.__id))

        sr = nls.NlsSpeechRecognizer(
            url=WS_URL,
            token=self.__token,
            appkey=APPKEY,
            on_start=self.test_on_start,
            on_result_changed=self.test_on_result_chg,
            on_completed=self.test_on_completed,
            on_error=self.test_on_error,
            on_close=self.test_on_close,
            callback_args=[self.__id],
        )

        print("{}: session start".format(self.__id))
        r = sr.start(
            aformat="pcm",
            sample_rate=16000,                     # 现在我们已经重采样到 16k
            ch=1,
            enable_intermediate_result=True,
            enable_punctuation_prediction=True,
            enable_inverse_text_normalization=True,
            ex={"hello": 123},
        )

        # 官方示例没有判断 r,这里只做日志,不再 return
        print("{}: sr.start() 返回: {}".format(self.__id, r))

        # 640 字节一片发送(官方示例这么写的)
        frame_size = 640
        total_len = len(self.__data)
        print(f"{self.__id}: 将以 {frame_size} 字节一帧发送,共 {total_len} 字节")

        offset = 0
        while offset < total_len:
            chunk = self.__data[offset: offset + frame_size]
            sr.send_audio(chunk)   # 不要把返回值当 True/False 判错,官方示例就是直接调用
            offset += frame_size
            time.sleep(0.01)

        print(f"{self.__id}: 音频发送完毕,调用 stop()")
        r = sr.stop()
        print("{}: sr stopped:{}".format(self.__id, r))
        time.sleep(1)


# ============== main ==============

def main():
    global APPKEY

    if not APPKEY:
        print("[ERROR] 未配置 ALIYUN_ISI_APPKEY(请在 .env 或系统环境变量中设置)")
        sys.exit(1)

    # 1) 获取一个"当前可用"的 Token(只有过期时才会真正去申请)
    token_mgr = TokenManager()
    token = token_mgr.get_token()

    # 2) 读取 ./data/input.wav 并转换为 16k 单声道 PCM
    audio_bytes = load_audio_as_pcm_bytes(AUDIO_PATH)

    # 3) 是否打开 NLS SDK trace 调试(需要详细日志就 True)
    nls.enableTrace(True)

    # 4) 跑一次一句话识别
    tester = TestSr("thread0", audio_bytes, token)
    th = tester.start()
    th.join()

    print("[INFO] 识别流程结束。")
    print("[INFO] 最终识别结果 result_text =", tester.result_text)


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print("[FATAL] 程序异常退出:", e)
        sys.exit(1)

🔮 博主整理不易,如果对你有帮助,可以点个免费的赞吗?感谢感谢!

相关推荐
JeffDingAI1 小时前
【MindSpore社区活动】在对抗中增强网络实践
python·深度学习·gan
1024小神1 小时前
使用AVFoundation实现二维码识别的角点坐标和区域
开发语言·数码相机·ios·swift
陌路201 小时前
C++ 单例模式
开发语言·c++
廋到被风吹走1 小时前
【JDK版本】JDK1.8相比JDK1.7 语言特性之函数式编程
java·开发语言·python
y***61311 小时前
PHP操作redis
开发语言·redis·php
fire-flyer1 小时前
Reactor Context 详解
java·开发语言
CoderYanger1 小时前
动态规划算法-简单多状态dp问题:14.粉刷房子
开发语言·算法·leetcode·动态规划·1024程序员节
BoBoZz191 小时前
QuadraticHexahedronDemo 非线性单元的展示与窗口交互
python·vtk·图形渲染·图形处理
Q_Q19632884751 小时前
python+django/flask+vue的个性化电影推荐系统
spring boot·python·django·flask·node.js