前端如何实现音频播放
介绍说明:
- 当前使用的是react框架,音频播放是用的audio标签绑定src实现。
- 后端生成分片音频,前端如何实现
实现如图效果:
功能包含拖动,快进退,播放,暂停,朗读高亮,当然这些功能都是基于audio去写的一套额外样式。
坑点避免:
- 当后端是分片音频时,采用一个audio动态接收,audio在初始化时并未加载所有的分段音频,所以频发触发交互时,产生bug如下:
- 快进退操作时,由于音频在加载中,没有资源时需要去额外判空,导致记录的相关变量很容易计算错误(比如时间超出音频总时长,或者在倒退时,此时上一个音频未加载出来,时间会变成负数),当然这些问题可以通过节流来避免。
- ios系统会默认获取当前播放的audio标签内容,此时音频是分片的,ios的控制面板显示的是每一小段音频文件,关于这点,需要将分片音频合成一个完整的音频文件解决。
<audio
id='audio'
className="audio"
onEnded={handleOnEnded}
onTimeUpdate={handleOnTimeUpdate}
ref={audioRef}
src={audio}
controls
/>
坑点避免:
- 如上第一个坑点解决方案:希望页面一初始化便能获取所有的音频,所以应当遍历出所有的分片音频数组,分别赋值给auido的src标签,这样可以避免资源加载不及时的问题,但是代码变的烦琐了,每次操作都需要找到当前正在播放的音频。
- 第二个坑点此时还未解决。
由于需要让ios或者某些安卓机型能正常长进度条面板,(问为什么后端不合成完整音频?解释说是有噪音),此时选了前端合成音频的方案,借助webapi audioContext去完成合并。
此时需要注意下AudioContext的兼容性:
可以看到大部分都是支持的,需要注意ios14以下的safari不支持,需要去额外排除。
if ('AudioContext' in window || 'webkitAudioContext' in window || 'mozAudioContext' in window) {
// 支持 Web Audio API
setIsSupport(true)
} else {
console.error('当前浏览器不支持 Web Audio API')
return
}
const upload = async (e: any) => {
const result = await new ObsUpload().handleUpload(e)
console.log(result, '执行上传')
return result
}
第一步:合并音频
注意点: blob链接在安卓播放时会由于内存策略,导致播一会就暂停了,将blob格式换成base64再赋值,可以解决这个问题。
async function blobToBase64(blob: Blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
useEffect(()=>{
audioRef.current
?.getAudioBuf()
.then(async (data: AudioBuffer) => {
if (data?.length) {
const blobF = audioRef.current?.bufferToWave(data, data?.length)
blobToBase64(blobF)
.then((base64String) => {
setInitPopup(true)
audioRef.current?.setSrc(base64String as string)
})
.catch((error) => {
console.error('Error converting Blob to Base64:', error)
})
}
})
.finally(() => {
setLoading(false)
})
},[])
let audioContext: AudioContext
// @ts-ignore
if (window?.AudioContext || window?.webkitAudioContext) audioContext = new AudioContext()
// 基于src地址获得 AudioBuffer 的方法
const getAudioBuffer = (src: any) => {
return new Promise((resolve, reject) => {
fetch(src)
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => {
audioContext.decodeAudioData(arrayBuffer).then((buffer) => {
resolve(buffer)
})
})
})
}
// 拼接音频的方法
const concatAudio = (arrBufferList: AudioBuffer[]) => {
// 获得 AudioBuffer
const audioBufferList = arrBufferList
// 最大通道数
const maxChannelNumber = Math.max(...audioBufferList.map((audioBuffer) => audioBuffer.numberOfChannels))
// 总长度
const totalLength = audioBufferList.map((buffer) => buffer.length).reduce((lenA, lenB) => lenA + lenB, 0)
// 创建一个新的 AudioBuffer
const newAudioBuffer = audioContext.createBuffer(maxChannelNumber, totalLength, audioBufferList[0].sampleRate)
// 将所有的 AudioBuffer 的数据拷贝到新的 AudioBuffer 中
let offset = 0
audioBufferList.forEach((audioBuffer, index) => {
for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
newAudioBuffer.getChannelData(channel).set(audioBuffer.getChannelData(channel), offset)
}
offset += audioBuffer.length
})
return newAudioBuffer
}
// AudioBuffer 转 blob
function bufferToWave(abuffer: AudioBuffer, len: number) {
const numOfChan = abuffer.numberOfChannels
const length = len * numOfChan * 2 + 44
const buffer = new ArrayBuffer(length)
const view = new DataView(buffer)
const channels = []
let i
let sample
let offset = 0
let pos = 0
// write WAVE header
// "RIFF"
setUint32(0x46464952)
// file length - 8
setUint32(length - 8)
// "WAVE"
setUint32(0x45564157)
// "fmt " chunk
setUint32(0x20746d66)
// length = 16
setUint32(16)
// PCM (uncompressed)
setUint16(1)
setUint16(numOfChan)
setUint32(abuffer.sampleRate)
// avg. bytes/sec
setUint32(abuffer.sampleRate * 2 * numOfChan)
// block-align
setUint16(numOfChan * 2)
// 16-bit (hardcoded in this demo)
setUint16(16)
// "data" - chunk
setUint32(0x61746164)
// chunk length
setUint32(length - pos - 4)
// write interleaved data
for (i = 0; i < abuffer.numberOfChannels; i++) channels.push(abuffer.getChannelData(i))
while (pos < length) {
// interleave channels
for (i = 0; i < numOfChan; i++) {
// clamp
sample = Math.max(-1, Math.min(1, channels[i][offset]))
// scale to 16-bit signed int
// eslint-disable-next-line no-bitwise
sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0
// write 16-bit sample
view.setInt16(pos, sample, true)
pos += 2
}
// next source sample
offset++
}
// create Blob
return new Blob([buffer], { type: 'audio/wav' })
function setUint16(data: any) {
view.setUint16(pos, data, true)
pos += 2
}
function setUint32(data: any) {
view.setUint32(pos, data, true)
pos += 4
}
}
第二步:计算audio的各种变量
// 当前播放的音频已播的时间
const handleOnTimeUpdate = (event: any) => {
// 使用requestAnimationFrame包裹setState调用
// setTimeSeconds(event?.target?.currentTime)
// currentTimeRef.current = event?.target?.currentTime
// 实时更新索引
playIndex.current = getPlayAudio(event?.target?.currentTime)?.index
}
/** 获取高亮文本 */
const lightHtml = useMemo(() => {
return (
<>
{audioData?.map((item) => {
return (
<span
className={classNames(item.index === playIndex.current && styles.bText)}
id={`light-${item.index}`}
key={item?.index}
>
{item?.content}
</span>
)
})}
</>
)
}, [playIndex.current, audioData, timeSeconds])
// 计算快进退时间所要定位到的音频索引
const getForwardData = useCallback(
(seconds: number, type: 'drag' | 'forBackWord') => {
const audioDom = document.querySelector('audio') as HTMLAudioElement
if (!audioDom || !audioDom.src) return
// 拖拽
if (type === 'drag') {
audioDom.currentTime = seconds
}
// 块进退按钮
if (type === 'forBackWord') {
if (audioDom.currentTime + seconds >= totalTime) {
// currentTimeRef.current = totalTime
audioDom.currentTime = totalTime
setTimeSeconds(totalTime)
playIndex.current = audioData.length - 1
return
}
if (audioDom.currentTime + seconds <= 0) {
// currentTimeRef.current = 0
audioDom.currentTime = 0
setTimeSeconds(0)
playIndex.current = 0
return
}
audioDom.currentTime += seconds
}
const gotoTime = audioDom.currentTime as number
// currentTimeRef.current = gotoTime
setTimeSeconds(gotoTime)
// 获取当前音频定位到的时间 计算对应分段音频的index
const audioItem = getPlayAudio(gotoTime)
playIndex.current = audioItem?.index
},
[audioData]
)
需要注意的是,在audio的回调事件中不能做set异步操作,这样会导致ios系统面板捕捉h5进度失效的问题。
- 定义playIndex 记录当前读取到的音频索引
- 注意点:ios中给文字添加高亮,如果文字有font-weight属性,会导致文字样式不渲染的问题,如:文字截断错位。(不清楚原因)
- 快进退方法,这里是将快进退和拖拽进度条组合成了一个方法,参数一:传入要去的时间(s),方式(drag:拖拽,forBackWord:快进退时间 s)
由于不直接在audio的回调事件中,setstate异步操作改变值,所以创建一个定时器去定时改变,变量值
// useInterval是ahooks提供的定时器
const Inter = () => {
if (!audioRef?.current?.currentTime) return
if (totalTime - Math.ceil(audioRef.current.currentTime) < 1) {
audioRef.current.pause()
setPlay(false)
setTimeSeconds(totalTime)
} else {
setTimeSeconds(audioRef.current.currentTime)
}
}
useInterval(Inter, count)
同时在播放或者暂停时需要去手动关闭定时器
// 手动监听当前暂停还是播放状态
useEffect(() => {
// @ts-ignore
if (!audioRef.current) return setPlay(Boolean(!audioRef?.current?.paused))
setCount(!audioRef?.current?.paused ? 1000 : undefined)
setPlay(!audioRef?.current?.paused)
}, [audioRef?.current?.paused])