<?php
namespace app\common\service;
use think\Cache;
use think\Log;
/**
* 百度语音合成(ThinkPHP5 版)
* 密钥通过后台配置读取
*/
class BaiduTts
{
const TOKEN_CACHE_KEY = 'baidu_tts_token';
const TOKEN_TTL = 7000;
private static $instance = null;
private $appId;
private $apiKey;
private $secretKey;
public static function instance()
{
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct($appId, $apiKey, $secretKey)
{
// 从后台读取
$this->appId=$appId;
$this->apiKey= $apiKey;
$this->secretKey=$secretKey;
if (!$this->appId || !$this->apiKey || !$this->secretKey) {
throw new \Exception('请在后台配置百度语音合成密钥');
}
}
/**
* 通过密钥生成单例
*/
public static function create($appId, $apiKey, $secretKey): self
{
// 支持多租户:用密钥 md5 做区分
$key = 'baidu_tts_' . md5($appId . $apiKey);
if (!isset(self::$instance[$key])) {
self::$instance[$key] = new self($appId, $apiKey, $secretKey);
}
return self::$instance[$key];
}
/* 主入口 */
public function synthesis(string $text, string $saveDir = ''): string
{
$text = $this->plainText($text); // 先净化
if ($text === '') {
throw new \Exception('合成文本不能为空');
}
$saveDir = $saveDir ?: RUNTIME_PATH . 'tts' . DS;
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$mp3File = $saveDir . md5($text) . '.mp3';
if (is_file($mp3File)) {
return $mp3File;
}
$token = $this->getAccessToken();
$this->doSynthesis($text, $token, $mp3File);
return $mp3File;
}
/* 获取/刷新 token */
private function getAccessToken(): string
{
$token = Cache::get(self::TOKEN_CACHE_KEY);
if ($token) return $token;
$url = 'https://aip.baidubce.com/oauth/2.0/token?' . http_build_query([
'grant_type' => 'client_credentials',
'client_id' => $this->apiKey,
'client_secret' => $this->secretKey,
]);
$resp = json_decode(file_get_contents($url), true);
if (empty($resp['access_token'])) {
Log::error('BaiduTts getAccessToken failed: ' . json_encode($resp));
throw new \Exception('获取百度 access_token 失败');
}
$token = $resp['access_token'];
Cache::set(self::TOKEN_CACHE_KEY, $token, self::TOKEN_TTL);
return $token;
}
/* 真正合成 */
private function doSynthesis(string $text, string $token, string $saveFile): void
{
$api = 'https://tsn.baidu.com/text2audio';
$param = [
'tok' => $token,
'tex' => $text,
'cuid' => md5(uniqid()),
'ctp' => 1,
'lan' => 'zh',
'aue' => 3,
'per' => 0,
'spd' => 5,
'pit' => 5,
'vol' => 5,
];
$audio = $this->httpPost($api, http_build_query($param));
if (strpos($audio, '{') === 0) {
$err = json_decode($audio, true);
Log::error('BaiduTts synthesis failed: ' . $audio);
throw new \Exception('合成失败:' . ($err['err_msg'] ?? '未知'));
}
file_put_contents($saveFile, $audio);
}
private function httpPost(string $url, string $body): string
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$resp = curl_exec($ch);
if ($resp === false) {
throw new \Exception('Curl Error: ' . curl_error($ch));
}
curl_close($ch);
return $resp;
}
/**
* 把文字处理成适合 TTS 朗读的内容
* 1. 去 HTML 标签 & 实体 2. 去多余空白 3. 去不可见符号
* 4. 把常见符号转成口语
*/
private function plainText(string $raw): string
{
// 先统一编码
$raw = mb_convert_encoding($raw, 'UTF-8', 'UTF-8');
/* --- 1. 剥掉所有 HTML 标签 & 实体 --- */
$raw = strip_tags($raw);
$raw = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
/* --- 2. 删掉非打印字符(除了空格、换行) --- */
$raw = preg_replace('/[\x{00}-\x{08}\x{0B}-\x{0C}\x{0E}-\x{1F}\x{7F}]+/u', '', $raw);
/* --- 3. 把常见符号口语化 --- */
$map = [
'&' => '和',
'@' => '在',
'#' => '号',
'©' => '版权',
'®' => '注册商标',
'℃' => '摄氏度',
'¥' => '元',
'$' => '美元',
'%' => '百分之',
'°' => '度',
'+' => '加',
'=' => '等于',
'×' => '乘',
'÷' => '除以',
'>' => '大于',
'<' => '小于',
'/' => '每',
'\\' => '、',
'|' => '或',
'【' => '(',
'】' => ')',
'[' => '(',
']' => ')',
'{' => '(',
'}' => ')',
'(' => '(', // 全角括号保留
')' => ')',
'——' => ',', // 破折号
'—' => ',',
'…' => ',',
'·' => '点',
'•' => '点',
'※' => '星',
'①' => '1',
'②' => '2',
'③' => '3',
'④' => '4',
'⑤' => '5',
'⑥' => '6',
'⑦' => '7',
'⑧' => '8',
'⑨' => '9',
'⑩' => '10',
];
$raw = strtr($raw, $map);
/* --- 4. 连续空格、制表、换行 合并成单个空格 --- */
$raw = preg_replace('/\s+/u', ' ', $raw);
/* --- 5. 去掉首尾空格 --- */
return trim($raw);
}
}
下面是调用代码
$text = $this->request->param('text','','trim');
if (!$text){
return json(['code'=>0,'msg'=>'请输入要合成的文本']);
}
try{
$site = Config::get("site");
$baidu = \app\common\service\BaiduTts::create(
$site['baidu_api_id'],
$site['baidu_api_key'],
$site['baidu_secret_key']
);
$mp3 = $baidu->synthesis($text);
header('Content-Type: audio/mpeg');
header('Content-Disposition: inline; filename="' . basename($mp3) . '"');
readfile($mp3);
exit;
}catch (\Exception $e){
return json(['code'=>0,'msg'=>$e->getMessage()]);
}
相关文章