PaddlePaddle / PaddleSpeech

Easy-to-use Speech Toolkit including Self-Supervised Learning model, SOTA/Streaming ASR with punctuation, Streaming TTS with text frontend, Speaker Verification System, End-to-End Speech Translation and Keyword Spotting. Won NAACL2022 Best Demo Award.
https://paddlespeech.readthedocs.io
Apache License 2.0
10.56k stars 1.81k forks source link

[TTS] The base64 data returned by the tts interface is faulty. tts语音合成接口返回base64数据有误。 #3530

Open AnnCY1 opened 9 months ago

AnnCY1 commented 9 months ago

English base64 data returned by a webSocket connection cannot be played in the

Interface address: ws://{server}:{port}/paddlespeech/tts/streaming

中文: 使用tts接口,通过webSocket连接返回的base64数据无法在浏览器的

接口地址: ws://{server}:{port}/paddlespeech/tts/streaming

AnnCY1 commented 9 months ago

已经解决了,原来传过来的数据是 base64编码的pcm16音频数据,需要先转为wav格式的blob对象,然后才能转为url地址,代码我是问GPT问出来的,TypeScript实现:

  // 已接收到最后一次消息包,发送结束命令并进行朗读
  if (message.status == 2) {
    let audioUrl = pcmToAudioUrl(chunk) // chunk是接口返回的base64字符串全部拼接到一起的数据
    audioEle.value.src = audioUrl
    audioEle.value.play() // 使用<audio>标签进行播放
    /**
     * 接收base64数据转为音频可播放的url地址
     * @param {string} base64Data - base64编码的pcm16音频数据 就是tts接口发送回来的所有数据,不加任何处理
     */
    // eslint-disable-next-line no-inner-declarations
    function pcmToAudioUrl(base64Data: string): string {
      // console.log(base64Data)
      let pcmData = base64ToUint8Array(base64Data)
      // 创建WAV格式的Blob对象 (这是重点!直接创建blob数据是无法播放的!)
      const wavBlob = createWavBlob(pcmData)
      // 将URL设置为音频源即可
      return URL.createObjectURL(wavBlob)

      // base64编码的pcm16音频数据 转换为unit8格式数据
      function base64ToUint8Array(base64String: string) {
        const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
        const base64 = (base64String + padding)
          .replace(/-/g, '+')
          .replace(/_/g, '/')

        const rawData = window.atob(base64)
        const outputArray = new Uint8Array(rawData.length)

        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i)
        }
        return outputArray
      }
      // 创建WAV格式的Blob对象 (调节采样率来微调语速和音调)
      function createWavBlob(pcmData: any) {
        const format = 1 // 格式代码(1表示PCM)
        const numChannels = 1 // 声道数量(单声道为1,立体声为2)
        const sampleRate = 26500 // 采样率(例如44100 Hz)
        const bitsPerSample = 16 // 每样本的位数(例如16位)
        const blockAlign = numChannels * (bitsPerSample / 8) // 对齐单位
        const byteRate = sampleRate * blockAlign // 每秒的字节数
        const buffer = new ArrayBuffer(44 + pcmData.length) // WAV文件头部长度为44字节
        const view = new DataView(buffer)
        // 写入WAV文件头部信息
        writeString(view, 0, 'RIFF') // ChunkID
        view.setUint32(4, 36 + pcmData.length, true) // ChunkSize
        writeString(view, 8, 'WAVE') // Format
        writeString(view, 12, 'fmt ') // Subchunk1ID
        view.setUint32(16, 16, true) // Subchunk1Size
        view.setUint16(20, format, true) // AudioFormat
        view.setUint16(22, numChannels, true) // NumChannels
        view.setUint32(24, sampleRate, true) // SampleRate
        view.setUint32(28, byteRate, true) // ByteRate
        view.setUint16(32, blockAlign, true) // BlockAlign
        view.setUint16(34, bitsPerSample, true) // BitsPerSample
        writeString(view, 36, 'data') // Subchunk2ID
        view.setUint32(40, pcmData.length, true) // Subchunk2Size
        // 将PCM数据写入buffer
        const pcmDataView = new Uint8Array(buffer, 44)
        pcmDataView.set(pcmData)
        return new Blob([view], { type: 'audio/wav' })
      }
      // 写入字符串到DataView中的指定位置
      function writeString(view: any, offset: any, string: any) {
        for (let i = 0; i < string.length; i++) {
          view.setUint8(offset + i, string.charCodeAt(i))
        }
      }
    }
  }

