Initial commit
This commit is contained in:
390
modules/tts/tts-free-provider.js
Normal file
390
modules/tts/tts-free-provider.js
Normal file
@@ -0,0 +1,390 @@
|
||||
import { synthesizeFreeV1, FREE_VOICES, FREE_DEFAULT_VOICE } from './tts-api.js';
|
||||
import { normalizeEmotion, splitTtsSegmentsForFree } from './tts-text.js';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAYS = [500, 1000, 2000];
|
||||
|
||||
const activeQueueManagers = new Map();
|
||||
|
||||
function normalizeSpeed(value) {
|
||||
const num = Number.isFinite(value) ? value : 1.0;
|
||||
if (num >= 0.5 && num <= 2.0) return num;
|
||||
return Math.min(2.0, Math.max(0.5, 1 + num / 100));
|
||||
}
|
||||
|
||||
function generateBatchId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function estimateDuration(text) {
|
||||
return Math.max(2, Math.ceil(String(text || '').length / 4));
|
||||
}
|
||||
|
||||
function resolveFreeVoiceByName(speakerName, mySpeakers, defaultSpeaker) {
|
||||
if (!speakerName) return defaultSpeaker;
|
||||
const list = Array.isArray(mySpeakers) ? mySpeakers : [];
|
||||
|
||||
const byName = list.find(s => s.name === speakerName);
|
||||
if (byName?.value) return byName.value;
|
||||
|
||||
const byValue = list.find(s => s.value === speakerName);
|
||||
if (byValue?.value) return byValue.value;
|
||||
|
||||
const isFreeVoice = FREE_VOICES.some(v => v.key === speakerName);
|
||||
if (isFreeVoice) return speakerName;
|
||||
|
||||
return defaultSpeaker;
|
||||
}
|
||||
|
||||
class SegmentQueueManager {
|
||||
constructor(options) {
|
||||
const { player, messageId, batchId, totalSegments } = options;
|
||||
|
||||
this.player = player;
|
||||
this.messageId = messageId;
|
||||
this.batchId = batchId;
|
||||
this.totalSegments = totalSegments;
|
||||
|
||||
this.segments = Array(totalSegments).fill(null).map((_, i) => ({
|
||||
index: i,
|
||||
status: 'pending',
|
||||
audioBlob: null,
|
||||
text: '',
|
||||
retryCount: 0,
|
||||
error: null,
|
||||
retryTimer: null,
|
||||
}));
|
||||
|
||||
this.nextEnqueueIndex = 0;
|
||||
this.onSegmentReady = null;
|
||||
this.onSegmentSkipped = null;
|
||||
this.onRetryNeeded = null;
|
||||
this.onComplete = null;
|
||||
this.onProgress = null;
|
||||
this._completed = false;
|
||||
this._destroyed = false;
|
||||
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
get signal() {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
|
||||
markLoading(index) {
|
||||
if (this._destroyed) return;
|
||||
const seg = this.segments[index];
|
||||
if (seg && seg.status === 'pending') {
|
||||
seg.status = 'loading';
|
||||
}
|
||||
}
|
||||
|
||||
setReady(index, audioBlob, text = '') {
|
||||
if (this._destroyed) return;
|
||||
const seg = this.segments[index];
|
||||
if (!seg) return;
|
||||
|
||||
seg.status = 'ready';
|
||||
seg.audioBlob = audioBlob;
|
||||
seg.text = text;
|
||||
seg.error = null;
|
||||
|
||||
this.onSegmentReady?.(index, seg);
|
||||
this._tryEnqueueNext();
|
||||
}
|
||||
|
||||
setFailed(index, error) {
|
||||
if (this._destroyed) return false;
|
||||
const seg = this.segments[index];
|
||||
if (!seg) return false;
|
||||
|
||||
seg.retryCount++;
|
||||
seg.error = error;
|
||||
|
||||
if (seg.retryCount >= MAX_RETRIES) {
|
||||
seg.status = 'skipped';
|
||||
this.onSegmentSkipped?.(index, seg);
|
||||
this._tryEnqueueNext();
|
||||
return false;
|
||||
}
|
||||
|
||||
seg.status = 'pending';
|
||||
const delay = RETRY_DELAYS[seg.retryCount - 1] || 2000;
|
||||
|
||||
seg.retryTimer = setTimeout(() => {
|
||||
seg.retryTimer = null;
|
||||
if (!this._destroyed) {
|
||||
this.onRetryNeeded?.(index, seg.retryCount);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_tryEnqueueNext() {
|
||||
if (this._destroyed) return;
|
||||
|
||||
while (this.nextEnqueueIndex < this.totalSegments) {
|
||||
const seg = this.segments[this.nextEnqueueIndex];
|
||||
|
||||
if (seg.status === 'ready' && seg.audioBlob) {
|
||||
this.player.enqueue({
|
||||
id: `msg-${this.messageId}-batch-${this.batchId}-seg-${seg.index}`,
|
||||
messageId: this.messageId,
|
||||
segmentIndex: seg.index,
|
||||
batchId: this.batchId,
|
||||
audioBlob: seg.audioBlob,
|
||||
text: seg.text,
|
||||
});
|
||||
seg.status = 'enqueued';
|
||||
this.nextEnqueueIndex++;
|
||||
this.onProgress?.(this.getStats());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seg.status === 'skipped') {
|
||||
this.nextEnqueueIndex++;
|
||||
this.onProgress?.(this.getStats());
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
this._checkCompletion();
|
||||
}
|
||||
|
||||
_checkCompletion() {
|
||||
if (this._completed || this._destroyed) return;
|
||||
if (this.nextEnqueueIndex >= this.totalSegments) {
|
||||
this._completed = true;
|
||||
this.onComplete?.(this.getStats());
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let ready = 0, skipped = 0, pending = 0, loading = 0, enqueued = 0;
|
||||
for (const seg of this.segments) {
|
||||
switch (seg.status) {
|
||||
case 'ready': ready++; break;
|
||||
case 'enqueued': enqueued++; break;
|
||||
case 'skipped': skipped++; break;
|
||||
case 'loading': loading++; break;
|
||||
default: pending++; break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: this.totalSegments,
|
||||
enqueued,
|
||||
ready,
|
||||
skipped,
|
||||
pending,
|
||||
loading,
|
||||
nextEnqueue: this.nextEnqueueIndex,
|
||||
completed: this._completed
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
|
||||
try {
|
||||
this.abortController.abort();
|
||||
} catch {}
|
||||
|
||||
for (const seg of this.segments) {
|
||||
if (seg.retryTimer) {
|
||||
clearTimeout(seg.retryTimer);
|
||||
seg.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.onComplete = null;
|
||||
this.onSegmentReady = null;
|
||||
this.onSegmentSkipped = null;
|
||||
this.onRetryNeeded = null;
|
||||
this.onProgress = null;
|
||||
this.segments = [];
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllFreeQueues() {
|
||||
for (const qm of activeQueueManagers.values()) {
|
||||
qm.destroy();
|
||||
}
|
||||
activeQueueManagers.clear();
|
||||
}
|
||||
|
||||
export function clearFreeQueueForMessage(messageId) {
|
||||
const qm = activeQueueManagers.get(messageId);
|
||||
if (qm) {
|
||||
qm.destroy();
|
||||
activeQueueManagers.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speakMessageFree(options) {
|
||||
const {
|
||||
messageId,
|
||||
segments,
|
||||
defaultSpeaker = FREE_DEFAULT_VOICE,
|
||||
mySpeakers = [],
|
||||
player,
|
||||
config,
|
||||
tryLoadLocalCache,
|
||||
storeLocalCache,
|
||||
buildCacheKey,
|
||||
updateState,
|
||||
clearMessageFromQueue,
|
||||
mode = 'auto',
|
||||
} = options;
|
||||
|
||||
if (!segments?.length) return { success: false };
|
||||
|
||||
clearFreeQueueForMessage(messageId);
|
||||
|
||||
const freeSpeed = normalizeSpeed(config?.volc?.speechRate);
|
||||
const splitSegments = splitTtsSegmentsForFree(segments);
|
||||
|
||||
if (!splitSegments.length) return { success: false };
|
||||
|
||||
const batchId = generateBatchId();
|
||||
|
||||
if (mode === 'manual') clearMessageFromQueue?.(messageId);
|
||||
|
||||
updateState?.({
|
||||
status: 'sending',
|
||||
text: splitSegments.map(s => s.text).join('\n').slice(0, 200),
|
||||
textLength: splitSegments.reduce((sum, s) => sum + s.text.length, 0),
|
||||
cached: false,
|
||||
error: '',
|
||||
duration: splitSegments.reduce((sum, s) => sum + estimateDuration(s.text), 0),
|
||||
currentSegment: 0,
|
||||
totalSegments: splitSegments.length,
|
||||
});
|
||||
|
||||
const queueManager = new SegmentQueueManager({
|
||||
player,
|
||||
messageId,
|
||||
batchId,
|
||||
totalSegments: splitSegments.length
|
||||
});
|
||||
|
||||
activeQueueManagers.set(messageId, queueManager);
|
||||
|
||||
const fetchSegment = async (index) => {
|
||||
if (queueManager._destroyed) return;
|
||||
|
||||
const segment = splitSegments[index];
|
||||
if (!segment) return;
|
||||
|
||||
queueManager.markLoading(index);
|
||||
|
||||
updateState?.({
|
||||
currentSegment: index + 1,
|
||||
status: 'sending',
|
||||
});
|
||||
|
||||
const emotion = normalizeEmotion(segment.emotion);
|
||||
const voiceKey = segment.resolvedSpeaker
|
||||
|| (segment.speaker
|
||||
? resolveFreeVoiceByName(segment.speaker, mySpeakers, defaultSpeaker)
|
||||
: (defaultSpeaker || FREE_DEFAULT_VOICE));
|
||||
|
||||
const cacheParams = {
|
||||
providerMode: 'free',
|
||||
text: segment.text,
|
||||
speaker: voiceKey,
|
||||
freeSpeed,
|
||||
emotion: emotion || '',
|
||||
};
|
||||
|
||||
if (tryLoadLocalCache) {
|
||||
try {
|
||||
const cacheHit = await tryLoadLocalCache(cacheParams);
|
||||
if (cacheHit?.entry?.blob) {
|
||||
queueManager.setReady(index, cacheHit.entry.blob, segment.text);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const { audioBase64 } = await synthesizeFreeV1({
|
||||
text: segment.text,
|
||||
voiceKey,
|
||||
speed: freeSpeed,
|
||||
emotion: emotion || null,
|
||||
}, { signal: queueManager.signal });
|
||||
|
||||
if (queueManager._destroyed) return;
|
||||
|
||||
const byteString = atob(audioBase64);
|
||||
const bytes = new Uint8Array(byteString.length);
|
||||
for (let j = 0; j < byteString.length; j++) {
|
||||
bytes[j] = byteString.charCodeAt(j);
|
||||
}
|
||||
const audioBlob = new Blob([bytes], { type: 'audio/mpeg' });
|
||||
|
||||
if (storeLocalCache && buildCacheKey) {
|
||||
const cacheKey = buildCacheKey(cacheParams);
|
||||
storeLocalCache(cacheKey, audioBlob, {
|
||||
text: segment.text.slice(0, 200),
|
||||
textLength: segment.text.length,
|
||||
speaker: voiceKey,
|
||||
resourceId: 'free',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
queueManager.setReady(index, audioBlob, segment.text);
|
||||
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError' || queueManager._destroyed) {
|
||||
return;
|
||||
}
|
||||
queueManager.setFailed(index, err);
|
||||
}
|
||||
};
|
||||
|
||||
queueManager.onRetryNeeded = (index, retryCount) => {
|
||||
fetchSegment(index);
|
||||
};
|
||||
|
||||
queueManager.onSegmentReady = (index, seg) => {
|
||||
const stats = queueManager.getStats();
|
||||
updateState?.({
|
||||
currentSegment: stats.enqueued + stats.ready,
|
||||
status: stats.enqueued > 0 ? 'queued' : 'sending',
|
||||
});
|
||||
};
|
||||
|
||||
queueManager.onSegmentSkipped = (index, seg) => {
|
||||
};
|
||||
|
||||
queueManager.onProgress = (stats) => {
|
||||
updateState?.({
|
||||
currentSegment: stats.enqueued,
|
||||
totalSegments: stats.total,
|
||||
});
|
||||
};
|
||||
|
||||
queueManager.onComplete = (stats) => {
|
||||
if (stats.enqueued === 0) {
|
||||
updateState?.({
|
||||
status: 'error',
|
||||
error: '全部段落请求失败',
|
||||
});
|
||||
}
|
||||
activeQueueManagers.delete(messageId);
|
||||
queueManager.destroy();
|
||||
};
|
||||
|
||||
for (let i = 0; i < splitSegments.length; i++) {
|
||||
fetchSegment(i);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export { FREE_VOICES, FREE_DEFAULT_VOICE };
|
||||
Reference in New Issue
Block a user