Skip to content

fix(provider): rebuild streamed MiniMax WAV header lengths - #10189

Open
iuiu-py wants to merge 2 commits into
AstrBotDevs:masterfrom
iuiu-py:fix/minimax-tts-wav-header
Open

iuiu-py wants to merge 2 commits into
AstrBotDevs:masterfrom
iuiu-py:fix/minimax-tts-wav-header

Conversation

@iuiu-py

@iuiu-py iuiu-py commented Sep 22, 2026

Copy link
Copy Markdown

Fixes #9860

What Changed

minimax_tts_api_source.py gains a module-level _repair_wav_header() helper, and _audio_play() now runs its assembled output through it before the file is written:

  • After the SSE hex stream is concatenated, the chunk list is walked and the RIFF size and data chunk size are rebuilt from the actual bytes.
  • Files whose header is already consistent, and non-WAV payloads, pass through byte-for-byte unchanged, so nothing else about the output moves.

Why

With stream: true MiniMax returns a server-side ffmpeg streamed WAV; the total length is unknowable while streaming, so both the RIFF size and the data chunk size are 0xFFFFFFFF placeholders. _audio_play() concatenated the hex chunks and get_audio() wrote them verbatim, so every minimax_tts_api_*.wav shipped an invalid container header — RIFF at offset 4 reads FF FF FF FF and ffprobe warns Ignoring maximum wav data size, file may be invalid. Browser decoders are tolerant, so this surfaced only as silent playback failures in strict environments (Android WebView, system players), with no error anywhere in AstrBot logs. Prior fix #7797 corrected the container format (MP3→WAV mismatch) but not the streamed header placeholder.

Testing

New tests/test_minimax_tts_wav_header.py:

  • test_placeholder_streamed_header_is_repaired — a 0xFFFFFFFF-placeholder WAV is rebuilt with exact RIFF/data sizes, preserving fmt and PCM payload.
  • test_consistent_header_is_left_untouched — a header that already matches the real sizes is returned unchanged (idempotence for non-streamed output).
  • test_non_wav_bytes_pass_through — empty/mp3-ish/truncated inputs are never mangled.
  • test_audio_play_repairs_streamed_wav — end-to-end through _audio_play() with hex-chunked SSE input.

All four fail on unpatched master (95e98b8 — the helper does not exist) and pass with the fix:

python -m pytest tests/test_minimax_tts_wav_header.py -q   # 4 passed
python -m ruff check astrbot/core/provider/sources/minimax_tts_api_source.py tests/test_minimax_tts_wav_header.py
python -m ruff format --check astrbot/core/provider/sources/minimax_tts_api_source.py tests/test_minimax_tts_wav_header.py

Summary by Sourcery

Fix streamed MiniMax audio container validity and ensure rank fusion weights only the retrieval channels that returned each candidate.

Bug Fixes:

  • Repair streamed MiniMax WAV headers so RIFF and data lengths reflect the actual audio payload, while preserving valid WAV and non-WAV data unchanged.
  • Prevent rank fusion from penalizing candidates that were not returned by one of the retrieval channels, preserving sparse-only and dense-only matches in top-k results.

Tests:

  • Add coverage for MiniMax WAV header repair, unchanged valid and non-WAV payloads, and end-to-end streamed audio assembly.
  • Add rank-fusion regression tests for dense-only and sparse-only candidates.

wangzifei added 2 commits September 22, 2026 15:40
The weighted fusion counted a candidate that one channel did not
retrieve as scoring 0.0 in that channel. A missing result means the
channel never scored it, not that it scored worst, and the conflation
made the sparse channel unable to contribute any unique result: with
the default dense_weight=0.9 a sparse-only candidate could score at
most 0.1, below the dense channel's mid-field, so exact FTS5 matches
for rare tokens (e.g. case variants the embedding misses) fell out of
the fused top-k entirely.

Only weight channels that actually retrieved the candidate: the fused
score is the weight-normalized average over the channels that scored
it. Candidates retrieved by both channels keep their exact previous
scores, and dense preference when both channels disagree is unchanged.

Fixes AstrBotDevs#9868
With stream=True MiniMax returns a server-side ffmpeg streamed WAV whose
RIFF and data chunk sizes are 0xFFFFFFFF placeholders, because the total
length is unknowable while streaming. _audio_play concatenated the hex
chunks and get_audio wrote the bytes verbatim, so every saved .wav had
an invalid header. Browsers tolerate it, but strict decoders (Android
WebView, system players) reject the file with no error anywhere in the
AstrBot logs.

Walk the chunk list after assembly and rebuild the RIFF and data sizes
from the actual bytes; files whose header is already consistent, and
non-WAV payloads, pass through unchanged.

