Skip to content

Commit 9b3b562

Browse files
committed
fix(model): detect audio format from magic bytes instead of hardcoding .mp3
invoke_speech_to_text wrote incoming audio to a NamedTemporaryFile with a hardcoded .mp3 suffix, so non-mp3 content (m4a/AAC, wav, ogg, flac, webm) was labeled .mp3 and rejected by OpenAI/Azure Whisper with "Invalid file format". The model-invoke payload carries only raw bytes (no filename), so detect the container from the leading magic bytes and pick the matching suffix, falling back to .mp3 for unknown content (backward compatible). Adds unit tests for the detection helper and an end-to-end test asserting the dispatch labels the temp file by format and writes the full payload.
1 parent f56d48b commit 9b3b562

4 files changed

Lines changed: 202 additions & 2 deletions

File tree

src/dify_plugin/core/plugin_executor.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,34 @@
9292
from dify_plugin.interfaces.tool import Tool
9393

9494

95+
def _detect_audio_suffix(header: bytes) -> str:
96+
"""Guess a temp-file suffix from the leading magic bytes of an audio blob.
97+
98+
The speech-to-text model-invoke payload carries only the raw audio bytes,
99+
with no original filename or extension. OpenAI/Azure Whisper endpoints
100+
determine the audio format from the multipart filename extension, so a
101+
wrong extension makes them reject otherwise-supported formats. We sniff the
102+
container from the header to label the temp file correctly.
103+
104+
Returns:
105+
The matching suffix (including the leading dot). MP3 content and any
106+
unrecognized header fall through to ``.mp3``, preserving the previous
107+
hardcoded behavior for those cases.
108+
"""
109+
suffix = ".mp3"
110+
if header[:4] == b"RIFF" and header[8:12] == b"WAVE":
111+
suffix = ".wav"
112+
elif header[:4] == b"fLaC":
113+
suffix = ".flac"
114+
elif header[:4] == b"OggS": # covers oga / ogg-opus
115+
suffix = ".ogg"
116+
elif header[4:8] == b"ftyp": # covers m4a / mp4 (AAC)
117+
suffix = ".m4a"
118+
elif header[:4] == b"\x1a\x45\xdf\xa3": # EBML (webm / matroska)
119+
suffix = ".webm"
120+
return suffix
121+
122+
95123
class PluginExecutor: # noqa: PLR0904
96124
def __init__(self, config: DifyPluginEnv, registration: PluginRegistration) -> None:
97125
self.config = config
@@ -536,8 +564,10 @@ def invoke_speech_to_text(
536564
data.model_type,
537565
)
538566

539-
with tempfile.NamedTemporaryFile(suffix=".mp3", mode="wb", delete=True) as temp:
540-
temp.write(binascii.unhexlify(data.file))
567+
audio_bytes = binascii.unhexlify(data.file)
568+
suffix = _detect_audio_suffix(audio_bytes[:16])
569+
with tempfile.NamedTemporaryFile(suffix=suffix, mode="wb", delete=True) as temp:
570+
temp.write(audio_bytes)
541571
temp.flush()
542572

543573
with pathlib.Path(temp.name).open("rb") as f:

tests/core/__init__.py

