Initial commit

This commit is contained in:
TYt50
2026-01-17 16:34:39 +08:00
commit 73b8a6d23f
72 changed files with 45972 additions and 0 deletions

309
modules/tts/tts-player.js Normal file
View File

@@ -0,0 +1,309 @@
/**
* TTS 队列播放器
*/
export class TtsPlayer {
constructor() {
this.queue = [];
this.currentAudio = null;
this.currentItem = null;
this.currentStream = null;
this.currentCleanup = null;
this.isPlaying = false;
this.onStateChange = null; // 回调:(state, item, info) => void
}
/**
* 入队
* @param {Object} item - { id, audioBlob, text? }
* @returns {boolean} 是否成功入队重复id会跳过
*/
enqueue(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
// 防重复
if (item.id && this.queue.some(q => q.id === item.id)) {
return false;
}
this.queue.push(item);
this._notifyState('enqueued', item);
if (!this.isPlaying) {
this._playNext();
}
return true;
}
/**
* 清空队列并停止播放
*/
clear() {
this.queue = [];
this._stopCurrent(true);
this.currentItem = null;
this.isPlaying = false;
this._notifyState('cleared', null);
}
/**
* 获取队列长度
*/
get length() {
return this.queue.length;
}
/**
* 立即播放(打断队列)
* @param {Object} item
*/
playNow(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
this.queue = [];
this._stopCurrent(true);
this._playItem(item);
return true;
}
/**
* 切换播放(同一条则暂停/继续)
* @param {Object} item
*/
toggle(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
if (this.currentItem?.id === item.id && this.currentAudio) {
if (this.currentAudio.paused) {
this.currentAudio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
this._notifyState('blocked', item);
});
} else {
this.currentAudio.pause();
}
return true;
}
return this.playNow(item);
}
_playNext() {
if (this.queue.length === 0) {
this.isPlaying = false;
this.currentItem = null;
this._notifyState('idle', null);
return;
}
const item = this.queue.shift();
this._playItem(item);
}
_playItem(item) {
this.isPlaying = true;
this.currentItem = item;
this._notifyState('playing', item);
if (item.streamFactory) {
this._playStreamItem(item);
return;
}
const url = URL.createObjectURL(item.audioBlob);
const audio = new Audio(url);
this.currentAudio = audio;
this.currentCleanup = () => {
URL.revokeObjectURL(url);
};
audio.onloadedmetadata = () => {
this._notifyState('metadata', item, { duration: audio.duration || 0 });
};
audio.ontimeupdate = () => {
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
};
audio.onplay = () => {
this._notifyState('playing', item);
};
audio.onpause = () => {
if (!audio.ended) this._notifyState('paused', item);
};
audio.onended = () => {
this.currentCleanup?.();
this.currentCleanup = null;
this.currentAudio = null;
this.currentItem = null;
this._notifyState('ended', item);
this._playNext();
};
audio.onerror = (e) => {
console.error('[TTS Player] 播放失败:', e);
this.currentCleanup?.();
this.currentCleanup = null;
this.currentAudio = null;
this.currentItem = null;
this._notifyState('error', item);
this._playNext();
};
audio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
this._notifyState('blocked', item);
this._playNext();
});
}
_playStreamItem(item) {
let objectUrl = '';
let mediaSource = null;
let sourceBuffer = null;
let streamEnded = false;
let hasError = false;
const queue = [];
const stream = item.streamFactory();
this.currentStream = stream;
const audio = new Audio();
this.currentAudio = audio;
const cleanup = () => {
if (this.currentAudio) {
this.currentAudio.pause();
}
this.currentAudio = null;
this.currentItem = null;
this.currentStream = null;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = '';
}
};
this.currentCleanup = cleanup;
const pump = () => {
if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) {
if (streamEnded && sourceBuffer && !sourceBuffer.updating && queue.length === 0) {
try {
if (mediaSource?.readyState === 'open') mediaSource.endOfStream();
} catch {}
}
return;
}
const chunk = queue.shift();
if (chunk) {
try {
sourceBuffer.appendBuffer(chunk);
} catch (err) {
handleStreamError(err);
}
}
};
const handleStreamError = (err) => {
if (hasError) return;
if (this.currentItem !== item) return;
hasError = true;
console.error('[TTS Player] 流式播放失败:', err);
try { stream?.abort?.(); } catch {}
cleanup();
this.currentCleanup = null;
this._notifyState('error', item);
this._playNext();
};
mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
audio.src = objectUrl;
mediaSource.addEventListener('sourceopen', () => {
if (hasError) return;
if (this.currentItem !== item) return;
try {
const mimeType = stream?.mimeType || 'audio/mpeg';
if (!MediaSource.isTypeSupported(mimeType)) {
throw new Error(`不支持的流式音频类型: ${mimeType}`);
}
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
sourceBuffer.mode = 'sequence';
sourceBuffer.addEventListener('updateend', pump);
} catch (err) {
handleStreamError(err);
return;
}
const append = (chunk) => {
if (hasError) return;
queue.push(chunk);
pump();
};
const end = () => {
streamEnded = true;
pump();
};
const fail = (err) => {
handleStreamError(err);
};
Promise.resolve(stream?.start?.(append, end, fail)).catch(fail);
});
audio.onloadedmetadata = () => {
this._notifyState('metadata', item, { duration: audio.duration || 0 });
};
audio.ontimeupdate = () => {
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
};
audio.onplay = () => {
this._notifyState('playing', item);
};
audio.onpause = () => {
if (!audio.ended) this._notifyState('paused', item);
};
audio.onended = () => {
if (this.currentItem !== item) return;
cleanup();
this.currentCleanup = null;
this._notifyState('ended', item);
this._playNext();
};
audio.onerror = (e) => {
console.error('[TTS Player] 播放失败:', e);
handleStreamError(e);
};
audio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
try { stream?.abort?.(); } catch {}
cleanup();
this._notifyState('blocked', item);
this._playNext();
});
}
_stopCurrent(abortStream = false) {
if (abortStream) {
try { this.currentStream?.abort?.(); } catch {}
}
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
}
this.currentCleanup?.();
this.currentCleanup = null;
this.currentStream = null;
}
_notifyState(state, item, info = null) {
if (typeof this.onStateChange === 'function') {
try { this.onStateChange(state, item, info); } catch (e) {}
}
}
}