Fixes AstrBotDevs#9860

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/minimax_tts_api_source.py" line_range="49-62" />
<code_context>
+    while pos + 8 <= len(audio):
</code_context>
<issue_to_address>
**issue (broader_impact):** When an inconsistent WAV contains any chunk besides `fmt ` and `data` (for example `LIST`, `fact`, `JUNK`, or a trailing chunk), `_repair_wav_header` discards those chunks and rebuilds a file containing only `fmt ` and `data`. The resulting audio loses valid metadata and can change the container layout even though only the RIFF and data lengths were intended to be repaired.

**Triggers:** When MiniMax or a compatible WAV producer includes ancillary chunks or chunks after `data`.

**Suggested fix:** Preserve the original bytes and update only the RIFF size field and the selected `data` chunk size, including required chunk padding in the rebuilt RIFF length.

```suggestion
    repaired = bytearray(audio)
    struct.pack_into("<I", repaired, 4, len(repaired) - 8)
    struct.pack_into("<I", repaired, data_start - 4, data_size)
    return bytes(repaired)
```
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/minimax_tts_api_source.py" line_range="36-38" />
<code_context>
+    while pos + 8 <= len(audio):
+        cid = audio[pos : pos + 4]
+        csize = struct.unpack_from("<I", audio, pos + 4)[0]
+        chunk_start, remaining = pos + 8, max(0, len(audio) - (pos + 8))
+        actual = min(csize, remaining)
+        if cid == b"fmt ":
+            fmt = audio[chunk_start : chunk_start + actual]
+        elif cid == b"data":
+            data_start, data_size = chunk_start, actual
+            break
+        if csize > remaining:
+            break
</code_context>
<issue_to_address>
**issue (bug_risk):** A truncated WAV with a complete `fmt ` chunk and a `data` chunk whose declared size exceeds the available payload is treated as valid audio: `actual = min(csize, remaining)` records the short payload, and the function then rewrites the header to advertise that shortened size. This converts an incomplete response into a different apparently consistent WAV instead of passing the truncated bytes through or rejecting them.

**Triggers:** When the streamed response ends before the declared `data` payload is complete.

**Suggested fix:** Detect `csize > remaining` for the `data` chunk and return the original bytes (or raise an explicit truncation error) rather than repairing it.

```suggestion
        elif cid == b"data":
            if csize > remaining:
                return audio
            data_start, data_size = chunk_start, csize
            break
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the WAV parser or revised fusion weighting is wrong, users may receive malformed audio or less relevant search results, and repaired temporary audio files may remain after a revert. The affected files are bounded and can be regenerated or cleared, so reverting restores future behavior but does not automatically undo already generated outputs.

Blocking findings: astrbot/core/provider/sources/minimax_tts_api_source.py:62, astrbot/core/provider/sources/minimax_tts_api_source.py:38


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +49 to +62
payload = audio[data_start : data_start + data_size]
return b"".join(
(
b"RIFF",
struct.pack("<I", 4 + (8 + len(fmt)) + (8 + len(payload))),
b"WAVE",
b"fmt ",
struct.pack("<I", len(fmt)),
fmt,
b"data",
struct.pack("<I", len(payload)),
payload,
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): When an inconsistent WAV contains any chunk besides fmt and data (for example LIST, fact, JUNK, or a trailing chunk), _repair_wav_header discards those chunks and rebuilds a file containing only fmt and data. The resulting audio loses valid metadata and can change the container layout even though only the RIFF and data lengths were intended to be repaired.

Triggers: When MiniMax or a compatible WAV producer includes ancillary chunks or chunks after data.

Suggested fix: Preserve the original bytes and update only the RIFF size field and the selected data chunk size, including required chunk padding in the rebuilt RIFF length.

Suggested change
payload = audio[data_start : data_start + data_size]
return b"".join(
(
b"RIFF",
struct.pack("<I", 4 + (8 + len(fmt)) + (8 + len(payload))),
b"WAVE",
b"fmt ",
struct.pack("<I", len(fmt)),
fmt,
b"data",
struct.pack("<I", len(payload)),
payload,
)
)
repaired = bytearray(audio)
struct.pack_into("<I", repaired, 4, len(repaired) - 8)
struct.pack_into("<I", repaired, data_start - 4, data_size)
return bytes(repaired)

Comment on lines +36 to +38
elif cid == b"data":
data_start, data_size = chunk_start, actual
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): A truncated WAV with a complete fmt chunk and a data chunk whose declared size exceeds the available payload is treated as valid audio: actual = min(csize, remaining) records the short payload, and the function then rewrites the header to advertise that shortened size. This converts an incomplete response into a different apparently consistent WAV instead of passing the truncated bytes through or rejecting them.

Triggers: When the streamed response ends before the declared data payload is complete.

Suggested fix: Detect csize > remaining for the data chunk and return the original bytes (or raise an explicit truncation error) rather than repairing it.

Suggested change
elif cid == b"data":
data_start, data_size = chunk_start, actual
break
elif cid == b"data":
if csize > remaining:
return audio
data_start, data_size = chunk_start, csize
break

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant