mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 17:51:20 -08:00
fix: rename cleanupTimeout to hideTimeout for consistency in ASR plugin
Refactor artplayerPluginAsr: Enhance audio capture and processing - Introduced gainNode for audio output management. - Added mediaStream and mediaStreamSource for fallback audio capture. - Refactored startCapture and stopCapture functions for better clarity and error handling. - Improved audio source setup with a dedicated function for capturing audio from video. - Ensured proper disconnection of audio nodes and cleanup of resources on destroy. feat: enhance ASR plugin with improved subtitle handling and styling fix: rename autoClearTimer to hideTimer for consistency in ASR plugin Update asr.js feat: refactor ASR plugin to improve audio chunk handling and WebSocket management Update asr.js fix: rename autoClearTimeout to cleanupTimeout for consistency Update asr.js feat: refactor ASR handling and improve WebSocket management Update index.js feat: add append and clear functions to enhance audio chunk handling Update asr.js Update asr.js feat: release version 2.0.0 of artplayer-plugin-asr - Implemented core functionality for audio recording using AudioWorklet. - Added support for configurable audio capture interval and sample rate. - Introduced WAV encoding for recorded audio chunks. - Created a new plugin structure with proper TypeScript definitions. - Added legacy support for older environments. - Updated package.json with necessary metadata and export configurations. - Included comprehensive inline documentation for better maintainability.
This commit is contained in:
1 parent
6c4e9f7e78
commit
5d7710979d
22 files changed
+893
-1
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import style from 'bundle-text:./style.less';
|
||||
|
||||
export default function artplayerPluginAsr(option = {}) {
|
||||
const { hideTimeout = 10000, length = 3, interval = 100, sampleRate = 16000, onAudioChunk = () => null } = option;
|
||||
|
||||
return (art) => {
|
||||
let started = false;
|
||||
let audioCtx = null;
|
||||
let sourceNode = null;
|
||||
let recorderNode = null;
|
||||
let gainNode = null;
|
||||
let bufferChunks = [];
|
||||
let timer = null;
|
||||
let workletLoaded = false;
|
||||
let hideTimer = null;
|
||||
let mediaStream = null;
|
||||
let mediaStreamSource = null;
|
||||
|
||||
const $asr = art.layers.add({
|
||||
name: 'asr',
|
||||
html: '',
|
||||
});
|
||||
|
||||
function splitByPunctuation(text) {
|
||||
return text
|
||||
.split(/(?<=[、。!?!?\.])\s*/u)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-length);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
$asr.style.display = 'none';
|
||||
}
|
||||
|
||||
function append(subtitle) {
|
||||
if (typeof subtitle !== 'string') return;
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(hide, hideTimeout);
|
||||
$asr.style.display = '';
|
||||
$asr.innerHTML = splitByPunctuation(subtitle)
|
||||
.map((line) => `<div class="art-asr-line">${line}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
const recorderProcessorCode = `
|
||||
class RecorderProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input && input[0]) {
|
||||
this.port.postMessage(input[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('recorder-processor', RecorderProcessor);
|
||||
`;
|
||||
|
||||
const createWorkletBlobUrl = () => {
|
||||
const blob = new Blob([recorderProcessorCode], { type: 'application/javascript' });
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
function floatTo16BitPCM(float32Array) {
|
||||
const len = float32Array.length;
|
||||
const buffer = new ArrayBuffer(len * 2);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < len; i++) {
|
||||
let s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function setupAudioContext() {
|
||||
if (audioCtx) return audioCtx;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
|
||||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
async function setupAudioSource() {
|
||||
if (!sourceNode && art.video) {
|
||||
try {
|
||||
sourceNode = audioCtx.createMediaElementSource(art.video);
|
||||
return sourceNode;
|
||||
} catch (err) {
|
||||
console.warn('[artplayerPluginAsr] Direct connection failed, trying fallback...', err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mediaStream) {
|
||||
mediaStream = await captureAudioFromVideo(art.video);
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStreamSource = audioCtx.createMediaStreamSource(mediaStream);
|
||||
return mediaStreamSource;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[artplayerPluginAsr] MediaStream fallback failed:', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureAudioFromVideo(videoElement) {
|
||||
try {
|
||||
if (videoElement.captureStream) return videoElement.captureStream();
|
||||
if (videoElement.mozCaptureStream) return videoElement.mozCaptureStream();
|
||||
console.warn('[artplayerPluginAsr] captureStream not supported');
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn('[artplayerPluginAsr] Error capturing stream:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCapture() {
|
||||
if (started) return;
|
||||
|
||||
try {
|
||||
await setupAudioContext();
|
||||
const audioSource = await setupAudioSource();
|
||||
if (!audioSource) throw new Error('Could not establish audio source');
|
||||
|
||||
if (!workletLoaded) {
|
||||
const blobUrl = createWorkletBlobUrl();
|
||||
await audioCtx.audioWorklet.addModule(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
workletLoaded = true;
|
||||
}
|
||||
|
||||
// 创建输出音频的 GainNode(正常播放)
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 1;
|
||||
|
||||
recorderNode = new AudioWorkletNode(audioCtx, 'recorder-processor');
|
||||
recorderNode.port.onmessage = (event) => {
|
||||
bufferChunks.push(new Float32Array(event.data));
|
||||
};
|
||||
|
||||
// 分流:识别和播放
|
||||
audioSource.connect(recorderNode);
|
||||
audioSource.connect(gainNode);
|
||||
|
||||
gainNode.connect(audioCtx.destination); // 正常声音输出
|
||||
// recorderNode 不连接 destination,避免输出干扰
|
||||
|
||||
const CHUNK_SAMPLES = sampleRate * (interval / 1000);
|
||||
timer = setInterval(async () => {
|
||||
if (bufferChunks.length === 0) return;
|
||||
|
||||
let accumulated = new Float32Array(0);
|
||||
while (accumulated.length < CHUNK_SAMPLES && bufferChunks.length) {
|
||||
const next = bufferChunks.shift();
|
||||
const tmp = new Float32Array(accumulated.length + next.length);
|
||||
tmp.set(accumulated, 0);
|
||||
tmp.set(next, accumulated.length);
|
||||
accumulated = tmp;
|
||||
}
|
||||
|
||||
if (accumulated.length < CHUNK_SAMPLES) return;
|
||||
|
||||
const chunkToSend = accumulated.slice(0, CHUNK_SAMPLES);
|
||||
const pcm = floatTo16BitPCM(chunkToSend);
|
||||
const subtitle = await onAudioChunk(pcm);
|
||||
append(subtitle);
|
||||
}, interval);
|
||||
|
||||
started = true;
|
||||
} catch (err) {
|
||||
console.error('[artplayerPluginAsr] Initialization failed:', err);
|
||||
await stopCapture();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCapture() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
|
||||
if (recorderNode) {
|
||||
recorderNode.disconnect();
|
||||
recorderNode.port.onmessage = null;
|
||||
recorderNode = null;
|
||||
}
|
||||
|
||||
if (gainNode) {
|
||||
gainNode.disconnect();
|
||||
gainNode = null;
|
||||
}
|
||||
|
||||
bufferChunks = [];
|
||||
}
|
||||
|
||||
async function destroy() {
|
||||
await stopCapture();
|
||||
|
||||
if (mediaStreamSource) {
|
||||
mediaStreamSource.disconnect();
|
||||
mediaStreamSource = null;
|
||||
}
|
||||
|
||||
if (sourceNode) {
|
||||
sourceNode.disconnect();
|
||||
sourceNode = null;
|
||||
}
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((track) => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
if (audioCtx) {
|
||||
await audioCtx.close();
|
||||
audioCtx = null;
|
||||
}
|
||||
|
||||
workletLoaded = false;
|
||||
}
|
||||
|
||||
art.on('play', startCapture);
|
||||
art.on('pause', stopCapture);
|
||||
art.on('destroy', destroy);
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginAsr',
|
||||
stop: destroy,
|
||||
hide,
|
||||
append,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (!document.getElementById('artplayer-plugin-asr')) {
|
||||
const $style = document.createElement('style');
|
||||
$style.id = 'artplayer-plugin-asr';
|
||||
$style.textContent = style;
|
||||
document.head.appendChild($style);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window['artplayerPluginAsr'] = artplayerPluginAsr;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.art-video-player {
|
||||
.art-layer-asr {
|
||||
pointer-events: none !important;
|
||||
position: absolute;
|
||||
z-index: 150;
|
||||
inset: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
padding: 0 2%;
|
||||
font-size: 1rem;
|
||||
gap: var(--art-subtitle-gap);
|
||||
padding-bottom: var(--art-subtitle-bottom);
|
||||
transition: padding-bottom var(--art-transition-duration) ease;
|
||||
text-shadow:
|
||||
var(--art-subtitle-border) 1px 0 1px,
|
||||
var(--art-subtitle-border) 0 1px 1px,
|
||||
var(--art-subtitle-border) -1px 0 1px,
|
||||
var(--art-subtitle-border) 0 -1px 1px,
|
||||
var(--art-subtitle-border) 1px 1px 1px,
|
||||
var(--art-subtitle-border) -1px -1px 1px,
|
||||
var(--art-subtitle-border) 1px -1px 1px,
|
||||
var(--art-subtitle-border) -1px 1px 1px;
|
||||
}
|
||||
|
||||
&.art-control-show {
|
||||
.art-layer-asr {
|
||||
padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user