Whitespace-only changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Tests for audio format detection used by speech-to-text dispatch.
2+
3+
These exercise ``_detect_audio_suffix`` with only the leading magic bytes of
4+
each container, so no real audio files are required.
5+
"""
6+
7+
import pytest
8+
9+
from dify_plugin.core.plugin_executor import _detect_audio_suffix # noqa: PLC2701
10+
11+
12+
@pytest.mark.parametrize(
13+
("header", "expected"),
14+
[
15+
# WAV: "RIFF" .... "WAVE"
16+
(b"RIFF\x24\x08\x00\x00WAVEfmt ", ".wav"),
17+
# FLAC
18+
(b"fLaC\x00\x00\x00\x22", ".flac"),
19+
# Ogg (covers oga / ogg-opus)
20+
(b"OggS\x00\x02\x00\x00\x00\x00\x00\x00", ".ogg"),
21+
# MP4/M4A (AAC): ftyp box at offset 4
22+
(b"\x00\x00\x00\x20ftypM4A ", ".m4a"),
23+
(b"\x00\x00\x00\x18ftypmp42", ".m4a"),
24+
# WebM / Matroska EBML magic
25+
(b"\x1a\x45\xdf\xa3\x01\x00\x00\x00", ".webm"),
26+
],
27+
)
28+
def test_detect_audio_suffix_known_formats(header: bytes, expected: str) -> None:
29+
assert _detect_audio_suffix(header) == expected
30+
31+
32+
@pytest.mark.parametrize(
33+
"header",
34+
[
35+
b"",
36+
b"\x00\x00\x00\x00",
37+
b"not an audio header",
38+
# "RIFF" but not "WAVE" (e.g. AVI) must not be misdetected as wav
39+
b"RIFF\x24\x08\x00\x00AVI LIST",
40+
# MP3 (ID3 tag and raw frame sync) intentionally falls through to .mp3,
41+
# matching the previous hardcoded behavior.
42+
b"ID3\x04\x00\x00\x00\x00\x00\x00",
43+
b"\xff\xf3\x90\x64\x00\x00\x00\x00",
44+
],
45+
)
46+
def test_detect_audio_suffix_falls_back_to_mp3(header: bytes) -> None:
47+
assert _detect_audio_suffix(header) == ".mp3"
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""End-to-end tests for ``PluginExecutor.invoke_speech_to_text``.
2+
3+
These drive the real dispatch with a recording fake model to assert that the
4+
temp file handed to the speech2text model is labeled with a suffix that matches
5+
the audio container, and that the full payload (not just the sniffed header) is
6+
written.
7+
"""
8+
9+
import binascii
10+
import pathlib
11+
from collections.abc import Mapping
12+
from typing import IO
13+
14+
import pytest
15+
16+
from dify_plugin.config.config import DifyPluginEnv
17+
from dify_plugin.core.entities.plugin.request import ModelInvokeSpeech2TextRequest
18+
from dify_plugin.core.plugin_executor import PluginExecutor
19+
from dify_plugin.core.runtime import Session
20+
from dify_plugin.entities import I18nObject
21+
from dify_plugin.entities.model import AIModelEntity, FetchFrom, ModelType
22+
from dify_plugin.errors.model import InvokeError
23+
from dify_plugin.interfaces.model.speech2text_model import Speech2TextModel
24+
25+
26+
def _model_entity() -> AIModelEntity:
27+
return AIModelEntity(
28+
model="whisper",
29+
label=I18nObject(en_us="whisper"),
30+
model_type=ModelType.SPEECH2TEXT,
31+
fetch_from=FetchFrom.PREDEFINED_MODEL,
32+
model_properties={},
33+
parameter_rules=[],
34+
)
35+
36+
37+
class RecordingSpeech2TextModel(Speech2TextModel):
38+
"""Captures the temp-file suffix and bytes the executor hands to the model."""
39+
40+
model_type = ModelType.SPEECH2TEXT
41+
42+
def __init__(self) -> None:
43+
super().__init__(model_schemas=[_model_entity()])
44+
self.captured_suffix: str | None = None
45+
self.captured_bytes: bytes | None = None
46+
47+
def validate_credentials(self, model: str, credentials: Mapping) -> None:
48+
del model, credentials
49+
50+
@property
51+
def _invoke_error_mapping(self) -> dict[type[InvokeError], list[type[Exception]]]:
52+
return {}
53+
54+
def _invoke(
55+
self,
56+
model: str,
57+
credentials: dict,
58+
file: IO[bytes],
59+
user: str | None = None,
60+
) -> str:
61+
del model, credentials, user
62+
self.captured_suffix = pathlib.Path(file.name).suffix
63+
self.captured_bytes = file.read()
64+
return "transcribed"
65+
66+
67+
class _Registration:
68+
def __init__(self, model_instance: object) -> None:
69+
self.model_instance = model_instance
70+
71+
def get_model_instance(self, provider: str, model_type: ModelType) -> object:
72+
del provider, model_type
73+
return self.model_instance
74+
75+
76+
def _request(audio: bytes) -> ModelInvokeSpeech2TextRequest:
77+
return ModelInvokeSpeech2TextRequest(
78+
user_id="user-1",
79+
provider="provider",
80+
model_type=ModelType.SPEECH2TEXT,
81+
model="whisper",
82+
credentials={},
83+
file=binascii.hexlify(audio).decode("ascii"),
84+
)
85+
86+
87+
# Real container headers padded with trailing bytes so the test also proves the
88+
# entire payload is written, not just the sniffed 16-byte header.
89+
WAV = b"RIFF\x24\x08\x00\x00WAVEfmt " + b"\x00" * 64
90+
M4A = b"\x00\x00\x00\x20ftypM4A " + b"\x11" * 64
91+
OGG = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00" + b"\x22" * 64
92+
UNKNOWN = b"\x00\x01\x02\x03 not a known audio container " + b"\x33" * 32
93+
94+
95+
@pytest.mark.parametrize(
96+
("audio", "expected_suffix"),
97+
[
98+
(WAV, ".wav"),
99+
(M4A, ".m4a"),
100+
(OGG, ".ogg"),
101+
(UNKNOWN, ".mp3"),
102+
],
103+
)
104+
def test_invoke_speech_to_text_labels_temp_file_by_format(
105+
audio: bytes,
106+
expected_suffix: str,
107+
) -> None:
108+
model = RecordingSpeech2TextModel()
109+
executor = PluginExecutor(DifyPluginEnv(), _Registration(model))
110+
111+
result = executor.invoke_speech_to_text(Session.empty_session(), _request(audio))
112+
113+
assert result == {"result": "transcribed"}
114+
assert model.captured_suffix == expected_suffix
115+
# The full payload reaches the model, not just the sniffed header.
116+
assert model.captured_bytes == audio
117+
118+
119+
def test_invoke_speech_to_text_rejects_non_speech2text_model() -> None:
120+
executor = PluginExecutor(DifyPluginEnv(), _Registration(object()))
121+
122+
with pytest.raises(ValueError, match="not found for provider"):
123+
executor.invoke_speech_to_text(Session.empty_session(), _request(WAV))

0 commit comments

Comments
 (0)