UNPKG

esp-ai

Version:

Provide a complete set of AI dialogue solutions for your development board, including but not limited to the IAT+LLM+TTS integration solution for the ESP32 series development board. | 为你的开发板提供全套的AI对话方案,包括但不限于 `ESP32` 系列开发板的 `IAT+LLM+TTS` 集成方案。

231 lines (210 loc) 8.49 kB
/** * Copyright (c) 2024 小明IO * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Commercial use of this software requires prior written authorization from the Licensor. * 请注意:将 ESP-AI 代码用于商业用途需要事先获得许可方的授权。 * 删除与修改版权属于侵权行为,请尊重作者版权,避免产生不必要的纠纷。 * * @author 小明IO * @email 1746809408@qq.com * @github https://github.com/wangzongming/esp-ai * @websit https://espai.fun */ const https = require('https'); const http = require('http'); const log = require('../utils/log'); const { PassThrough } = require('stream'); const moment = require('moment'); function isHttpUrl(url) { const regex = /^https?:\/\/.+/; return regex.test(url); } function isHttpsUrl(url) { const regex = /^https:\/\/.+/; return regex.test(url); } function urlToStream(url, errCb, onEnd) { try { const stream = new PassThrough(); const hf = isHttpsUrl(url) ? https : http; hf.get(url, { timeout: 100000, }, (response) => { // console.log(response) if (response.statusCode !== 200) { log.error(`音频地址不可用:${url}`) stream.emit('error', new Error(`Request failed with status code ${response.statusCode}`)); return; } response.pipe(stream); }).on('error', (err) => { errCb && errCb(); log.error(`音频播放错误:${err}`) stream.emit('error', err); }).on("close", () => { log.t_info('音频下载完毕'); onEnd && onEnd(); }); return stream; } catch (err) { console.log(err) log.error(`音频播放错误: ${err}`) errCb && errCb(); } } /** * 播放任何 mp3、wav 的声音 * 提供 http 地址 */ function play_audio(url, client_ws, task_id, session_id, device_id, seek, on_end) { let checkBufferedAmount = null; try { if (!isHttpUrl(url)) { log.error("play_audio 不支持本地地址!") return; } client_ws && client_ws.send(JSON.stringify({ type: "session_status", status: "tts_chunk_start", })); if (!G_devices.get(device_id)) return; // 结束时一定要调用本函数 let ended = false; async function endCb(event = "play_end") { if (!G_devices.get(device_id)) return; if (ended) return; ended = true; const { start_audio_time } = G_devices.get(device_id); // 真正结束 const end_time = Date.now(); // 结束时间 const play_time = end_time - start_audio_time; // 播放时间 const end_res = on_end && await on_end({ start_time: start_audio_time, end_time: end_time, play_time: Math.floor(play_time / 1000), break_second: Math.floor(seek + play_time / 1000), event, seek }) // 特殊发一组标志 client_ws.send(Buffer.from(G_session_ids.tts_all_end, 'utf-8')); G_devices.set(device_id, { ...G_devices.get(device_id), first_session: true, play_audio_ing: false, play_audio_on_end: null, }) if (end_res?.url) { play_audio(end_res?.url, client_ws, task_id, session_id, device_id, end_res?.seek, on_end); } } G_devices.set(device_id, { ...G_devices.get(device_id), play_audio_ing: true, play_audio_on_end: (...arg) => { client_ws.send(Buffer.from(G_session_ids.tts_all_end, 'utf-8')); return on_end && on_end(...arg); }, play_audio_seek: seek || 0, }) client_ws.send(JSON.stringify({ type: "play_audio", tts_task_id: task_id || "any_audio" })); let is_parse_over = false; const output_stream = urlToStream(url, () => endCb("error"), () => { is_parse_over = true; } ); let buffer_queue = []; let is_sending = false; let is_first_on_data = true; const session_id_buffer = Buffer.from(session_id, 'utf-8'); output_stream.on('data', function (audio) { if (is_first_on_data) { if (!G_devices.get(device_id)) return; is_first_on_data = false; G_devices.set(device_id, { ...G_devices.get(device_id), start_audio_time: Date.now(), }) } let c_l = G_max_audio_chunk_size; for (let i = 0; i < audio.length; i += c_l) { if (!G_devices.get(device_id)) return; const { session_id: now_session_id } = G_devices.get(device_id); if ((session_id && now_session_id !== session_id)) { log.t_info("用户终止流") buffer_queue = []; output_stream.end(); break; } const end = Math.min(i + c_l, audio.length); const chunk = audio.slice(i, end); if (!(Buffer.isBuffer(chunk))) { log.t_info(`跳过无效 chunk: ${i}`); continue; } buffer_queue.push(chunk); !is_sending && sendNextChunk(); } }); // 处理发送缓冲区是否清空 function sendNextChunk() { if (buffer_queue.length > 0) { if (!G_devices.get(device_id)) return; const { session_id: now_session_id } = G_devices.get(device_id); if ((session_id && now_session_id !== session_id)) { log.t_info("用户终止对话,清空音频缓冲区") buffer_queue = []; return; } is_sending = true; const send_chunk = buffer_queue.shift(); const real_chunk = Buffer.concat([session_id_buffer, send_chunk]) // console.log("发送音频:", moment().format("HH:mm:ss:sss"), real_chunk.length, real_chunk); client_ws.send(real_chunk, (err) => { if (is_parse_over) { if (!G_devices.get(device_id)) return; if (buffer_queue.length === 0) { endCb(); } } if (err) { console.error('发送数据出错:', err); } // 等待 WebSocket 缓冲区清空后继续发送 clearInterval(checkBufferedAmount); checkBufferedAmount = setInterval(() => { if (client_ws.bufferedAmount === 0) { clearInterval(checkBufferedAmount); is_sending = false; sendNextChunk(); } }, 10); }); } } } catch (err) { clearInterval(checkBufferedAmount); if (!G_devices.get(device_id)) return; G_devices.set(device_id, { ...G_devices.get(device_id), play_audio_ing: false, play_audio_on_end: null, }) log.error("音频播放失败:" + err); } } module.exports = play_audio;