url = 'https://api.deepseek.com/chat/completions'; $this->api_key = env('DEEPSEEK_API_KEY'); $this->headers = [ 'Authorization' => 'Bearer '.$this->api_key, 'Content-Type' => 'application/json; charset=UTF-8' ]; } // 与推理模型对话 public function chatWithReasoner($data) { $content = getProp($data, 'content'); $model = getProp($data, 'model', 'r1'); $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat'; // 是否启用情感 $enable_emotion = getProp($data, 'enable_emotion', 0); if ($enable_emotion) { $sys_content = '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词{情感}”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选 2.非对话部分请全部用旁白角色代替 3.情感必须在【通用、开心、悲伤、生气、害怕、厌恶、惊讶】中选一个,不得使用其他词语'; }else { $sys_content = '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选 2.非对话部分请全部用旁白角色代替'; } $messages = [ [ 'role' => 'system', 'content' => $sys_content ], [ 'role' => 'user', 'content' => $content ] ]; $post_data = [ 'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat 'messages' => $messages, 'max_tokens' => 8192, 'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 // 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 'response_format' => [ 'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式 ], 'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。 ]; $client = new Client(['timeout' => 1200, 'verify' => false]); $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); $update_data = []; $content = ''; if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) { $content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : ''; $update_data = [ 'role' => 'assistant', 'content' => $response_arr['choices'][0]['message']['content'], 'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : [] ]; } // 处理获取到的剧本数据 $script_content = handleScriptWords($content, $enable_emotion); $result = [ 'origin_content' => $content, 'roles' => getProp($script_content, 'roles'), 'words' => getProp($script_content, 'words'), ]; return $result; } public function saveParagraphAudio($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); $sequence = getProp($data, 'sequence'); $id = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->where('sequence', $sequence)->value('id'); $list = [ 'bid' => $bid, 'cid' => $cid, 'version_id' => $version_id, 'sequence' => $sequence, 'role' => getProp($data, 'role'), 'gender' => getProp($data, 'gender'), 'text' => trim(getProp($data, 'text')), 'emotion' => getProp($data, 'emotion'), 'emotion_type' => getProp($data, 'emotion_type'), 'voice_type' => getProp($data, 'voice_type'), 'voice_name' => getProp($data, 'voice_name'), 'speed_ratio' => getProp($data, 'speed_ratio'), 'loudness_ratio'=> getProp($data, 'loudness_ratio'), 'emotion_scale' => getProp($data, 'emotion_scale'), 'updated_at' => date('Y-m-d H:i:s') ]; if (getProp($data, 'paragraph_audio_url')) $list['paragraph_audio_url'] = getProp($data, 'paragraph_audio_url'); if ($id) { $boolen = DB::table('mp_chapter_paragraph_audios')->where('id', $id)->update($list); }else { $list['created_at'] = date('Y-m-d H:i:s'); $boolen = DB::table('mp_chapter_paragraph_audios')->insert($list); } return $boolen; } // 新增合成任务 public function addGenerateTask($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); $generate_json = getProp($data, 'generate_json'); // 获取已生成的音频 $paragraph_audios = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->get(); $paragraph_list = []; foreach($paragraph_audios as $item) { $paragraph_list[getProp($item, 'sequence')] = [ 'role' => getProp($item, 'role'), 'gender' => getProp($item, 'gender'), 'text' => trim(getProp($item, 'text')), 'emotion' => getProp($item, 'emotion'), 'emotion_type' => getProp($item, 'emotion_type'), 'voice_type' => getProp($item, 'voice_type'), 'voice_name' => getProp($item, 'voice_name'), 'speed_ratio' => getProp($item, 'speed_ratio'), 'loudness_ratio' => getProp($item, 'loudness_ratio'), 'emotion_scale' => getProp($item, 'emotion_scale'), 'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'), ]; } // 更新角色-音色信息 $existed_role_info = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->value('role_info'); $existed_role_info = json_decode($existed_role_info, true); if ($existed_role_info) $existed_roles = array_keys($existed_role_info); else $existed_roles = []; // 获取情感信息 $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray(); $emotion_list = array_flip($emotion_list); // 构造生成音频的json $words = json_decode($generate_json, true); // 最终合成前的参数组 $mp_chapter_paragraph_audios = []; $sequence = 1; foreach($words as &$word) { if (!isset($word['text']) || !isset($word['emotion']) || !isset($word['voice_type']) || !isset($word['voice_name']) || !isset($word['speed_ratio']) || !isset($word['loudness_ratio']) || !isset($word['emotion_scale'])) Utils::throwError('20003:参数格式有误'); if (!($word['text']) || !($word['voice_type']) || !($word['voice_name']) || !($word['speed_ratio']) || !($word['loudness_ratio']) || !($word['emotion_scale'])) Utils::throwError('20003:参数不得为空'); $role = getProp($word, 'role'); $word['gender'] = (int)$word['gender']; if (isset($emotion_list[getProp($word, 'emotion')])) { // 如果有对应情感则赋值,没有则默认为中性(neutral) $word['emotion_type'] = $emotion_list[getProp($word, 'emotion')]; }else { $word['emotion'] = '中性'; $word['emotion_type'] = 'neutral'; } if (!in_array($role, $existed_roles)) { $existed_role_info[$role] = [ 'timbre_type' => $word['voice_type'], 'timbre_name' => $word['voice_name'], ]; } $word['paragraph_audio_url'] = ''; // 判断生成参数是否相同,相同则直接使用已生成的音频 $paragraph = isset($paragraph_list[$sequence]) ? $paragraph_list[$sequence] : []; // 如果音频存在并且参数都未改变则使用已生成的音频 if (getProp($paragraph, 'paragraph_audio_url')) { if (getProp($paragraph, 'role') == getProp($word, 'role') && getProp($paragraph, 'text') == getProp($word, 'text') && getProp($paragraph, 'emotion_type') == getProp($word, 'emotion_type') && getProp($paragraph, 'voice_type') == getProp($word, 'voice_type') && getProp($paragraph, 'speed_ratio') == getProp($word, 'speed_ratio') && getProp($paragraph, 'loudness_ratio') == getProp($word, 'loudness_ratio') && getProp($paragraph, 'emotion_scale') == getProp($word, 'emotion_scale')) { $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url'); } } // 组装章节分句音频数据 $tmp = $word; $tmp['bid'] = $bid; $tmp['version_id'] = $version_id; $tmp['cid'] = $cid; $tmp['sequence'] = $sequence; $tmp['created_at'] = date('Y-m-d H:i:s'); $tmp['updated_at'] = date('Y-m-d H:i:s'); $mp_chapter_paragraph_audios[] = $tmp; $sequence++; } $generate_json = json_encode($words, 256); try { DB::beginTransaction(); $role_info = json_encode($existed_role_info, 256); $boolen = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->update(['role_info' => $role_info, 'updated_at' => date('Y-m-d H:i:s')]); if (!$boolen) { DB::rollBack(); Utils::throwError('20003:更新角色信息失败'); } $count = DB::table('mp_audio_tasks')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->count('id'); $chapter_audio = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->first(); if (!$count) { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】'; }else { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')'; } $boolen1 = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->update(['generate_status'=>'制作中', 'generate_json' => $generate_json, 'updated_at' => date('Y-m-d H:i:s')]); if (!$boolen1) { DB::rollBack(); Utils::throwError('20003:更新生成数据失败'); } $boolen2 = DB::table('mp_audio_tasks')->insert([ 'audio_id' => getProp($chapter_audio, 'id'), 'status' => '执行中', 'generate_json' => $generate_json, 'bid' => $bid, 'book_name' => getProp($chapter_audio, 'book_name'), 'version_id' => $version_id, 'version_name' => getProp($chapter_audio, 'version_name'), 'cid' => $cid, 'chapter_name' => getProp($chapter_audio, 'chapter_name'), 'task_name' => $task_name, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$boolen2) { DB::rollBack(); Utils::throwError('20003:创建任务失败'); } // 删除章节分句音频数据并重新插入 DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->delete(); $boolen3 = DB::table('mp_chapter_paragraph_audios')->insert($mp_chapter_paragraph_audios); if (!$boolen3) { DB::rollBack(); Utils::throwError('20003:更新章节分句音频失败'); } } catch (\Exception $e) { DB::rollBack(); Utils::throwError('20003:'.$e->getMessage()); } DB::commit(); return true; } public function timbreList($data) { $gender = getProp($data, 'gender'); $timbre_name = getProp($data, 'timbre_name'); $query = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_name as voice_name', 'timbre_type as voice_type', 'gender'); if ($gender) { $query->where('gender', $gender); } if ($timbre_name) { $query->where('timbre_name', 'like', "%{$timbre_name}%"); } $list = $query->get()->map(function ($value) { $value = (array)$value; $value['voice_name'] = str_replace('(多情感)', '', $value['voice_name']); return $value; })->toArray(); return $list; } // 生成火山临时token public function setStsToken() { // ************* 配置参数 ************* $method = 'GET'; $service = 'sts'; $host = 'open.volcengineapi.com'; $region = env('VOLC_REGION'); $endpoint = 'https://open.volcengineapi.com'; // $endpoint = 'https://tos-cn-beijing.volces.com'; $access_key = env('VOLC_AK'); $secret_key = env('VOLC_SK'); // 获取缓存中的token,如果没有则请求接口 $token = Redis::get('volc_sts_token'); if (!$token) { // 查询参数 $query_parameters = [ 'Action' => 'AssumeRole', 'RoleSessionName' => 'user@zw', 'RoleTrn' => 'trn:iam::2102575520:role/tos_role', 'Version' => '2018-01-01' ]; // 生成URL编码的查询字符串 $request_parameters = http_build_query($query_parameters, '', '&', PHP_QUERY_RFC3986); // 获取签名头信息 $headers = $this->getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key); // 构建完整URL $request_url = $endpoint . '?' . $request_parameters; $client = new Client(['verify' => false]); $response = $client->get($request_url, ['headers' => $headers]); $response_arr = json_decode($response->getBody()->getContents(), true); $result = [ 'SessionToken' => isset($response_arr['Result']['Credentials']['SessionToken']) ? $response_arr['Result']['Credentials']['SessionToken'] : '', 'AccessKeyId' => isset($response_arr['Result']['Credentials']['AccessKeyId']) ? $response_arr['Result']['Credentials']['AccessKeyId'] : '', 'SecretAccessKey' => isset($response_arr['Result']['Credentials']['SecretAccessKey']) ? $response_arr['Result']['Credentials']['SecretAccessKey'] : '', 'Region' => env('VOLC_REGION'), 'Endpoint' => env('VOLC_END_POINT'), 'Bucket' => env('VOLC_BUCKET'), ]; // 缓存token Redis::setex('volc_sts_token', 3000, json_encode($result)); return $result; } else { return json_decode($token, true); } // $response = $response['Response']; // $access_key = $response['Credentials']['AccessKeyId']; // $secret_key = $response['Credentials']['AccessKeySecret']; // $security_token = $response['Credentials']['SecurityToken']; // $expiration = $response['Credentials']['Expiration']; // dd($response_arr); } private function sign($key, $msg) { return hash_hmac('sha256', $msg, $key, true); } // 生成签名密钥 private function getSignatureKey($key, $dateStamp, $regionName, $serviceName) { $kDate = $this->sign($key, $dateStamp); $kRegion = $this->sign($kDate, $regionName); $kService = $this->sign($kRegion, $serviceName); $kSigning = $this->sign($kService, 'request'); return $kSigning; } // 获取签名头信息 private function getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key) { $contenttype = 'application/x-www-form-urlencoded'; $accept = 'application/json'; // 获取当前UTC时间 $t = new DateTime('now', new DateTimeZone('UTC')); $xdate = $t->format('Ymd\THis\Z'); $datestamp = $t->format('Ymd'); // 1. 拼接规范请求串 $canonical_uri = '/'; $canonical_querystring = $request_parameters; $canonical_headers = "content-type:{$contenttype}\nhost:{$host}\nx-date:{$xdate}\n"; $signed_headers = 'content-type;host;x-date'; // 空请求体的SHA256哈希 $payload_hash = hash('sha256', ''); $canonical_request = implode("\n", [ $method, $canonical_uri, $canonical_querystring, $canonical_headers, $signed_headers, $payload_hash ]); // 2. 拼接待签名字符串 $algorithm = 'HMAC-SHA256'; $credential_scope = implode('/', [$datestamp, $region, $service, 'request']); $hashed_canonical_request = hash('sha256', $canonical_request); $string_to_sign = implode("\n", [ $algorithm, $xdate, $credential_scope, $hashed_canonical_request ]); // 3. 计算签名 $signing_key = $this->getSignatureKey($secret_key, $datestamp, $region, $service); $signature = hash_hmac('sha256', $string_to_sign, $signing_key); // 4. 添加签名到请求头 $authorization_header = sprintf( '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', $algorithm, $access_key, $credential_scope, $signed_headers, $signature ); return [ 'Accept' => $accept, 'Content-Type' => $contenttype, 'X-Date' => $xdate, 'Authorization' => $authorization_header ]; } // 文字合成语音(火山引擎) public function tts($data) { $url = 'https://openspeech.bytedance.com/api/v1/tts'; $headers = [ 'Authorization' => 'Bearer;'.env('VOLC_TOKEN'), 'Content-Type' => 'application/json; charset=UTF-8' ]; $post_data = [ 'app' => [ 'appid' => env('VOLC_APPID'), 'token' => env('VOLC_TOKEN'), 'cluster' => 'volcano_tts' ], 'user' => [ 'uid' => 'mp_audio' ], // 'audio' => [ // 'voice_type' => // ], ]; } public function generateScriptWords($cid, $model = 'r1') { ini_set('max_execution_time', 0); if (!$cid) Utils::throwError('20003: 请选择章节!'); $content = DB::table('chapters as c')->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')->where('c.id', $cid)->value('cc.content'); $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat'; $messages = [ [ 'role' => 'system', 'content' => '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男或女):台词{情感}”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加不包括旁白的性别,在男或女中选 2.非对话部分请全部用旁白角色代替 3.情感必须在【开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦】中选一个,不得使用其他词语' ], [ 'role' => 'user', 'content' => $content ] ]; $post_data = [ 'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat 'messages' => $messages, 'max_tokens' => 8192, 'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。 // 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。 'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。 'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。 'response_format' => [ 'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式 ], 'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。 ]; $client = new Client(['timeout' => 1200, 'verify' => false]); $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); $update_data = []; $content = ''; if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) { $content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : ''; $update_data = [ 'role' => 'assistant', 'content' => $response_arr['choices'][0]['message']['content'], 'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : [] ]; } return $content; } }