201 lines
6.2 KiB
JavaScript
201 lines
6.2 KiB
JavaScript
/**
|
|
* 支持安卓、iOS、H5的pcm格式,
|
|
* 如果想支持mp3,需要增加mp3相关解码合并处理等,个人测试效果不太好,所以没有加
|
|
*/
|
|
export class StreamPlayer {
|
|
constructor({
|
|
inputSampleRate = 16000,
|
|
numChannels = 1,
|
|
bitDepth = 16,
|
|
littleEndian = true,
|
|
pcmType = 'int',
|
|
callback = ()=>{}
|
|
} = {}) {
|
|
// 参数校验
|
|
if (![16, 32].includes(bitDepth)) throw new Error('bitDepth 必须是 16 或 32');
|
|
if (inputSampleRate <= 0) throw new Error('采样率必须大于 0');
|
|
if (!['int', 'float'].includes(pcmType)) throw new Error('pcmType 必须是 int 或 float');
|
|
|
|
this.audioContext = new(window.AudioContext || window.webkitAudioContext)();
|
|
this.inputSampleRate = inputSampleRate;
|
|
this.numChannels = numChannels;
|
|
this.bitDepth = bitDepth;
|
|
this.littleEndian = littleEndian;
|
|
this.pcmType = pcmType;
|
|
this.audioQueue = [];
|
|
this.playbackEndTime = 0;
|
|
this.currentSource = null;
|
|
this.lastBlockTail = null; // 用于交叉淡入淡出
|
|
this.callback = callback;
|
|
}
|
|
|
|
appendChunk(audioData) {
|
|
console.log('this.audioQueue', this.audioQueue.length);
|
|
this.audioQueue.push(audioData);
|
|
this._processQueue();
|
|
}
|
|
|
|
destroy() {
|
|
this.audioQueue = [];
|
|
this.playbackEndTime = 0;
|
|
if (this.currentSource) {
|
|
this.currentSource.stop();
|
|
this.currentSource = null;
|
|
}
|
|
if (this.audioContext) {
|
|
this.audioContext.close();
|
|
this.audioContext = null;
|
|
}
|
|
}
|
|
|
|
async _processQueue() {
|
|
if(this.audioQueue.length === 0) {
|
|
this.callback('ended')
|
|
return
|
|
}
|
|
if (!this.audioContext || this.currentSource) return;
|
|
const buffer = this._mergeArrayBuffers(this.audioQueue)
|
|
this.audioQueue = []
|
|
try {
|
|
const audioBuffer = this._convertPCM(buffer);
|
|
await this._schedulePlay(audioBuffer);
|
|
this._processQueue();
|
|
} catch (err) {
|
|
console.error('音频处理失败:', err.name, err.message, err.stack);
|
|
}
|
|
}
|
|
|
|
_convertPCM(buffer) {
|
|
// 1. 数据对齐:确保长度是样本大小的整数倍
|
|
const bytesPerSample = this.bitDepth / 8;
|
|
const requiredBytes = bytesPerSample * this.numChannels;
|
|
const validByteLength = Math.floor(buffer.byteLength / requiredBytes) * requiredBytes;
|
|
const validBuffer = buffer.slice(0, validByteLength);
|
|
const dataView = new DataView(validBuffer);
|
|
const numSamples = validByteLength / requiredBytes;
|
|
|
|
// 2. 创建音频缓冲区
|
|
const audioBuffer = this.audioContext.createBuffer(
|
|
this.numChannels,
|
|
numSamples,
|
|
this.inputSampleRate
|
|
);
|
|
|
|
// 3. 解析PCM数据
|
|
for (let ch = 0; ch < this.numChannels; ch++) {
|
|
const channelData = audioBuffer.getChannelData(ch);
|
|
for (let i = 0; i < numSamples; i++) {
|
|
// 计算字节位置(修正后的多声道索引)
|
|
const pos = (i * this.numChannels * bytesPerSample) + (ch * bytesPerSample);
|
|
let value;
|
|
|
|
// 根据位深和数据类型解析
|
|
if (this.bitDepth === 16) {
|
|
value = dataView.getInt16(pos, this.littleEndian) / 32768;
|
|
} else {
|
|
if (this.pcmType === 'int') {
|
|
value = dataView.getInt32(pos, this.littleEndian) / 2147483648;
|
|
} else {
|
|
value = dataView.getFloat32(pos, this.littleEndian);
|
|
}
|
|
}
|
|
channelData[i] = value;
|
|
}
|
|
|
|
// 4. 消除直流偏移
|
|
let sum = 0;
|
|
for (let i = 0; i < channelData.length; i++) {
|
|
sum += channelData[i];
|
|
}
|
|
const dcOffset = sum / channelData.length;
|
|
for (let i = 0; i < channelData.length; i++) {
|
|
channelData[i] -= dcOffset;
|
|
}
|
|
|
|
// 5. 块内淡入淡出(减少边界突变)
|
|
const fadeLength = Math.min(100, channelData.length);
|
|
for (let i = 0; i < fadeLength; i++) {
|
|
channelData[i] *= (i / fadeLength);
|
|
}
|
|
const startFadeOut = Math.max(0, channelData.length - fadeLength);
|
|
for (let i = startFadeOut; i < channelData.length; i++) {
|
|
const factor = 1 - (i - startFadeOut) / fadeLength;
|
|
channelData[i] *= factor;
|
|
}
|
|
}
|
|
|
|
// 6. 块间交叉淡入淡出(解决重音关键点)
|
|
const crossfadeLength = 50;
|
|
if (this.lastBlockTail && this.lastBlockTail.length === this.numChannels) {
|
|
for (let ch = 0; ch < this.numChannels; ch++) {
|
|
const channelData = audioBuffer.getChannelData(ch);
|
|
const prevTail = this.lastBlockTail[ch];
|
|
|
|
// 只处理有足够长度交叉的情况
|
|
if (prevTail.length >= crossfadeLength && channelData.length >= crossfadeLength) {
|
|
for (let i = 0; i < crossfadeLength; i++) {
|
|
const prevWeight = (crossfadeLength - i) / crossfadeLength;
|
|
const currWeight = i / crossfadeLength;
|
|
channelData[i] = channelData[i] * currWeight + prevTail[i] * prevWeight;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. 保存当前块尾部用于下一次交叉
|
|
this.lastBlockTail = [];
|
|
for (let ch = 0; ch < this.numChannels; ch++) {
|
|
const channelData = audioBuffer.getChannelData(ch);
|
|
const tailStart = Math.max(0, channelData.length - crossfadeLength);
|
|
this.lastBlockTail[ch] = channelData.slice(tailStart);
|
|
}
|
|
|
|
return audioBuffer;
|
|
}
|
|
_schedulePlay(audioBuffer) {
|
|
return new Promise(resolve => {
|
|
const source = this.audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(this.audioContext.destination);
|
|
|
|
const plannedStartTime = this.playbackEndTime;
|
|
const now = this.audioContext.currentTime;
|
|
let startTime = Math.max(plannedStartTime, now);
|
|
|
|
// 动态速率调整(示例,需根据实际测试调整阈值)
|
|
const timeDrift = plannedStartTime - now;
|
|
if (timeDrift < -0.02) {
|
|
source.playbackRate.value = Math.min(1.04, 1 - (timeDrift / audioBuffer.duration));
|
|
} else if (timeDrift > 0.02) {
|
|
source.playbackRate.value = Math.max(0.96, 1 - (timeDrift / audioBuffer.duration));
|
|
} else {
|
|
source.playbackRate.value = 1.0;
|
|
}
|
|
|
|
source.start(startTime);
|
|
this.playbackEndTime = startTime + (audioBuffer.duration / source.playbackRate.value);
|
|
|
|
source.onended = () => {
|
|
this.currentSource = null;
|
|
resolve();
|
|
};
|
|
this.currentSource = source;
|
|
});
|
|
}
|
|
|
|
// 合并多个 ArrayBuffer 的辅助函数
|
|
_mergeArrayBuffers(buffers) {
|
|
let totalLength = 0;
|
|
for (let buffer of buffers) {
|
|
totalLength += buffer.byteLength;
|
|
}
|
|
const result = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (let buffer of buffers) {
|
|
const view = new Uint8Array(buffer);
|
|
result.set(view, offset);
|
|
offset += view.length;
|
|
}
|
|
return result.buffer;
|
|
}
|
|
} |