做语音机器人项目时,经常会遇到一个很基础但又烦人的需求:
给我一个本地 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
-
下载Python SDK。
从Github获取Python SDK,或直接下载streamInputTts-github-python。
-
安装SDK依赖。
进入SDK根目录使用如下命令安装SDK依赖:
python -m pip install -r requirements.txt -
安装SDK。
依赖安装完成后使用如下命令安装SDK:
python -m pip install . -
安装完成后通过以下代码导入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)