|
|
@@ -5097,8 +5097,13 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
// 构建完整的提示词
|
|
|
- $supportAudioReference = $this->isActReferenceAudioSupported($model);
|
|
|
- $processResult = $this->processActContentWithProducts($actContent, $products, $supportAudioReference);
|
|
|
+ // 参考音频开关:调用方传 enable_reference_audio 优先,未传则读 ENABLE_REFERENCE_AUDIO(默认开启)
|
|
|
+ $supportAudioReference = $this->isReferenceAudioEnabled($data) && $this->isActReferenceAudioSupported($model);
|
|
|
+ // 先在提示词转换之前完成参考音频校验与截断(超出模型段数/时长上限的角色回退使用音色提示词)
|
|
|
+ $audioPlan = $supportAudioReference
|
|
|
+ ? $this->resolveActReferenceAudios($actContent, $products, $model)
|
|
|
+ : ['allowed' => [], 'excluded' => [], 'total_duration' => 0];
|
|
|
+ $processResult = $this->processActContentWithProducts($actContent, $products, $supportAudioReference, $audioPlan['allowed']);
|
|
|
$fullPrompt = $processResult['content'];
|
|
|
// if ($art_style_type == '3D真人风格') $fullPrompt = "美术风格: $art_style\n\n".$fullPrompt;
|
|
|
// 资产参考图与调用方传入的参考图合并后统一去重(保留资产参考图在前,不影响提示词中的<图片N>编号)
|
|
|
@@ -11309,7 +11314,7 @@ class AnimeService
|
|
|
* @param bool $supportAudioReference 模型是否支持参考音频;支持时角色音色优先使用 timbre_url
|
|
|
* @return array
|
|
|
*/
|
|
|
- public function processActContentWithProducts($actContent, $products, $supportAudioReference = false) {
|
|
|
+ public function processActContentWithProducts($actContent, $products, $supportAudioReference = false, $allowedAudioProducts = null) {
|
|
|
if (empty($actContent) || empty($products)) {
|
|
|
return [
|
|
|
'content' => $actContent,
|
|
|
@@ -11366,8 +11371,15 @@ class AnimeService
|
|
|
$voice_prompt = $product_dict[$product_name]['voice_prompt'];
|
|
|
$timbre_url = $product_dict[$product_name]['timbre_url'];
|
|
|
|
|
|
+ // 判断该角色的参考音频是否可用(模型支持 + 配置了音色 + 未超出模型段数/时长限制)
|
|
|
+ $audio_allowed = $supportAudioReference && !empty($timbre_url);
|
|
|
+ if ($audio_allowed && is_array($allowedAudioProducts)) {
|
|
|
+ $audio_allowed = isset($allowedAudioProducts[$product_name])
|
|
|
+ && $allowedAudioProducts[$product_name] === $timbre_url;
|
|
|
+ }
|
|
|
+
|
|
|
// 分配音频序号(与图片序号互相独立;相同音频URL复用同一序号,保证角色与音频一一对应)
|
|
|
- if ($supportAudioReference && !empty($timbre_url)) {
|
|
|
+ if ($audio_allowed) {
|
|
|
if (!in_array($timbre_url, $reference_audios)) {
|
|
|
$reference_audios[] = $timbre_url;
|
|
|
$product_audio_index_map[$product_name] = $current_audio_index;
|
|
|
@@ -11381,8 +11393,8 @@ class AnimeService
|
|
|
$audio_voice_fallbacks[$product_name] = $voice_prompt ?? '';
|
|
|
}
|
|
|
|
|
|
- // 处理voice_prompt:模型支持参考音频且角色有音频时优先使用音频,不再输出音色提示词(旁白是公共的在后面单独处理)
|
|
|
- if ((!$supportAudioReference || empty($timbre_url)) && !empty($voice_prompt) && $product_name !== '旁白') {
|
|
|
+ // 处理voice_prompt:有可用参考音频的角色优先使用音频,不再输出音色提示词(旁白是公共的在后面单独处理)
|
|
|
+ if (!$audio_allowed && !empty($voice_prompt) && $product_name !== '旁白') {
|
|
|
$voice_prompts[] = $product_name . ':' . $voice_prompt . "\n";
|
|
|
}
|
|
|
|
|
|
@@ -11446,10 +11458,10 @@ class AnimeService
|
|
|
// 第四步:将参考音频音色信息和voice_prompt信息添加到内容最前面
|
|
|
$voice_prompt_prefix = '';
|
|
|
|
|
|
- // 有参考音频的角色:声明"角色名 对应 音频N",统一使用<音频N>标记,
|
|
|
+ // 有参考音频的角色:按官方推荐句式"参考<音频N>中的音色"声明对应关系,
|
|
|
// 素材上传失败时与正文标记一起被清理/重排,保证角色与音频始终对应
|
|
|
foreach ($product_audio_index_map as $product_name => $audio_index) {
|
|
|
- $voice_prompt_prefix .= $product_name . ':全程使用<音频' . $audio_index . '>的音色进行配音' . "\n";
|
|
|
+ $voice_prompt_prefix .= '参考<音频' . $audio_index . '>中的音色,为' . $product_name . '配音' . "\n";
|
|
|
}
|
|
|
|
|
|
if (!empty($voice_prompts)) {
|
|
|
@@ -11578,6 +11590,33 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 是否启用参考音频(角色音色)
|
|
|
+ * 优先取调用方传入的 enable_reference_audio,未传时读取环境变量 ENABLE_REFERENCE_AUDIO,默认开启
|
|
|
+ *
|
|
|
+ * @param array $data 调用方入参
|
|
|
+ * @return bool
|
|
|
+ */
|
|
|
+ public function isReferenceAudioEnabled($data) {
|
|
|
+ // 环境变量兜底:兼容 true/false/1/0/on/off/yes/no 等写法,无法识别时按默认开启
|
|
|
+ $envValue = env('ENABLE_REFERENCE_AUDIO', true);
|
|
|
+ $envDefault = is_bool($envValue)
|
|
|
+ ? $envValue
|
|
|
+ : filter_var($envValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
|
+ if ($envDefault === null) {
|
|
|
+ $envDefault = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $value = getProp($data, 'enable_reference_audio', null);
|
|
|
+ if ($value === null || $value === '') {
|
|
|
+ return $envDefault;
|
|
|
+ }
|
|
|
+
|
|
|
+ $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
|
+
|
|
|
+ return $bool === null ? $envDefault : $bool;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 判断视频模型是否支持角色参考音频(音色)
|
|
|
* 目前支持:智帧统一API、Seedance 2.0 系列(国内百度/海外百度/快快AI)
|
|
|
*
|
|
|
@@ -11593,6 +11632,294 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 获取模型对参考音频的限制
|
|
|
+ * Seedance 2.0(含 2.0 lite / 2.0 fast):最多3段,单个[2,15]s,总时长不超过15s
|
|
|
+ * Seedance 2.5:最多10段,单个[2,30]s,总时长不超过30s
|
|
|
+ *
|
|
|
+ * @param string $model
|
|
|
+ * @return array|null 非参考音频模型返回 null
|
|
|
+ */
|
|
|
+ public function getActAudioLimits($model) {
|
|
|
+ if (!$this->isActReferenceAudioSupported($model)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (strpos($model, '2.5') !== false || strpos($model, '2-5') !== false) {
|
|
|
+ return ['max_count' => 10, 'max_single' => 30, 'max_total' => 30];
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['max_count' => 3, 'max_single' => 15, 'max_total' => 15];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成提示词之前解析可用的参考音频
|
|
|
+ * 按正文中{资产名}首次出现的顺序,逐个校验单个音频时长、段数与总时长,超出限制的部分直接截断;
|
|
|
+ * 被截断(或单个时长超限)的角色不会出现在提示词的<音频N>引用中,会自动回退使用音色提示词
|
|
|
+ *
|
|
|
+ * @param string $actContent 片段内容
|
|
|
+ * @param array $products 资产数组
|
|
|
+ * @param string $model 视频模型
|
|
|
+ * @return array ['allowed' => [产品名 => 音频URL], 'excluded' => [产品名 => 原因], 'total_duration' => 秒]
|
|
|
+ */
|
|
|
+ public function resolveActReferenceAudios($actContent, $products, $model) {
|
|
|
+ $result = ['allowed' => [], 'excluded' => [], 'total_duration' => 0];
|
|
|
+
|
|
|
+ $limits = $this->getActAudioLimits($model);
|
|
|
+ if (!$limits || empty($actContent) || empty($products)) {
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 产品名 => 音色音频
|
|
|
+ $timbreMap = [];
|
|
|
+ foreach ($products as $product) {
|
|
|
+ $name = getProp($product, 'product_name');
|
|
|
+ if (!$name) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $timbre = trim((string)getProp($product, 'timbre_url', ''));
|
|
|
+ if ($timbre !== '') {
|
|
|
+ $timbreMap[$name] = $timbre;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (empty($timbreMap)) {
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按正文中{资产名}首次出现的顺序确定音频编号顺序(与提示词生成保持一致)
|
|
|
+ preg_match_all('/\{([^}]+)\}/u', $actContent, $matches);
|
|
|
+ $orderedNames = [];
|
|
|
+ foreach (($matches[1] ?? []) as $name) {
|
|
|
+ if (!isset($orderedNames[$name])) {
|
|
|
+ $orderedNames[$name] = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $usedUrls = [];
|
|
|
+ $totalDuration = 0;
|
|
|
+ foreach (array_keys($orderedNames) as $name) {
|
|
|
+ $timbre = $timbreMap[$name] ?? '';
|
|
|
+ if ($timbre === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 相同音频复用同一段素材,不重复计数
|
|
|
+ if (isset($usedUrls[$timbre])) {
|
|
|
+ $result['allowed'][$name] = $timbre;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $duration = $this->getRemoteAudioDurationSeconds($timbre);
|
|
|
+
|
|
|
+ if ($duration > 0 && $duration > $limits['max_single']) {
|
|
|
+ $result['excluded'][$name] = '单个音频时长超限(' . $duration . 's > ' . $limits['max_single'] . 's)';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (count($usedUrls) >= $limits['max_count']) {
|
|
|
+ $result['excluded'][$name] = '超过参考音频段数上限(' . $limits['max_count'] . '段)';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if ($duration > 0 && ($totalDuration + $duration) > $limits['max_total']) {
|
|
|
+ $result['excluded'][$name] = '超过参考音频总时长上限(' . $limits['max_total'] . 's)';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $usedUrls[$timbre] = true;
|
|
|
+ $totalDuration += max(0, $duration);
|
|
|
+ $result['allowed'][$name] = $timbre;
|
|
|
+ }
|
|
|
+
|
|
|
+ $result['total_duration'] = round($totalDuration, 2);
|
|
|
+
|
|
|
+ if (!empty($result['excluded'])) {
|
|
|
+ dLog('anime')->info('参考音频超出模型限制,已截断', [
|
|
|
+ 'model' => $model,
|
|
|
+ 'limits' => $limits,
|
|
|
+ 'allowed' => array_keys($result['allowed']),
|
|
|
+ 'excluded' => $result['excluded'],
|
|
|
+ 'total_duration' => $result['total_duration'],
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 探测远程音频时长(wav 精确解析;mp3 按首帧码率估算)
|
|
|
+ *
|
|
|
+ * @param string $url 音频URL
|
|
|
+ * @return float 秒;无法探测时返回 0
|
|
|
+ */
|
|
|
+ public function getRemoteAudioDurationSeconds($url) {
|
|
|
+ try {
|
|
|
+ // 使用 Guzzle(不依赖 allow_url_fopen,LNMP 环境下同样可用)
|
|
|
+ $client = new \GuzzleHttp\Client(['verify' => false, 'timeout' => 20, 'connect_timeout' => 10]);
|
|
|
+ $response = $client->get($url, [
|
|
|
+ 'headers' => ['Range' => 'bytes=0-1048575'],
|
|
|
+ 'http_errors' => false,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $data = (string)$response->getBody();
|
|
|
+ if (strlen($data) < 64) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 文件总大小:206 时取 Content-Range 的分母,否则用 Content-Length
|
|
|
+ $totalSize = 0;
|
|
|
+ $contentRange = $response->getHeaderLine('Content-Range');
|
|
|
+ if ($contentRange !== '' && preg_match('#/(\d+)#', $contentRange, $m)) {
|
|
|
+ $totalSize = (int)$m[1];
|
|
|
+ } else {
|
|
|
+ $contentLength = $response->getHeaderLine('Content-Length');
|
|
|
+ if ($contentLength !== '') {
|
|
|
+ $totalSize = (int)$contentLength;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // WAV:解析 fmt/data chunk 精确计算
|
|
|
+ if (substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WAVE') {
|
|
|
+ $offset = 12;
|
|
|
+ $byteRate = 0;
|
|
|
+ $dataSize = 0;
|
|
|
+ while ($offset + 8 <= strlen($data)) {
|
|
|
+ $chunkId = substr($data, $offset, 4);
|
|
|
+ $chunkSize = unpack('V', substr($data, $offset + 4, 4))[1];
|
|
|
+ if ($chunkId === 'fmt ' && $offset + 8 + 12 <= strlen($data)) {
|
|
|
+ $byteRate = unpack('V', substr($data, $offset + 16, 4))[1];
|
|
|
+ } elseif ($chunkId === 'data') {
|
|
|
+ $dataSize = $chunkSize;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ $offset += 8 + $chunkSize + ($chunkSize % 2);
|
|
|
+ }
|
|
|
+ if ($byteRate > 0 && $dataSize > 0) {
|
|
|
+ return round($dataSize / $byteRate, 2);
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // MP3:优先 Xing/Info 帧数,其次逐帧统计,最后按码率估算
|
|
|
+ $scanStart = 0;
|
|
|
+ if (strlen($data) >= 10 && substr($data, 0, 3) === 'ID3') {
|
|
|
+ // 跳过 ID3v2 标签,避免命中标签数据中的伪同步字
|
|
|
+ $tagSize = ((ord($data[6]) & 0x7F) << 21) | ((ord($data[7]) & 0x7F) << 14)
|
|
|
+ | ((ord($data[8]) & 0x7F) << 7) | (ord($data[9]) & 0x7F);
|
|
|
+ $scanStart = 10 + $tagSize;
|
|
|
+ }
|
|
|
+
|
|
|
+ $mpeg1Layer3 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
|
|
|
+ $mpeg2Layer3 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
|
|
+ $sampleRatesV1 = [44100, 48000, 32000, 0];
|
|
|
+ $sampleRatesV2 = [22050, 24000, 16000, 0];
|
|
|
+
|
|
|
+ $parseFrame = function ($buffer, $offset) use ($mpeg1Layer3, $mpeg2Layer3, $sampleRatesV1, $sampleRatesV2) {
|
|
|
+ if ($offset < 0 || $offset + 4 > strlen($buffer)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (ord($buffer[$offset]) !== 0xFF) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $b1 = ord($buffer[$offset + 1]);
|
|
|
+ if (($b1 & 0xE0) !== 0xE0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $b2 = ord($buffer[$offset + 2]);
|
|
|
+ $b3 = ord($buffer[$offset + 3]);
|
|
|
+ $versionBits = ($b1 >> 3) & 0x03; // 3=MPEG1, 2=MPEG2, 0=MPEG2.5
|
|
|
+ $layerBits = ($b1 >> 1) & 0x03; // 1=Layer3
|
|
|
+ $bitrateIndex = ($b2 >> 4) & 0x0F;
|
|
|
+ $sampleIndex = ($b2 >> 2) & 0x03;
|
|
|
+ $channelMode = ($b3 >> 6) & 0x03; // 3=单声道
|
|
|
+ if ($versionBits === 1 || $layerBits !== 1 || $bitrateIndex === 0 || $bitrateIndex === 15 || $sampleIndex === 3) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $bitrate = $versionBits === 3 ? $mpeg1Layer3[$bitrateIndex] : $mpeg2Layer3[$bitrateIndex];
|
|
|
+ $rate = $versionBits === 3 ? $sampleRatesV1[$sampleIndex] : $sampleRatesV2[$sampleIndex];
|
|
|
+ if ($bitrate <= 0 || $rate <= 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $samplesPerFrame = $versionBits === 3 ? 1152 : 576;
|
|
|
+ $frameLen = (int)(($samplesPerFrame / 8 * $bitrate * 1000) / $rate);
|
|
|
+ if ($frameLen <= 4) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ 'version' => $versionBits,
|
|
|
+ 'bitrate' => $bitrate,
|
|
|
+ 'rate' => $rate,
|
|
|
+ 'samples' => $samplesPerFrame,
|
|
|
+ 'length' => $frameLen,
|
|
|
+ 'channel_mode' => $channelMode,
|
|
|
+ ];
|
|
|
+ };
|
|
|
+
|
|
|
+ // 定位首个有效帧,并校验下一帧也合法(避免命中伪同步字)
|
|
|
+ $firstFrame = null;
|
|
|
+ $frameOffset = -1;
|
|
|
+ for ($i = $scanStart; $i + 4 <= strlen($data); $i++) {
|
|
|
+ $frame = $parseFrame($data, $i);
|
|
|
+ if (!$frame) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $nextOffset = $i + $frame['length'];
|
|
|
+ if ($nextOffset + 4 <= strlen($data) && !$parseFrame($data, $nextOffset)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $firstFrame = $frame;
|
|
|
+ $frameOffset = $i;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (!$firstFrame) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1) Xing/Info 头包含总帧数,可直接精确计算
|
|
|
+ $sideInfoSize = $firstFrame['version'] === 3
|
|
|
+ ? ($firstFrame['channel_mode'] === 3 ? 17 : 32)
|
|
|
+ : ($firstFrame['channel_mode'] === 3 ? 9 : 17);
|
|
|
+ $xingOffset = $frameOffset + 4 + $sideInfoSize;
|
|
|
+ $xingTag = substr($data, $xingOffset, 4);
|
|
|
+ if (($xingTag === 'Xing' || $xingTag === 'Info') && strlen($data) >= $xingOffset + 12) {
|
|
|
+ $flags = unpack('N', substr($data, $xingOffset + 4, 4))[1];
|
|
|
+ if ($flags & 0x01) {
|
|
|
+ $frameCount = unpack('N', substr($data, $xingOffset + 8, 4))[1];
|
|
|
+ if ($frameCount > 0) {
|
|
|
+ return round($frameCount * $firstFrame['samples'] / $firstFrame['rate'], 2);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 文件不大时下载完整内容逐帧统计(精确)
|
|
|
+ $fullData = $data;
|
|
|
+ if ($totalSize > 0 && strlen($data) < $totalSize && $totalSize <= 10485760) {
|
|
|
+ $fullData = (string)$client->get($url, ['http_errors' => false])->getBody();
|
|
|
+ }
|
|
|
+ if ($totalSize > 0 && strlen($fullData) >= $totalSize) {
|
|
|
+ $samples = 0;
|
|
|
+ $i = $frameOffset;
|
|
|
+ $bufferLength = strlen($fullData);
|
|
|
+ while ($i + 4 <= $bufferLength) {
|
|
|
+ $frame = $parseFrame($fullData, $i);
|
|
|
+ if (!$frame) {
|
|
|
+ $i++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $samples += $frame['samples'];
|
|
|
+ $i += $frame['length'];
|
|
|
+ }
|
|
|
+ if ($samples > 0) {
|
|
|
+ return round($samples / $firstFrame['rate'], 2);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) 回退:按首帧码率估算(仅用于无法读取完整文件的极端情况)
|
|
|
+ return round(($totalSize * 8) / ($firstFrame['bitrate'] * 1000), 2);
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ dLog('anime')->warning('参考音频时长探测失败', ['url' => $url, 'error' => $e->getMessage()]);
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 将角色音色参考音频按模型分支上传为素材
|
|
|
* 上传失败时对应素材的<音频N>标记会从 $prompt 中清理(引用传递),与参考图处理保持一致
|
|
|
*
|
|
|
@@ -11646,18 +11973,18 @@ class AnimeService
|
|
|
$escaped_name = preg_quote($product_name, '/');
|
|
|
|
|
|
// 说明行中仍有<音频N>标记,说明该角色的音频素材可用,无需兜底
|
|
|
- if (preg_match('/^' . $escaped_name . ':全程使用<音频\d+>的音色进行配音[^\n]*$/mu', $prompt)) {
|
|
|
+ if (preg_match('/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*$/mu', $prompt)) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- $linePattern = '/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*$/mu';
|
|
|
+ $linePattern = '/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*$/mu';
|
|
|
if (!preg_match($linePattern, $prompt)) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if ($voice_prompt === '') {
|
|
|
// 该角色没有音色提示词,素材不可用时直接移除残留的说明行
|
|
|
- $prompt = preg_replace('/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*\n?/mu', '', $prompt, 1);
|
|
|
+ $prompt = preg_replace('/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*\n?/mu', '', $prompt, 1);
|
|
|
} else {
|
|
|
// 素材不可用:把说明行替换为该角色原本的音色提示词
|
|
|
$prompt = preg_replace_callback($linePattern, function () use ($product_name, $voice_prompt) {
|