关于前端实现音频播放

799 阅读5分钟

前端如何实现音频播放

介绍说明:

  • 当前使用的是react框架,音频播放是用的audio标签绑定src实现。
  • 后端生成分片音频,前端如何实现

实现如图效果:

企业微信截图_17143015535862.png

功能包含拖动,快进退,播放,暂停,朗读高亮,当然这些功能都是基于audio去写的一套额外样式。

坑点避免:

  • 当后端是分片音频时,采用一个audio动态接收,audio在初始化时并未加载所有的分段音频,所以频发触发交互时,产生bug如下:
  1. 快进退操作时,由于音频在加载中,没有资源时需要去额外判空,导致记录的相关变量很容易计算错误(比如时间超出音频总时长,或者在倒退时,此时上一个音频未加载出来,时间会变成负数),当然这些问题可以通过节流来避免。
  2. ios系统会默认获取当前播放的audio标签内容,此时音频是分片的,ios的控制面板显示的是每一小段音频文件,关于这点,需要将分片音频合成一个完整的音频文件解决。
<audio
  id='audio'
  className="audio"
  onEnded={handleOnEnded}
  onTimeUpdate={handleOnTimeUpdate}
  ref={audioRef}
  src={audio}
  controls
/>

坑点避免:

  • 如上第一个坑点解决方案:希望页面一初始化便能获取所有的音频,所以应当遍历出所有的分片音频数组,分别赋值给auido的src标签,这样可以避免资源加载不及时的问题,但是代码变的烦琐了,每次操作都需要找到当前正在播放的音频。
  • 第二个坑点此时还未解决。

由于需要让ios或者某些安卓机型能正常长进度条面板,(问为什么后端不合成完整音频?解释说是有噪音),此时选了前端合成音频的方案,借助webapi audioContext去完成合并。

此时需要注意下AudioContext的兼容性:

企业微信截图_17143030863367.png

可以看到大部分都是支持的,需要注意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])