Transfer-Encoding: chunked 实时音频播放

什么是 Transfer-Encoding: chunked

浏览器在大多数情况下是支持以 Transfer-Encoding 为 "chunked" 的方式传输的音频实时播放的。这种传输方式允许服务器将音频数据分块传输,而不是一次性发送整个音频文件。这样,浏览器可以在接收到部分音频数据后立即开始播放,而无需等待整个文件下载完成。

以下是一些支持实时播放的条件和注意事项:

  1. HTTP/1.1 支持:Transfer-Encoding: chunked 是 HTTP/1.1 协议的一部分,绝大多数现代浏览器都支持。

  2. 媒体格式:浏览器需要支持所传输的音频格式,例如 MP3、AAC、OGG 等。

  3. 响应头:确保响应头中正确设置了 Transfer-Encoding: chunked。

  4. 流式处理:服务器需要以流的方式发送音频数据块,而不是一次性发送所有数据。

通过这种方式,浏览器会在接收到第一块音频数据后立即开始播放,从而实现实时播放的效果。

在 HTTP 协议中,"Transfer-Encoding: chunked" 是一种用于分块传输数据的编码方式。它允许服务器将响应体分成一系列较小的数据块(chunk),每个块都有其自己的大小标头。这种方法使得服务器可以在生成响应的同时发送数据,而无需在发送之前知道整个响应的大小。

Chunked 编码

Chunked 编码工作原理

在使用 chunked 编码传输数据时,响应体会被分成一系列数据块。每个数据块由以下部分组成:

  1. 块大小(十六进制表示)后跟 CRLF(回车换行)。
  2. 数据块本身。
  3. 另一个 CRLF。

最后,一个大小为 0 的块标志着数据传输的结束。结构如下:

<size in hex>\r\n
<data>\r\n
<size in hex>\r\n
<data>\r\n
...
0\r\n
\r\n

Chunked 编码示例

假设我们要传输以下字符串:"Hello, world! This is chunked encoding."

  1. 将字符串分块,例如我们分成两块:

    • "Hello, world! "
    • "This is chunked encoding."
  2. 计算每个块的大小并转换为十六进制:

    • "Hello, world! " 的大小是 14(十进制),即 0E(十六进制)
    • "This is chunked encoding." 的大小是 20(十进制),即 14(十六进制)
  3. 使用 chunked 编码格式传输:

0E\r\n
Hello, world! \r\n
14\r\n
This is chunked encoding.\r\n
0\r\n
\r\n

tio-boot 实时播放音频示例

  • 服务端 返回 Transfer-Encoding: chunked 编码的 PCM 16kHz 音频文件 这里是 tio-boot
  • 要使用 curl 接收 Transfer-Encoding: chunked 编码的 PCM 16kHz 音频文件,
  • 播放 将 curl 的输出重定向到一个播放程序。这可以通过在 Unix 系统上使用 arecordaplay 来实现。arecord 用于录制音频,aplay 用于播放音频。 windows 使用ffplay

步骤

  1. 服务器端准备 确保服务器端能够以 chunked 编码方式返回 PCM 16kHz 音频数据。例如,使用 tio-boot 实现:
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;

