直播行业对实时字幕的需求越来越迫切,一方面是听障用户的无障碍访问要求,另一方面是跨境直播场景下多语种观众的理解需求。快手AI开放平台提供了语音识别相关的能力接口,PHP作为大量直播系统后端的常用语言,完全可以通过合理的架构设计完成音频流的实时转写和多语种字幕分发。本文将从接口对接、音频流处理、多语种翻译到前端推送,完整拆解这套方案的实现过程。

一、整体架构设计与准备工作
在动手写代码之前,先明确整条数据链路的走向。主播端的音频流(通常是AAC或Opus编码)由流媒体服务器(如SRS或Nginx-RTMP模块)分流出来,PHP进程通过FFmpeg拉取音频并解码成PCM裸流,按固定时长切片后送入快手AI的语音识别接口。识别接口返回带时间戳的文本片段,PHP再调用翻译接口生成目标语言版本,最后通过WebSocket服务把字幕推送给观众端播放器。
这套架构里PHP承担的是调度和编排角色,真正耗CPU的音频解码交给FFmpeg子进程,耗网络的识别请求则交给PHP的协程或异步客户端。环境方面建议使用PHP 8.1以上版本,配合Swoole扩展处理并发,用Composer安装GuzzleHTTP作为备用HTTP客户端。还需要在快手AI开放平台创建应用,拿到App Key和App Secret,用于生成调用凭证。
音频切片的长度是个需要权衡的参数。切片太短(低于200毫秒)会导致识别接口收到大量碎片化音频,准确率下降明显;切片太长(超过2秒)又会造成字幕延迟,观众体验变差。实践中有两种常见策略:一种是600毫秒固定切片,实现简单、延迟可控;另一种是基于VAD(语音活动检测)的动态切片,在检测到停顿时才切一刀,识别效果更好但实现复杂。对绝大多数直播场景,固定600毫秒切片已经够用。
二、音频流采集与FFmpeg解码
PHP本身处理音频的能力很弱,所以解码工作全部交给FFmpeg。通过proc_open启动一个FFmpeg子进程,从流媒体服务器拉取音频流并输出16kHz、16位、单声道的PCM数据,这是绝大多数语音识别接口的标准输入格式。下面是核心的进程管理代码:
<?php
class AudioStreamCapture
{
private $process;
private $pipes;
public function __construct(string $streamUrl)
{
// 拉取直播流,只取音频轨,转成16k单声道PCM
$cmd = "ffmpeg -i {$streamUrl} -vn -ac 1 -ar 16000 "
. "-f s16le -acodec pcm_s16le - 2>/dev/null";
$descriptors = [
0 => ["pipe", "r"],
1 => ["pipe", "w"],
2 => ["pipe", "w"]
];
$this->process = proc_open($cmd, $descriptors, $this->pipes);
}
// 每次读取固定时长的音频数据(600ms约19200字节)
public function readChunk(): string
{
return fread($this->pipes[1], 16000 * 2 * 0.6);
}
public function close(): void
{
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_terminate($this->process);
}
}
注意计算一下字节数:采样率16000Hz、16位(2字节)、单声道,一秒钟就是32000字节,600毫秒自然是19200字节。用fread读取指定长度可以保证每次拿到的都是完整的一片段。FFmpeg进程要做好守护,一旦直播流中断导致进程退出,需要有重连机制,可以在外层用while循环检测进程状态并重新拉起,重连间隔建议设置3到5秒,避免对流媒体服务器造成冲击。
另一个容易踩的坑是音频流的中断检测。直播中途主播推流暂停时,fread会阻塞等待数据,这本身没问题,但如果流彻底结束,FFmpeg进程退出而管道缓冲区还有残留数据,需要先把管道读空再判断进程状态,否则会丢掉最后一段字幕。
三、对接快手AI语音识别接口
拿到PCM切片后,下一步是调用快手AI的语音识别接口。大多数此类接口采用分片上传模式:第一次请求带上会话初始化参数,后续请求持续追加音频分片,接口逐次返回已识别的文本。请求签名一般基于HMAC-SHA256,把App Key、时间戳、随机数和请求体一起签名后放在Header里。下面给出封装好的调用类:
<?php
class KuaishouAsrClient
{
private string $appKey;
private string $appSecret;
private string $apiBase = "https://ai.kuaishou.com/api/v1/asr";
public function __construct(string $appKey, string $appSecret)
{
$this->appKey = $appKey;
$this->appSecret = $appSecret;
}
private function sign(string $body, int $ts): string
{
$raw = $this->appKey . $ts . $body;
return hash_hmac("sha256", $raw, $this->appSecret);
}
public function recognizeChunk(string $audio, string $sessionId): array
{
$ts = time();
$body = json_encode([
"session_id" => $sessionId,
"audio_format" => "pcm",
"sample_rate" => 16000,
"audio_data" => base64_encode($audio)
]);
$ch = curl_init($this->apiBase);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-App-Key: {$this->appKey}",
"X-Timestamp: {$ts}",
"X-Signature: {$this->sign($body, $ts)}"
]
]);
$resp = curl_exec($ch);
curl_close($ch);
return json_decode($resp, true) ?: [];
}
}
识别结果通常包含is_final标记和句级置信度。流式识别有个特点:同一句话会随着后续音频的到来不断被修正,前几次返回的可能是半截句子,最后一次才是完整版本。前端渲染时要处理这种"部分结果覆盖"的逻辑,PHP侧也需要维护一个会话内的结果缓冲区,用句子起始时间戳做去重键,收到is_final为真的结果后才把该句写入最终字幕队列。
网络抖动是实时字幕的头号敌人。识别请求失败时不能简单丢弃该分片,否则字幕会出现明显的空洞。建议为每个分片维护一个待确认队列,请求失败时指数退避重试两到三次,仍失败则把该分片与下一个分片合并后重发,利用识别接口对上下文的记忆能力弥补损失。
四、多语种翻译与字幕流分发
识别出中文文本后,多语种字幕的生成依赖翻译接口。这里的性能瓶颈很明显:如果同步串行调用四五个语言的翻译接口,每个请求300毫秒,字幕延迟会累加到秒级以上,完全不可接受。解决方案是并行请求。如果使用Swoole,可以直接用协程并发;如果坚持FPM环境,可以用curl_multi实现并发请求。下面是Swoole协程版本的示例:
<?php
use Swoole\Coroutine as Co;
class SubtitleTranslator
{
private array $langs = ["en", "ja", "ko"];
public function translateAll(string $text): array
{
$results = [];
$wg = new Co\WaitGroup();
foreach ($this->langs as $lang) {
$wg->add();
Co::create(function () use ($text, $lang, &$results, $wg) {
$results[$lang] = $this->requestTranslate($text, $lang);
$wg->done();
});
}
$wg->wait(3.0); // 最多等待3秒,超时的语言放弃本句
return $results;
}
private function requestTranslate(string $text, string $lang): string
{
$client = new Co\Http\Client("translate-api.example.internal", 443, true);
$client->post("/v1/translate", json_encode([
"text" => $text,
"target" => $lang
]));
$data = json_decode($client->body, true);
return $data["translated"] ?? "";
}
}
翻译完成后,字幕通过WebSocket分发。观众端在建立连接时上报自己的语言偏好,服务端按语言维度维护订阅组,一条中文识别结果翻译完成后,打包成带时间戳的JSON消息推送给对应的订阅组。消息结构建议包含四个字段:句子的起始毫秒数、结束毫秒数、语言代码和文本内容,前端据此做滚动渲染和时间轴对齐。
还有一个细节值得关注:字幕的时效性优先级高于完整性。如果某句翻译因为网络问题延迟了五秒才返回,这句字幕再推给前端已经没有意义,甚至会造成画面和字幕错位的观感。因此在分发队列里要设置过期时间,超过当前直播时间三秒的字幕直接丢弃,宁可少一句也不能乱序。
五、稳定性保障与监控
长时间运行的直播字幕服务,稳定性设计不能省。首先是内存问题,PHP的FPM模型不适合这种常驻任务,务必用Swoole或CLI常驻进程来跑,同时给识别会话缓冲区和字幕分发队列设置容量上限,防止某场超长直播把内存吃爆。其次是进程守护,推荐用Supervisor管理字幕进程,异常退出后自动拉起,并配合日志记录每次重连和接口失败的原因,方便事后排查。
监控指标方面,重点盯三个数字:识别请求的P95延迟、字幕从产生到推送的整体延迟、以及各语言翻译的成功率。一旦整体延迟超过两秒,说明链路某处出现积压,需要告警介入。可以每隔一段时间向字幕流注入一条心跳消息,前端收到心跳但收不到字幕时也能主动上报异常,形成端到端的可观测性闭环。把这套体系搭建完善后,PHP完全能撑起一套稳定的多语种实时直播字幕服务。