# 增量压缩音频播放

`bass.streamPlayer` 接收 MP3 等压缩音频的连续文件字节。它不需要完整文件、不依赖 TTS、HTTP 或 WebSocket，可以用于任意能够分块交付音频的生产者。

## 基本用法

```aardio
import bass.streamPlayer;
var player = bass.streamPlayer();

// 收到生产者的一个编码音频块时：
// var ok,err = player.write(data);

// 生产者确认不会再发送任何数据时：
// player.finish();

// 生产者失败时（不把残缺音频当作正常结束）：
// player.finish("错误信息");

// 用户打断或窗口关闭时：
player.close();
```

> BASS 是闭源的第三方组件：非商业用途免费，商业用途需按 [un4seen 官网](https://www.un4seen.com/bass.html#license) 购买授权。只需播放 MP3（例如 TTS 语音）时，可改用免费的系统原生播放器 [sys.mediaFoundation.streamingMp3Player](https://www.aardio.com/zh-cn/docs/library/sys/mediaFoundation/streamingMp3Player/)，接口相同。

所有操作在创建播放器的线程执行，线程需要消息循环。`wait(timeoutMs)` 是带消息泵的便利方法；GUI 可以使用已有的 `win.loopMessage()`。

一个实例代表一个连续编码文件流，不是多个独立 MP3 文件的播放列表。它不是 PCM 样本推送器。

## 对象接口

| 方法/属性 | 语义 |
|---|---|
| `write(data)` | 接收字符串/buffer 字节；成功 true，失败 null,错误字符串；可重用传入的 buffer |
| `finish()` | 结束输入，先送完排队数据，再通知解码器 EOF；不会立即停止声音；可重复调用 |
| `finish(err)` | 来源失败：立即停止并触发一次 `onError`；`err` 可以是错误字符串或含 `message` 的错误表 |
| `play()` | 开始或继续播放；可在尚无音频时调用 |
| `pause()` | 暂停播放，仍可接收输入，但仍受缓存上限限制 |
| `close()` | 立即停止并释放当前实例，可重复调用，不触发 onEnd |
| `wait(timeoutMs)` | 等待实际播放完毕；超时不自动停止，需要时显式 close |
| `state` | buffering、playing、paused、ended、closed、failed |
| `done` | 已播完、关闭或失败 |
| `channel` | 格式识别后可访问 bass.channel；结束后为 null。不要自行 free |
| `bufferedBytes` | aardio 层待送入 BASS 的编码字节数，不包括 BASS 自己的缓冲 |
| `receivedBytes` | 已收到的编码字节总数 |
| `error` | 失败信息 |

事件可在构造参数中给出，也可在第一次 `write` 之前设置：

- `onStart()`：首次成功启动 BASS 播放请求，不是声卡输出首个样本的精确时钟。
- `onEnd()`：输入结束，缓冲已播完，原生资源已释放。
- `onError(err)`：失败，资源已释放。回调自身抛错记入 `callbackError`。

空块 `write("")` 是无操作，**不是 EOF**。生产者错误时调用 `finish(err)` 或 `close()`，不要把不完整的音频当作正常完成。

## 缓冲选项

```aardio
import bass.streamPlayer;
var player = bass.streamPlayer({
    initialBytes=2048;       // 初次尝试识别格式的字节数
    maxInitialBytes=65536;   // 初始头部识别预算
    maxBufferBytes=4194304;  // aardio 层待播队列上限，4 MiB
    volume=1;               // 本地播放音量 0..1
    autoPlay=true;
});
player.close();
```

初始数据不足以识别格式时，在预算内积累更多字节重试；短音频调用 `finish` 后会立即尝试识别。无法识别、缓冲超限或原生调用失败都明确返回错误，不静默丢块。

当 BASS 只能接收一部分输入时，剩余数据由播放器保留并通过短定时器继续提交。EOF 只在剩余数据全部接收后发送。每个流单独设置低延迟恢复与播放缓冲，不修改进程全局 BASS 缓冲设置。

内置解码器能处理的格式不等于所有文件都能从流中解码：依赖文件尾索引、过大的前置标签或额外插件的格式可能失败。此次重点验证的是 TTS 返回的 MP3。可通过提高初始识别预算处理合理的大头部，但不要取消内存上限。

## 已知问题：不要写入非音频数据

**只把确认是音频的字节写入播放器。** 写入 BASS 无法识别的数据（例如 HTTP 错误页、文本、格式错误的文件）时，BASS 会调用系统的 Media Foundation 继续探测格式。本机实测（32 位进程）：推送流上的这一次探测耗时约 3 秒，最后返回 `BASS_ERROR_FILEFORM`（41），却占用约 1.9GB 进程地址空间且不再释放。32 位进程随后无法创建线程，出现“内存不够”、“创建线程时遇到错误”等与音频无关的故障。

同样的数据改用内存流 `BASS_StreamCreateFile`，或先禁用 BASS 的 Media Foundation，都不会出现。这是闭源 BASS 内部的问题，本库没有做规避。需要处理不可信数据时：

- 写入前自行确认数据是音频，例如先检查 HTTP 状态码与 `Content-Type`；
- 或在创建推送流之前调用 `::Bass.BASS_SetConfig(40/*BASS_CONFIG_MF_DISABLE*/,1)` 禁用 Media Foundation。这是进程全局设置，会影响之后创建的所有 BASS 流；播放 MP3/OGG 不需要它，但依赖系统解码器的 AAC 等格式会因此无法播放；
- 只播放 MP3 时改用 `sys.mediaFoundation.streamingMp3Player`。它直接调用系统 MP3 解码器，不做格式探测；整段输入都不是 MP3 时，在 `finish()` 报告“未解码出音频”。

## 与原有 PCM 推送接口的区别

- `bass.channel.create(...).push(...)`：输入是已解码的 PCM 样本，不能拿来直接推送 MP3。
- `bass.channel.openPush(initialData,flags)` + `channel.pushFile(...)`：新增加的底层压缩文件推送接口。
- `bass.streamPlayer`：推荐入口，管理初始数据、部分写入、缓存、播放与 EOF。

### 底层接口注意事项

`openPush` 成功返回 `channel,consumedBytes`。BASS 不一定消费所有初始数据；必须先补送未消费的初始部分，再送新到达的数据。失败返回 `null,错误字符串,BASS错误码`。

`pushFile(data,length)` 返回**实际接收的字节数**。返回 0 表示暂时没有空间，不代表失败；未接收字节必须保留。`pushFile(null,0)` 是 EOF，不是“空块”。

`openPush` 禁止 `BASS_STREAM_AUTOFREE`。必须在创建线程显式 `free()`，以保持初始原生回调的生命周期；不要在其他线程释放回调。高级播放器已经正确处理这些细节。

## 连接流式 TTS

播放器有 `write/finish` 方法，可以直接作为 `web.edgeTextToSpeech` 的输出对象：

```aardio
import web.edgeTextToSpeech;
import bass.streamPlayer;

var player = bass.streamPlayer();
var task,err = web.edgeTextToSpeech().synthesize({text="收到前面的音频就开始播放。"}, player);
if(!task){player.close();return print(err.message);}

var ok,err = player.wait(120000); // 合成失败也会传给播放器
if(!ok) print(err);
task.cancel();
player.close();
```

## 验证

测试包括任意输入切分、原生部分写入、EOF、取消、空输入、缓存溢出，以及在只收到前缀时实际播放位置前进。不同输入切分在相同解码模式下生成完全一致的 PCM 数据。