import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.core.ChannelContext;
import com.litongjava.tio.core.Tio;
import com.litongjava.tio.http.common.HeaderName;
import com.litongjava.tio.http.common.HeaderValue;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.common.encoder.ChunkEncoder;
import com.litongjava.tio.http.common.sse.ChunkedPacket;
import com.litongjava.tio.http.server.util.SseUtils;
import com.litongjava.tio.utils.http.ContentTypeUtils;
import com.litongjava.tio.utils.hutool.ResourceUtil;
import com.litongjava.tio.utils.resp.RespVo;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class AudioChunkHandler {

  public HttpResponse tts(HttpRequest httpRequest) {
    // 获取channelContext
    ChannelContext channelContext = httpRequest.getChannelContext();
    // 获取response
    HttpResponse response = TioRequestContext.getResponse();

    // 判断文件是否存在
    URL resource = ResourceUtil.getResource("samples/Blowin_in_the_Wind-16k.pcm");
    if (resource == null) {
      response.fail(RespVo.fail("Resource not found"));
      return response;
    }

    // 文件扩展名,根据实际情况设置
    String fileExt = "pcm";
    String contentType = ContentTypeUtils.getContentType(fileExt);

    // 设置为流式输出,这样不会计算content-length,because Content-Length can't be present with Transfer-Encoding
    response.setStream(true);
    // 设置响应头
    response.addHeader(HeaderName.Transfer_Encoding, HeaderValue.from("chunked"));
    response.addHeader(HeaderName.Content_Type, HeaderValue.from(contentType));

    // 发送初始响应头,客户端会自动保持连接
    Tio.send(channelContext, response);

    // 打开文件
    try (InputStream inputStream = resource.openStream()) {
      // 读取文件并响应到客户端
      byte[] buffer = new byte[1024 * 10];
      int bytesRead;
      int i = 0;
      while ((bytesRead = inputStream.read(buffer)) != -1) {
        i++;
        ChunkedPacket ssePacket = new ChunkedPacket(ChunkEncoder.encodeChunk(buffer, bytesRead));
        Tio.send(channelContext, ssePacket);
        log.info("sned:{}:{}", i, bytesRead);
      }

      // 发送结束标志,客户会手动关闭连接
      ChunkedPacket endPacket = new ChunkedPacket(ChunkEncoder.encodeChunk(new byte[0]));
      Tio.send(channelContext, endPacket);

      SseUtils.closeChunkConnection(channelContext);
    } catch (IOException e) {
      response.fail(RespVo.fail("Failed to open resource:" + e.getMessage()));
      return response;
    }

    // 告诉默认的处理器不发送该包并且同时不关闭链接
    response.setSend(false);
    return response;
  }
}
  • 完整的源码地址 https://github.com/litongjava/java-ee-tio-boot-study/tree/main/tio-boot-latest-study/tio-boot-audio-chunked
  1. 客户端使用 curl 接收并播放音频

可以使用 curl 从服务器获取音频数据,并通过管道将其传递给 aplay 进行播放。以下是一个示例命令:

curl http://localhost/tts | aplay -f S16_LE -r 16000 -c 1

解释:

  • curl http://localhost/tts 从服务器获取音频数据。
  • | 管道符号,将 curl 的输出传递给 aplay
  • aplay -f S16_LE -r 16000 -c 1 使用 aplay 播放音频,其中:
    • -f S16_LE 表示音频格式为 16 位小端 (signed 16-bit little-endian) PCM。
    • -r 16000 表示采样率为 16000 Hz。
    • -c 1 表示单声道 (mono)。

Windows 平台

在 Windows 上,可以使用一些其他工具,如 ffplay 来播放音频。以下是一个示例:

  1. 安装 ffmpeg 需要安装 ffmpeg,ffplay 是 ffmpeg 的一部分。

  2. 使用 curlffplay 播放音频

先测试保存到文件播放

curl -o output.pcm http://localhost/tts
ffplay -f s16le -ar 16000 -ac 1 output.pcm

测试成功

curl http://localhost/tts | ffplay -f s16le -ar 16000 -ac 1 -

测试失败

解释:

  • curl http://localhost/tts 从服务器获取音频数据。
  • | 管道符号,将 curl 的输出传递给 ffplay
  • ffplay -f s16le -ar 16000 -ac 1 - 使用 ffplay 播放音频,其中:
    • -f s16le 表示音频格式为 16 位小端 PCM。
    • -ar 16000 表示采样率为 16000 Hz。
    • -ac 1 表示单声道。

通过这种方式,可以使用 curl 接收以 chunked 编码传输的 PCM 16kHz 音频数据并进行实时播放。

手动编写代码,实现播放客户端

使用 Go 编写一个程序,通过 HTTP 请求实时接收 PCM 音频流并播放它, go-audio-stream-player 开源地址https://github.com/litongjava/go-audio-stream-player

go-audio-stream-player -f s16le -ar 16000 -ac 1 http://localhost/tts