这种方法只能实现接收到全部音频数据后使用audio标签播放,如果想实现流式播放音频,应该还需要一个流式播放器的函数,我看官方提供的demo里面是把音频数据放到一个叫流式播放器的函数里面执行的。

bchengwang commented 8 months ago

已经解决了,原来传过来的数据是 base64编码的pcm16音频数据,需要先转为wav格式的blob对象,然后才能转为url地址,代码我是问GPT问出来的,TypeScript实现:

  // 已接收到最后一次消息包,发送结束命令并进行朗读
  if (message.status == 2) {
    let audioUrl = pcmToAudioUrl(chunk) // chunk是接口返回的base64字符串全部拼接到一起的数据
    audioEle.value.src = audioUrl
    audioEle.value.play() // 使用<audio>标签进行播放
    /**
     * 接收base64数据转为音频可播放的url地址
     * @param {string} base64Data - base64编码的pcm16音频数据 就是tts接口发送回来的所有数据,不加任何处理
     */
    // eslint-disable-next-line no-inner-declarations
    function pcmToAudioUrl(base64Data: string): string {
      // console.log(base64Data)
      let pcmData = base64ToUint8Array(base64Data)
      // 创建WAV格式的Blob对象 (这是重点!直接创建blob数据是无法播放的!)
      const wavBlob = createWavBlob(pcmData)
      // 将URL设置为音频源即可
      return URL.createObjectURL(wavBlob)

      // base64编码的pcm16音频数据 转换为unit8格式数据
      function base64ToUint8Array(base64String: string) {
        const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
        const base64 = (base64String + padding)
          .replace(/-/g, '+')
          .replace(/_/g, '/')

        const rawData = window.atob(base64)
        const outputArray = new Uint8Array(rawData.length)

        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i)
        }
        return outputArray
      }
      // 创建WAV格式的Blob对象 (调节采样率来微调语速和音调)
      function createWavBlob(pcmData: any) {
        const format = 1 // 格式代码(1表示PCM)
        const numChannels = 1 // 声道数量(单声道为1,立体声为2)
        const sampleRate = 26500 // 采样率(例如44100 Hz)
        const bitsPerSample = 16 // 每样本的位数(例如16位)
        const blockAlign = numChannels * (bitsPerSample / 8) // 对齐单位
        const byteRate = sampleRate * blockAlign // 每秒的字节数
        const buffer = new ArrayBuffer(44 + pcmData.length) // WAV文件头部长度为44字节
        const view = new DataView(buffer)
        // 写入WAV文件头部信息
        writeString(view, 0, 'RIFF') // ChunkID
        view.setUint32(4, 36 + pcmData.length, true) // ChunkSize
        writeString(view, 8, 'WAVE') // Format
        writeString(view, 12, 'fmt ') // Subchunk1ID
        view.setUint32(16, 16, true) // Subchunk1Size
        view.setUint16(20, format, true) // AudioFormat
        view.setUint16(22, numChannels, true) // NumChannels
        view.setUint32(24, sampleRate, true) // SampleRate
        view.setUint32(28, byteRate, true) // ByteRate
        view.setUint16(32, blockAlign, true) // BlockAlign
        view.setUint16(34, bitsPerSample, true) // BitsPerSample
        writeString(view, 36, 'data') // Subchunk2ID
        view.setUint32(40, pcmData.length, true) // Subchunk2Size
        // 将PCM数据写入buffer
        const pcmDataView = new Uint8Array(buffer, 44)
        pcmDataView.set(pcmData)
        return new Blob([view], { type: 'audio/wav' })
      }
      // 写入字符串到DataView中的指定位置
      function writeString(view: any, offset: any, string: any) {
        for (let i = 0; i < string.length; i++) {
          view.setUint8(offset + i, string.charCodeAt(i))
        }
      }
    }
  }

这种方法只能实现接收到全部音频数据后使用audio标签播放,如果想实现流式播放音频,应该还需要一个流式播放器的函数,我看官方提供的demo里面是把音频数据放到一个叫流式播放器的函数里面执行的。 兄弟,有没有解决Web页面流式播放的问题啊。如果解决了可以一分享一下吗?

