简介:本资源是面向PHP开发者的一站式讯飞语音识别API集成方案,专为需快速接入长语音转写能力的后端项目设计,解决中文语音识别准确率低、英文支持弱、长音频处理复杂等实际开发痛点,适用于语音搜索、会议记录、客服语音分析等场景。压缩包共12个文件(41KB),含6个核心PHP类文件(如XFLongFormAsrClient.php实现分片上传与结果合并、RequestApi.php封装HTTP请求)、README.md与说明文档提供完整调用流程、附赠.docx含常见错误码解析与调试建议,LICENSE与composer.json确保合规集成与依赖管理。目前已有171人学习下载,开发者可直接通过Composer安装依赖,无需理解底层协议细节,即可调用支持中英文混合识别的长语音转写接口,显著降低语音能力接入门槛与调试成本。 上周渠道部甩过来三百多个客服录音文件,说三天内全部转成可检索文本。我第一反应是找外包,一看报价和时效直接放弃;第二反应是翻语音识别API,最终敲定了讯飞开放平台语音识别接口。这篇文章记录的就是我基于这个接口做的一个PHP实现项目:支持长语音转写,中英文都能识别,整个封装通过Composer安装依赖就能集成到现有PHP项目里。
项目说大不大,但踩坑不少。真正做完你会发现,讯飞长语音转写API的完整链路是"上传音频 -> 拿taskId -> 轮询结果 -> 解析文本"四步,每一步都有不少容易被文档忽略的细节。如果你也准备把语音识别能力接进PHP系统,或者正在为一批录音文件发愁,这篇内容应该能帮你少走很多弯路。我会把从选型、封装、联调到上线的完整过程都讲透,包括中间踩过的坑和最后的排查方案。
1. 长语音转写需求从哪来:被三百个录音文件逼出的封装项目
1.1 业务场景:录音文件转文本的刚需
需求本身不复杂:渠道部的客服通话录音,每天产生几十条,月底要统一转写成文字,用来做质检关键词检索和服务投诉复盘。之前一直是运营同事手动听写,一条十分钟的录音至少要花二十分钟来处理,三百条录音光听写就要一百多个小时,三天无论如何都完不成。
这种场景其实很典型。客服质检、会议纪要、课程音频转文稿、访谈记录整理,本质都是同一件事:把已经存在的音频文件变成可搜索、可编辑、可归档的文本。它和实时语音识别是两条完全不同的技术路线,实时识别关注的是"边说边出字",离线转写关注的是"整段音频如何高准确率地出稿"。我一开始也没想清楚这个区别,还尝试过用流式接口硬扛,结果发现要么音频长度受限,要么网络抖动导致中断,后来才把目光放到长语音转写这类专用接口上。
1.2 为什么选中讯飞开放平台而不是其他方案
国内能提供语音转写能力的平台不少,百度、阿里、腾讯都有相关产品。我当时的对比维度有三个:中文识别准确率、长音频支持友好度、开发者接入成本。讯飞在这三点的综合表现比较靠前,尤其是客服录音里常见的口音、数字、专业名词混读,实测下来讯飞的识别准确率确实要高一些。另一个原因是讯飞开放平台对离线转写场景有独立的"录音文件转写"接口,专门处理长度在几分钟到几小时之间的音频,而不是把流式接口强行拉长时间,这省了我自己切分音频、拼接结果的很多事。
| 对比维度 | 讯飞开放平台 | 百度智能云 | 阿里云 |
|---|---|---|---|
| 中文识别准确率 | 较高,口音适配好 | 中上,需训练模型提升 | 中上,需配置热词 |
| 长音频接口 | 有独立录音文件转写 | 有录音文件识别 | 有录音文件识别 |
| PHP SDK 维护 | 官方SDK不完善 | 官方有PHP SDK | 官方有PHP SDK |
| 免费额度 | 注册即送体验额度 | 有免费额度 | 有免费额度 |
还有一个很现实的原因:PHP生态里关于讯飞语音识别的成熟封装本来就少,用其他平台同样要自己写HTTP客户端。既然都要封装,选一个识别效果更稳的,对最终用户更负责。
1.3 为什么自己封装而不是直接用官方SDK
讯飞开放平台官方SDK覆盖了Java、Python、C++等主流语言,PHP不是它的重点维护对象。我翻了一下,官方PHP示例代码还停留在比较早期的写法,没有遵循PSR规范,也没有Composer集成,直接放到现代PHP项目里需要改不少地方。与其每次都要从官网复制代码再手工改造,不如我自己封装成一个Composer包,统一的命名空间、统一的异常处理、统一的日志输出,以后其他项目要用,一行composer require就能拉进来。
这个决策回头看是正确的。封装过程逼着我把签名、轮询、文件上传这些细节全部读透,遇到问题可以自己定位,而不是对着黑盒SDK干瞪眼。封装之后,调用方只需要传入音频文件路径,几行代码就能拿到转写文本,业务侧不用关心API细节。
2. Composer依赖与项目骨架:先把封装的地基建好
2.1 项目目录与自动加载设计
封装一个Composer包,第一步不是写API调用,而是把目录结构和自动加载规则定好。我采用的是最常见的PSR-4结构,包名用的yourname/xfyun-lfasr,实际发布时可以换成自己的命名空间。
xfyun-lfasr/ ├── composer.json ├── README.md ├── config/ │ └── xfyun.php ├── src/ │ ├── LfasrClient.php │ ├── SignatureHelper.php │ └── Exception/ │ └── XfyunApiException.php ├── examples/ │ └── transcribe.php └── tests/ └── SignatureHelperTest.phpsrc目录放核心代码,config放默认配置,examples提供可直接运行的示例脚本,tests放单元测试。这个分层的好处是:核心代码不依赖具体配置项,客户端实例化时传入AppID、APIKey、APISecret就行,配置文件只是方便使用者统一管理。
PSR-4自动加载规则在composer.json里声明,把YourName\\XfyunLfasr\\映射到src/目录,之后所有类都放src下对应路径即可,不需要手动维护加载文件。
2.2 composer.json怎么配
composer.json是整个包的"说明书",我建议把PHP版本要求、依赖、自动加载、扩展信息都写清楚。这是我的配置:
{ "name": "yourname/xfyun-lfasr", "description": "讯飞开放平台语音识别接口的PHP封装,支持长语音转写、中英文识别", "type": "library", "license": "MIT", "require": { "php": ">=7.4", "guzzlehttp/guzzle": "^7.0", "monolog/monolog": "^2.0" }, "require-dev": { "phpunit/phpunit": "^9.0" }, "autoload": { "psr-4": { "YourName\\XfyunLfasr\\": "src/" } }, "autoload-dev": { "psr-4": { "YourName\\XfyunLfasr\\Tests\\": "tests/" } } }使用者只需要在项目根目录执行composer require yourname/xfyun-lfasr,Composer会自动拉取Guzzle和Monolog。如果项目本身已经装了Guzzle,也不用担心重复安装,Composer会做版本仲裁。
我把PHP最低版本设为7.4,因为7.4之前的版本EOL已久,而且typed properties、箭头函数这些语法在封装时很好用。如果你的项目还在PHP 5.6上,那不建议用这个包,先升级PHP更现实。
2.3 Guzzle这个依赖帮我们省了哪些事
讯飞语音识别接口本质上就是HTTP接口,上传文件、轮询结果都靠请求响应。我选择Guzzle而不直接用file_get_contents或curl扩展,原因是Guzzle把HTTP客户端该有的能力都封装好了:超时控制、重试中间件、multipart文件上传、JSON解析、请求日志,这些都是真实项目里的刚需。
举一个具体例子:上传音频文件时,multipart格式如果手写很容易在文件流边界上出错,Guzzle直接接受fopen资源作为文件流,底层由cURL处理,我不用关心Content-Type和Content-Length的拼接。轮询任务时,Guzzle的timeout和connect_timeout参数可以分别控制请求超时和连接超时,避免某个节点挂起导致进程卡死。另一个实用点是Guzzle支持retry中间件,短时间的网络抖动可以自动重试,这在后面轮询长任务时非常有用。
3. 长语音转写API的核心机制:上传、签名与轮询
3.1 长语音转写与实时语音听写的本质区别
讯飞开放平台有两类语音识别产品,很多人第一次接触容易混淆。一类是实时语音听写,通常走WebSocket长连接,适合App内实时字幕、语音输入法这类交互场景,特点是边说话边出结果,但连接保持时间有限,网络波动会导致识别中断。另一类是录音文件转写,也就是长语音转写,走的是HTTP请求,把完整的音频文件上传到服务端,异步处理后再取回结果,适合对已录制音频做批量转写。
这两类接口的调用方式完全不同。长语音转写不需要维持长连接,核心逻辑是"提交任务、等待完成、拉取结果",本质是一个异步任务系统。理解了这一点,后面实现轮询就不会觉得奇怪。我当时差点一开始就接实时听写,后来发现单条录音超过接口时长限制,才转向录音文件转写。
3.2 签名鉴权:为什么要签,怎么签
讯飞开放平台的接口鉴权,核心思想是"AppID标识身份,APIKey/APISecret签名防篡改"。请求方需要把当前时间戳、AppID、请求参数按照一定规则拼成签名原串,再用APISecret做摘要,服务端用同样的算法校验,这样就算有人截获了请求,也无法伪造新的请求。
我封装时写了两种签名方式,分别对应讯飞不同版本接口的需求。一种是老版本常用的MD5拼接方式,上传时对appId + ts做MD5,查询时对appId + ts + taskId做MD5;另一种是HMAC-SHA256方式,用APISecret作为密钥对签名原串做HMAC加密,再base64编码。需要注意,签名原串的具体拼接字段每个版本的文档可能有差异,我建议以你申请应用时开放平台提供的接入文档为准。
<?php declare(strict_types=1); namespace YourName\XfyunLfasr; class SignatureHelper { /** * 老版本接口:MD5 签名 */ public static function signByMd5(string $appId, string $ts, string $taskId = ''): string { return md5($appId . $ts . $taskId); } /** * 新版本接口:HMAC-SHA256 签名 */ public static function signByHmac(string $apiSecret, string $signatureOrigin): string { return base64_encode(hash_hmac('sha256', $signatureOrigin, $apiSecret, true)); } }实际请求时,ts用Unix时间戳字符串,注意用当前服务器时间,如果本地服务器时间偏差过大,会被判定为签名过期。我第一次联调时就在这个问题上栽了跟头,服务器时间慢了五分钟,怎么签都是鉴权失败。
3.3 任务状态机与轮询策略
录音文件转写的任务状态通常包含提交成功、处理中、处理完成、处理失败几个阶段。上传音频后服务端会返回一个taskId,后续所有查询都靠这个ID。查询结果里会带一个状态字段,比如值为9时表示处理完成,值为-1时表示失败。
轮询策略上,我一开始用的是固定间隔一秒查一次,跑了几个任务发现对服务端压力不小,而且很多任务十几秒内根本不会结束,白白浪费请求。后来改成指数退避:初始间隔两秒,之后每次加倍,最大间隔十秒,状态变为处理完成或失败才停止。同时设置一个总超时时间,比如二十分钟,超过这个时间就判定任务异常,写入日志并告警。这里还可以结合Guzzle的retry中间件,对网络类错误做有限次重试,但要注意重试不要叠加到业务轮询里,否则会重复请求。
4. 核心代码实现:音频文件到文字的完整链路
4.1 签名工具类
签名工具类很简单,两个静态方法就够了。上面代码里已经给出,这里补充一个生成HMAC签名原串的例子。不同版本接口对签名原串的格式要求不同,常见的格式是把请求方法、请求路径、日期时间、Content-Type拼成一个带换行的字符串,再用APISecret做HMAC-SHA256。我在项目里封装了一个方法,专门负责组装这个原串,方便按文档调整。
public static function buildSignatureOrigin(string $method, string $host, string $path, string $datetime): string { return "host: {$host}\n" . "date: {$datetime}\n" . "{$method} {$path} HTTP/1.1\n" . "content-type: application/json"; }4.2 LfasrClient主类实现
LfasrClient是封装的门面,负责上传音频、查询结果等核心操作。构造函数接收AppID、APIKey、APISecret,同时可以传入一个Guzzle客户端实例或配置数组,方便测试时mock。
<?php declare(strict_types=1); namespace YourName\XfyunLfasr; use GuzzleHttp\Client; use RuntimeException; class LfasrClient { private string $appId; private string $apiKey; private string $apiSecret; private Client $client; public function __construct( string $appId, string $apiKey, string $apiSecret, ?Client $client = null ) { $this->appId = $appId; $this->apiKey = $apiKey; $this->apiSecret = $apiSecret; $this->client = $client ?? new Client([ 'base_uri' => 'https://api.xfyun.cn', 'timeout' => 30, ]); } /** * 上传音频文件,返回任务ID */ public function upload(string $filePath): string { if (!is_file($filePath)) { throw new RuntimeException("音频文件不存在: {$filePath}"); } $ts = (string) time(); $signa = SignatureHelper::signByMd5($this->appId, $ts); $response = $this->client->post('/v1/service/v1/lfasr/upload', [ 'headers' => [ 'appId' => $this->appId, 'ts' => $ts, 'signa' => $signa, ], 'multipart' => [ [ 'name' => 'file', 'contents' => fopen($filePath, 'r'), 'filename' => basename($filePath), ], ], ]); $result = json_decode((string) $response->getBody(), true); if (($result['ok'] ?? -1) !== 0) { throw new RuntimeException('上传音频失败: ' . json_encode($result, JSON_UNESCAPED_UNICODE)); } return $result['data']['taskId'] ?? ''; } /** * 查询任务状态与转写结果 */ public function query(string $taskId): array { $ts = (string) time(); $signa = SignatureHelper::signByMd5($this->appId, $ts, $taskId); $response = $this->client->post('/v1/service/v1/lfasr/query', [ 'headers' => [ 'appId' => $this->appId, 'ts' => $ts, 'signa' => $signa, ], 'json' => [ 'taskId' => $taskId, ], ]); return json_decode((string) $response->getBody(), true); } }这里需要说明两点。第一,上传文件时multipart里的filename要带上扩展名,服务端可能通过扩展名判断音频格式。第二,错误处理的关键是先把HTTP状态码和业务状态码分开判断,HTTP 200不代表业务成功,ok字段为0才是成功,这个误判是常见的联调坑。我团队里一个小伙伴就因为这个原因,把上传失败当成功,拿着空taskId去轮询,白白排查了半天。
4.3 完整调用示例
下面是examples/transcribe.php的代码,演示了从上传到轮询再到输出文本的完整调用过程:
<?php require __DIR__ . '/../vendor/autoload.php'; use YourName\XfyunLfasr\LfasrClient; $appId = '你的AppID'; $apiKey = '你的APIKey'; $apiSecret = '你的APISecret'; $client = new LfasrClient($appId, $apiKey, $apiSecret); $audioFile = $argv[1] ?? 'demo.mp3'; // 1. 上传 $taskId = $client->upload($audioFile); echo "任务ID: {$taskId}\n"; // 2. 轮询 $maxWait = 600; // 最长等10分钟 $interval = 2; $start = time(); while (true) { if (time() - $start > $maxWait) { throw new RuntimeException('任务处理超时'); } $result = $client->query($taskId); $status = $result['data']['status'] ?? -99; if ($status === 9) { $text = $result['data']['result'] ?? ''; echo "转写结果: {$text}\n"; break; } if ($status === -1) { throw new RuntimeException('任务处理失败: ' . json_encode($result, JSON_UNESCAPED_UNICODE)); } sleep($interval); $interval = min($interval * 2, 10); }这个脚本可以直接从命令行跑:php examples/transcribe.php meeting.mp3。日志输出不要用echo糊在所有代码里,实际项目建议接Monolog。我在示例里用echo只是为了降低阅读门槛,正式封装的LfasrClient本身不输出任何内容,把日志留给调用方去处理。
5. 中英文识别与音频格式兼容
5.1 language参数与中英文混合场景
讯飞长语音转写接口支持中英文识别,但默认不一定同时开启。某些接口版本需要在请求参数里显式指定识别语言,比如中文普通话对应zh_cn,英文对应en_us,也有支持混合识别的模式。如果你的音频里有中英文夹杂,比如技术会议录音、外贸客服对话,建议选择混合识别模式,否则可能把英文单词识别成不相关的中文拼音。
我在项目里留了一个language配置项,默认是zh_cn,同时把混合识别作为可选参数开放给调用方。测试下来,中英文混合模式下,人名、产品名、技术术语的识别准确率明显好于纯中文模式。如果你的业务里有大量专业术语,还可以用讯飞的"热词表"功能,把常见词预先上传,识别时会优先匹配,这个后面扩展部分再说。
5.2 音频格式与采样率:为什么用FFmpeg统一转成16kHz WAV
讯飞长语音转写对音频格式和采样率有明确要求,常见的mp3、wav、m4a、pcm都支持,但不同格式的识别准确率和兼容性差异不小。我实测下来,最稳的组合是16kHz采样率、单声道、16bit WAV格式。这种格式在语音识别领域几乎是"标准输入",服务端无需额外转码,特征提取损失最小,识别速度也快。
实际项目中,业务方丢过来的音频千奇百怪:有微信语音的m4a,有录音笔的wav,有电话合成音频的mp3,声道、采样率五花八门。我在封装里没有直接处理音频转换,而是建议在调用前用FFmpeg统一转码,命令很简单:
ffmpeg -i input.m4a -ar 16000 -ac 1 -acodec pcm_s16le output.wav-ar 16000表示采样率16kHz,-ac 1表示单声道,-acodec pcm_s16le表示16位little-endian编码。转成标准WAV之后再调用上传接口,能省掉很多奇怪的问题。如果你的项目不方便装FFmpeg,也可以用讯飞提供的一些音频转码SDK,但FFmpeg是跨平台最省事的方案。
5.3 超大音频的切分与合并策略
长语音转写接口对单文件时长和大小有限制,通常单个文件不能超过几十MB或几小时。如果录音文件超过限制,就需要先切分再分别转写,最后合并文本。
切分时要注意两个问题:一是切分点尽量选择静音段,避免在句子中间切断,否则前后两段的第一个字和最后一个字都可能识别不准;二是每段之间要留一点重叠,比如上一段末尾保留半秒到一秒的音频,写文本时再把重叠部分去重,这样能有效避免因切断导致的漏字。我用FFmpeg做静音检测,把音频按静音段切成若干片段,再逐段调用转写接口,最后按顺序拼接文本,效果比等间隔硬切好很多。
6. 实测中的坑与排查方案
6.1 鉴权失败的高频原因与排查链路
联调阶段遇到最多的报错就是鉴权失败,错误信息一般会直接提示signa校验不通过。我总结了三类高频原因,每类都踩过。
第一类是时间戳问题。ts用的不是当前Unix时间戳,或者服务器时间与标准时间偏差过大。这个问题的排查方法很简单:在服务器上执行date +%s看当前时间戳,再和在线时间戳工具对比,偏差超过一分钟就建议配置NTP自动同步。有一次生产环境服务器时间慢了好几分钟,所有请求全部鉴权失败,排查到最后才发现是服务器长时间没有同步时间。
第二类是签名串拼错。MD5签名方式是appId + ts,但有的接口文档要求appId + ts + taskId,少拼一个字段就必然报错。HMAC-SHA256方式更麻烦,签名原串里的大小写、换行符、空格都和最终签名强相关,多一个空格都过不了。我建议把签名函数写成单元测试,固定输入输出,用官方示例的appId和ts跑一遍,能过测试再接到主逻辑里。
第三类是参数位置放错。有的接口要求把appId、ts、signa放在请求头里,有的放在JSON body里。我在封装时专门写了不同版本的适配方法,统一对外暴露,内部按文档要求放到对应位置。
6.2 轮询卡住、结果延迟怎么处理
轮询长时间不结束,通常有两种情况:任务真的在排队处理,或者查询参数有问题导致永远查不到有效状态。
音频文件较大的时候,服务端需要排队解码、识别、后处理,等待时间长是正常的。我处理的办法是给轮询加总超时,同时把每次轮询的响应时间、状态变化记录到日志。如果任务状态长时间不变,且超过了预估时间,可以尝试用同一个音频文件重新提交任务,让服务端用新的taskId处理。
还有一次我遇到查询接口轮询一直返回ok: 0但status字段一直为0,排查后发现是我把查询请求JSON里的字段名写成了task_id,而接口要求的是taskId。这种大小写和命名风格不一致的问题,在联调阶段很容易被忽略,建议直接对照接口文档核对请求体。把查询请求和响应都打印到日志里,再比对文档,通常能找到问题。
6.3 并发限制与配额管理
讯飞开放平台的语音转写接口通常有并发和配额限制,比如同一AppID同时处理的音频任务数有限,单位时间内请求次数也有限。批量转写三百个文件时,如果一股脑全部提交任务,很快会触发限流,报"请求过多"或"并发超限"。
我的解决方案是在封装外面加一个简单的任务队列控制器:最大同时提交的任务数设置成3,其余任务排队,等前面的任务进入终态并释放配额后再提交下一个。用PHP数组实现生产者消费者模型比想象中简单,关键是信号量要控制好。另一个经验是尽量在业务低峰期跑批量任务,比如凌晨执行,配合定时任务,第二天早上直接取结果,这样既不会撞上白天的高峰限流,也不影响线上其他业务。
7. 项目落地效果与后续扩展思路
7.1 实测:三百个录音文件的转写结果
项目上线后,我拿渠道部的三百个客服录音文件做了实测。文件平均时长在8到15分钟之间,格式以mp3和m4a为主。用FFmpeg统一转成16kHz单声道WAV后,平均每个文件的上传和转写时间在3到8分钟左右,整体转写准确率按字符级对比,中文部分在90%以上,英文部分略低一些,但也能满足质检关键词检索的需求。
有一个细节值得注意:录音质量对识别结果影响极大。同样一段话,安静环境录的音频识别准确率明显高于带有背景音乐的录音。如果音频里有长时间的音乐或噪声,建议先用FFmpeg做降噪处理,比如用highpass和lowpass滤波器切掉非语音频段,能提升一定准确率。我后来在转码脚本里默认加了简单的降噪参数,识别结果的可用度提高不少。
7.2 扩展方向:热词、标点、Webhook回调
这个项目目前只实现了核心的转写能力,但实际上还可以做很多扩展。讯飞开放平台支持热词表和个性化词典,把业务里的常见词、人名、产品名、地名预先上传,识别时会优先匹配,能显著提升专业术语的准确率。如果你处理的是客服领域录音,建议把商品名称、优惠活动关键词、客服人员名单都加进去。
另外,标点预测也很实用。早期接口返回的文本不带标点,几乎是一整段话,后端做句子切分和关键词检索都比较困难。后来我发现接口支持标点预测能力,开启后返回的文本会自动加上逗号、句号、问号,文本可用性提升了一个档次。如果你的业务需要对转写文本做语义分析,标点预测建议开启。
Webhook回调是另一个值得做的方向。目前轮询方式是主动拉取,简单但费请求。讯飞一些接口支持回调通知,任务完成后服务端主动POST结果到指定URL。如果你们的服务有公网接口,可以改成回调模式,省去轮询开销,实时性也更好。我在后续版本里加了回调配置项,但现在还是以轮询为主,因为回调接口需要单独部署公网地址,不是所有项目都具备这个条件。
最后再分享一个小技巧:转写结果的文本有时候会带分句标记或置信度信息,不要直接当纯文本展示给用户,可以在入库前做一次清洗。我在项目里加了一个格式化器,把转写返回的JSON按句切分,用换行符分隔,前端展示时段落感清晰很多。这个细节不起眼,但对最终体验影响很明显。实测下来,用户对"带段落、带标点、可以按句检索"的转写文本满意度,远高于一大坨连续字符。
本文还有配套的精品资源,点击获取