Files
qingdao-employment-service/hook/piper-sdk.js
2025-12-07 17:06:20 +08:00

214 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PiperTTS SDK - 兼容移动端的流式语音合成客户端
* 特性:
* 1. Web Audio API 实时调度,解决移动端不支持 MSE 的问题
* 2. 头部注入 (Header Injection) 技术,解决分片解码错误
* 3. 自动状态管理与事件回调
*/
export class PiperTTS {
constructor(config = {}) {
this.baseUrl = config.baseUrl || 'http://localhost:5001';
this.audioCtx = config.audioCtx || new(window.AudioContext || window.webkitAudioContext)();
this.onStatus = config.onStatus || ((msg, type) => console.log(`[Piper] ${msg}`));
this.onStart = config.onStart || (() => {});
this.onEnd = config.onEnd || (() => {});
// 内部状态
this.ws = null;
this.nextTime = 0; // 下一段音频的预定播放时间
this.audioHeader = null; // 保存WAV/MP3头部
this.chunkQueue = []; // 数据缓冲队列
this.queueSize = 0; // 当前缓冲区字节数
this.analyser = null; // 可视化分析器节点
// 配置参数
this.flushThreshold = 8 * 1024; // 8KB 阈值
}
/**
* [重要] 初始化音频引擎
* 必须在用户点击事件click/touch中调用一次否则手机上没声音
*/
async init() {
if (this.audioCtx.state === 'suspended') {
await this.audioCtx.resume();
this.onStatus('音频引擎已激活', 'success');
}
}
/**
* 绑定可视化分析器
* @param {AnalyserNode} analyserNode - Web Audio Analyser节点
*/
attachVisualizer(analyserNode) {
this.analyser = analyserNode;
}
/**
* 开始合成并播放
* @param {string} text - 要合成的文本
* @param {object} options - 可选参数 {speaker_id, noise_scale, etc.}
*/
speak(text, options = {}) {
if (!text) return;
this.stop(); // 清理上一次播放
this.onStatus('正在建立连接...', 'processing');
try {
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/synthesize';
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.onStatus('连接成功,请求生成...', 'processing');
// 初始化时间轴:当前时间 + 缓冲延迟
this.nextTime = this.audioCtx.currentTime + 0.1;
this.onStart();
this.ws.send(
JSON.stringify({
text: text,
speaker_id: options.speakerId || null,
length_scale: options.lengthScale || 1.0,
noise_scale: options.noiseScale || 0.667,
})
);
};
this.ws.onmessage = (event) => this._handleMessage(event);
this.ws.onclose = async () => {
// 处理剩余残余数据
if (this.chunkQueue.length > 0) {
await this._processQueue(true);
}
this.onStatus('播放结束', 'success');
this.onEnd();
};
this.ws.onerror = (err) => {
console.error(err);
this.onStatus('连接发生错误', 'error');
};
} catch (e) {
this.onStatus(`启动失败: ${e.message}`, 'error');
}
}
/**
* 停止播放并重置状态
*/
stop() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
// 重置缓冲
this.chunkQueue = [];
this.queueSize = 0;
this.audioHeader = null;
// 注意Web Audio API 很难"立即停止"已经在 flight 中的 node
// 除非我们追踪所有的 sourceNode 并调用 .stop()。
// 简单实现suspend 再 resume 或者关闭 context (不推荐频繁关闭)。
// 这里的 stop 主要停止数据接收。
}
// --- 内部私有方法 ---
async _handleMessage(event) {
if (!(event.data instanceof ArrayBuffer)) return;
const chunk = event.data;
// 1. 捕获头部 (Header Injection 核心)
if (!this.audioHeader) {
// 截取前100字节作为通用头
this.audioHeader = chunk.slice(0, 100);
}
// 2. 入队
this.chunkQueue.push(chunk);
this.queueSize += chunk.byteLength;
// 3. 达到阈值则解码播放
if (this.queueSize >= this.flushThreshold) {
await this._processQueue();
}
}
async _processQueue(isLast = false) {
if (this.chunkQueue.length === 0) return;
// 1. 合并 Buffer
const rawData = new Uint8Array(this.queueSize);
let offset = 0;
for (const chunk of this.chunkQueue) {
rawData.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
// 清空队列
this.chunkQueue = [];
this.queueSize = 0;
try {
// 2. 构造带头部的 Buffer
let decodeTarget;
// 简单的头部检测逻辑,如果没有头,就拼上去
if (this.audioHeader && !this._hasHeader(rawData)) {
const newBuffer = new Uint8Array(this.audioHeader.byteLength + rawData.byteLength);
newBuffer.set(new Uint8Array(this.audioHeader), 0);
newBuffer.set(rawData, this.audioHeader.byteLength);
decodeTarget = newBuffer.buffer;
} else {
decodeTarget = rawData.buffer;
}
// 3. 解码
const decodedBuffer = await this.audioCtx.decodeAudioData(decodeTarget);
// 4. 播放调度
this._scheduleBuffer(decodedBuffer);
} catch (err) {
// 解码失败处理:如果是中间数据,放回队列头部等待拼接
if (!isLast) {
this.chunkQueue.unshift(rawData);
this.queueSize += rawData.byteLength;
} else {
console.warn('最后一段数据解码失败,丢弃', err);
}
}
}
_scheduleBuffer(decodedBuffer) {
const source = this.audioCtx.createBufferSource();
source.buffer = decodedBuffer;
// 连接可视化
if (this.analyser) {
source.connect(this.analyser);
this.analyser.connect(this.audioCtx.destination);
} else {
source.connect(this.audioCtx.destination);
}
// 计算播放时间:如果发生卡顿,立即播放;否则无缝衔接
const scheduleTime = Math.max(this.audioCtx.currentTime, this.nextTime);
source.start(scheduleTime);
// 更新下一段的开始时间
this.nextTime = scheduleTime + decodedBuffer.duration;
}
_hasHeader(uint8Arr) {
if (uint8Arr.byteLength < 4) return false;
// Check "RIFF" (WAV)
if (uint8Arr[0] === 82 && uint8Arr[1] === 73 && uint8Arr[2] === 70) return true;
// Check "ID3" (MP3)
if (uint8Arr[0] === 73 && uint8Arr[1] === 68 && uint8Arr[2] === 51) return true;
// Check MP3 Sync Word (Simplify)
if (uint8Arr[0] === 0xff && (uint8Arr[1] & 0xe0) === 0xe0) return true;
return false;
}
}