GiterRUOK commented 3 months ago

基于@AnnCY1的代码改了下,在小程序上ok了

// 参考:https://github.com/PaddlePaddle/PaddleSpeech/issues/3530
// base64编码的pcm16音频数据 转换为unit8格式数据 
export function pcmtowav(base64String) {
    const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
    const base64 = (base64String + padding)
        .replace(/-/g, '+')
        .replace(/_/g, '/')

    // const rawData = window.atob(base64)
    // const rawData = atobPolyfill(base64)
    const rawData = atobPolyfill(base64String) // 直接这样也可以
    const outputArray = new Uint8Array(rawData.length)

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i)
    }
    return createWav(outputArray)
}

// 创建WAV格式的Blob对象 (调节采样率来微调语速和音调)
export function createWav(pcmData) {
    const format = 1 // 格式代码(1表示PCM)
    const numChannels = 1 // 声道数量(单声道为1,立体声为2)
    const sampleRate = 24000 // 采样率(例如44100 Hz)
    const bitsPerSample = 16 // 每样本的位数(例如16位)
    const blockAlign = numChannels * (bitsPerSample / 8) // 对齐单位
    const byteRate = sampleRate * blockAlign // 每秒的字节数
    const buffer = new ArrayBuffer(44 + pcmData.length) // WAV文件头部长度为44字节
    const view = new DataView(buffer)
    // 写入WAV文件头部信息
    writeString(view, 0, 'RIFF') // ChunkID
    view.setUint32(4, 36 + pcmData.length, true) // ChunkSize
    writeString(view, 8, 'WAVE') // Format
    writeString(view, 12, 'fmt ') // Subchunk1ID
    view.setUint32(16, 16, true) // Subchunk1Size
    view.setUint16(20, format, true) // AudioFormat
    view.setUint16(22, numChannels, true) // NumChannels
    view.setUint32(24, sampleRate, true) // SampleRate
    view.setUint32(28, byteRate, true) // ByteRate
    view.setUint16(32, blockAlign, true) // BlockAlign
    view.setUint16(34, bitsPerSample, true) // BitsPerSample
    writeString(view, 36, 'data') // Subchunk2ID
    view.setUint32(40, pcmData.length, true) // Subchunk2Size
    // 将PCM数据写入buffer
    const pcmDataView = new Uint8Array(buffer, 44)
    pcmDataView.set(pcmData)
    return uni.arrayBufferToBase64(view.buffer) // 小程序没有Blog, 直接返回buffer或转base64都可以保存文件后播放
    // return view.buffer
    // return new Blob([view], {
    //  type: 'audio/wav'
    // })
}

function atobPolyfill(encodedString) {
    // 创建一个映射表,用于将 base64 字符转换为二进制字符串
    const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    const base64CharsMap = {};
    for (let i = 0; i < base64Chars.length; i++) {
        base64CharsMap[base64Chars[i]] = i;
    }
    // 移除字符串中的填充字符(如果有的话)
    let padding = encodedString.length % 4;
    if (padding) {
        encodedString += '='.repeat(4 - padding);
    }
    // 将 base64 编码的字符串转换为二进制字符串
    let binaryString = '';
    for (let i = 0; i < encodedString.length; i += 4) {
        const char1 = base64CharsMap[encodedString[i]];
        const char2 = base64CharsMap[encodedString[i + 1]];
        const char3 = base64CharsMap[encodedString[i + 2]];
        const char4 = base64CharsMap[encodedString[i + 3]];
        const binary1 = char1.toString(2).padStart(6, '0');
        const binary2 = char2.toString(2).padStart(6, '0');
        const binary3 = char3 !== undefined ? char3.toString(2).padStart(6, '0') : '00';
        const binary4 = char4 !== undefined ? char4.toString(2).padStart(6, '0') : '00';
        binaryString += binary1 + binary2 + binary3 + binary4;
    }
    // 将二进制字符串转换为 ASCII 字符串
    let asciiString = '';
    for (let i = 0; i < binaryString.length; i += 8) {
        const byte = binaryString.substring(i, i + 8);
        asciiString += String.fromCharCode(parseInt(byte, 2));
    }
    return asciiString;
}