fix(tts): propagate and validate actual audio MIME (#41043)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
authored and GitHub committed 2026-08-24 03:38:09 +00:00
1 parent ea6c15b68e
commit 3f7e6cdcb4
36 files changed
+1363 -765

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
TTS_AUTO_PLAY_TIMEOUT = 5
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
TTS_AUTO_PLAY_YIELD_CPU_TIME = 0.02
+5 -3
View File
@@ -151,8 +151,9 @@ class TextApi(Resource):
tags=["TTS"],
responses={
200: (
"Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; "
"otherwise the provider output is returned directly."
"Returns the generated audio. The `Content-Type` header reflects the provider audio container, "
"verified from the response bytes when recognizable. The binary response can be AAC, FLAC, MP4, "
"MP3, Ogg, WAV, or WebM."
),
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
@@ -165,7 +166,8 @@ class TextApi(Resource):
},
)
@expect_with_user(service_api_ns, TextToAudioPayload)
@binary_response(service_api_ns, "audio/mpeg")
# OpenAPI 2 cannot express a runtime-selected response MIME type, so document the body as generic binary.
@binary_response(service_api_ns, "application/octet-stream")
@service_api_ns.doc("text_to_audio")
@service_api_ns.doc(description="Convert text to audio using text-to-speech")
@service_api_ns.doc(
@@ -11,7 +11,6 @@ from typing import Any, Union
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.common.graph_runtime_state_support import GraphRuntimeStateSupport
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -70,7 +69,7 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.base.tts import AppGeneratorTTSPublisher
from core.db.session_factory import session_factory
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
@@ -349,9 +348,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if not publisher:
return None
audio_msg = publisher.check_and_get_audio()
if audio_msg and isinstance(audio_msg, AudioTrunk) and audio_msg.status != "finish":
return MessageAudioStreamResponse(audio=audio_msg.audio, task_id=task_id)
return None
if audio_msg is None:
return None
if audio_msg.status == "responding":
return MessageAudioStreamResponse(audio=audio_msg.audio, audio_type=audio_msg.audio_type, task_id=task_id)
if audio_msg.status in {"finish", "error"}:
return None
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_msg.status}")
def _wrapper_process_stream_response(
self, trace_manager: TraceQueueManager | None = None
@@ -362,7 +365,8 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
features_dict = self._workflow_features_dict
if (
features_dict.get("text_to_speech")
self._base_task_pipeline.stream
and features_dict.get("text_to_speech")
and features_dict["text_to_speech"].get("enabled")
and features_dict["text_to_speech"].get("autoPlay") == "enabled"
):
@@ -370,34 +374,41 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
tenant_id, features_dict["text_to_speech"].get("voice"), features_dict["text_to_speech"].get("language")
)
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while True:
audio_response = self._listen_audio_msg(publisher=tts_publisher, task_id=task_id)
if audio_response:
try:
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while audio_response := self._listen_audio_msg(publisher=tts_publisher, task_id=task_id):
yield audio_response
else:
break
yield response
if tts_publisher and isinstance(response, ErrorStreamResponse):
tts_publisher.cancel()
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
yield response
return
yield response
start_listener_time = time.time()
while (time.time() - start_listener_time) < TTS_AUTO_PLAY_TIMEOUT:
try:
if not tts_publisher:
break
audio_trunk = tts_publisher.check_and_get_audio()
if audio_trunk is None:
time.sleep(TTS_AUTO_PLAY_YIELD_CPU_TIME)
if tts_publisher is None:
return
tts_publisher.publish(None)
while True:
audio_trunk = tts_publisher.check_and_get_audio(block=True)
assert audio_trunk is not None
if audio_trunk.status == "responding":
yield MessageAudioStreamResponse(
audio=audio_trunk.audio, audio_type=audio_trunk.audio_type, task_id=task_id
)
continue
if audio_trunk.status == "finish":
break
else:
start_listener_time = time.time()
yield MessageAudioStreamResponse(audio=audio_trunk.audio, task_id=task_id)
except Exception:
logger.exception("Failed to listen audio message, task_id: %s", task_id)
break
if tts_publisher:
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status not in {"finish", "error"}:
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_trunk.status}")
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status == "error":
if audio_trunk.error is None:
raise RuntimeError("TTS publisher returned an error terminal without an exception")
yield ErrorStreamResponse(err=audio_trunk.error, task_id=task_id)
return
finally:
if tts_publisher:
tts_publisher.cancel()
@contextmanager
def _database_session(self):
@@ -1042,9 +1053,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
):
yield from responses
if tts_publisher:
tts_publisher.publish(None)
if self._conversation_name_generate_thread:
logger.debug("Conversation name generation running as daemon thread")
@@ -6,7 +6,6 @@ from typing import Union
from sqlalchemy.orm import Session, sessionmaker
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.common.graph_runtime_state_support import GraphRuntimeStateSupport
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -59,7 +58,7 @@ from core.app.entities.task_entities import (
WorkflowStartStreamResponse,
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.base.tts import AppGeneratorTTSPublisher
from core.ops.ops_trace_manager import TraceQueueManager
from core.workflow.system_variables import build_system_variables
from extensions.ext_database import db
@@ -246,9 +245,13 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if not publisher:
return None
audio_msg = publisher.check_and_get_audio()
if audio_msg and isinstance(audio_msg, AudioTrunk) and audio_msg.status != "finish":
return MessageAudioStreamResponse(audio=audio_msg.audio, task_id=task_id)
return None
if audio_msg is None:
return None
if audio_msg.status == "responding":
return MessageAudioStreamResponse(audio=audio_msg.audio, audio_type=audio_msg.audio_type, task_id=task_id)
if audio_msg.status in {"finish", "error"}:
return None
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_msg.status}")
def _wrapper_process_stream_response(
self, trace_manager: TraceQueueManager | None = None
@@ -259,7 +262,8 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
features_dict = self._workflow_features_dict
if (
features_dict.get("text_to_speech")
self._base_task_pipeline.stream
and features_dict.get("text_to_speech")
and features_dict["text_to_speech"].get("enabled")
and features_dict["text_to_speech"].get("autoPlay") == "enabled"
):
@@ -267,35 +271,41 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
tenant_id, features_dict["text_to_speech"].get("voice"), features_dict["text_to_speech"].get("language")
)
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while True:
audio_response = self._listen_audio_msg(publisher=tts_publisher, task_id=task_id)
if audio_response:
try:
for response in self._process_stream_response(tts_publisher=tts_publisher, trace_manager=trace_manager):
while audio_response := self._listen_audio_msg(publisher=tts_publisher, task_id=task_id):
yield audio_response
else:
break
yield response
if tts_publisher and isinstance(response, ErrorStreamResponse):
tts_publisher.cancel()
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
yield response
return
yield response
start_listener_time = time.time()
while (time.time() - start_listener_time) < TTS_AUTO_PLAY_TIMEOUT:
try:
if not tts_publisher:
break
audio_trunk = tts_publisher.check_and_get_audio()
if audio_trunk is None:
# release cpu
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
time.sleep(TTS_AUTO_PLAY_YIELD_CPU_TIME)
if tts_publisher is None:
return
tts_publisher.publish(None)
while True:
audio_trunk = tts_publisher.check_and_get_audio(block=True)
assert audio_trunk is not None
if audio_trunk.status == "responding":
yield MessageAudioStreamResponse(
audio=audio_trunk.audio, audio_type=audio_trunk.audio_type, task_id=task_id
)
continue
if audio_trunk.status == "finish":
break
else:
yield MessageAudioStreamResponse(audio=audio_trunk.audio, task_id=task_id)
except Exception:
logger.exception("Fails to get audio trunk, task_id: %s", task_id)
break
if tts_publisher:
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status not in {"finish", "error"}:
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_trunk.status}")
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio_trunk.status == "error":
if audio_trunk.error is None:
raise RuntimeError("TTS publisher returned an error terminal without an exception")
yield ErrorStreamResponse(err=audio_trunk.error, task_id=task_id)
return
finally:
if tts_publisher:
tts_publisher.cancel()
@contextmanager
def _database_session(self):
@@ -742,9 +752,6 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
):
yield from responses
if tts_publisher:
tts_publisher.publish(None)
def _save_workflow_app_log(self, *, session: Session, workflow_run_id: str | None):
invoke_from = self._application_generate_entity.invoke_from
match invoke_from:
+1
View File
@@ -132,6 +132,7 @@ class MessageAudioStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.TTS_MESSAGE
audio: str
audio_type: str | None = None
class MessageAudioEndStreamResponse(StreamResponse):
@@ -8,7 +8,6 @@ from typing import Any, cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import (
AgentChatAppGenerateEntity,
@@ -46,7 +45,7 @@ from core.app.entities.task_entities import (
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.app.task_pipeline.message_file_utils import prepare_file_dict
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.base.tts import AppGeneratorTTSPublisher
from core.db.session_factory import session_factory
from core.model_manager import ModelInstance
from core.ops.entities.trace_entity import TraceTaskName
@@ -210,10 +209,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
if publisher is None:
return None
audio_msg = publisher.check_and_get_audio()
if audio_msg and isinstance(audio_msg, AudioTrunk) and audio_msg.status != "finish":
# audio_str = audio_msg.audio.decode('utf-8', errors='ignore')
return MessageAudioStreamResponse(audio=audio_msg.audio, task_id=task_id)
return None
if audio_msg is None:
return None
if audio_msg.status == "responding":
return MessageAudioStreamResponse(audio=audio_msg.audio, audio_type=audio_msg.audio_type, task_id=task_id)
if audio_msg.status in {"finish", "error"}:
return None
raise RuntimeError(f"TTS publisher returned an unknown status: {audio_msg.status}")
def _wrapper_process_stream_response(
self, trace_manager: TraceQueueManager | None = None
@@ -223,40 +225,47 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
publisher = None
text_to_speech_dict = cast(dict[str, Any], self._app_config.app_model_config_dict.get("text_to_speech"))
if (
text_to_speech_dict
self.stream
and text_to_speech_dict
and text_to_speech_dict.get("autoPlay") == "enabled"
and text_to_speech_dict.get("enabled")
):
publisher = AppGeneratorTTSPublisher(
tenant_id, text_to_speech_dict.get("voice", ""), text_to_speech_dict.get("language", None)
)
for response in self._process_stream_response(publisher=publisher, trace_manager=trace_manager):
while True:
audio_response = self._listen_audio_msg(publisher, task_id)
if audio_response:
try:
for response in self._process_stream_response(publisher=publisher, trace_manager=trace_manager):
while audio_response := self._listen_audio_msg(publisher, task_id):
yield audio_response
else:
break
yield response
if publisher and isinstance(response, ErrorStreamResponse):
publisher.cancel()
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
yield response
return
yield response
start_listener_time = time.time()
# timeout
while (time.time() - start_listener_time) < TTS_AUTO_PLAY_TIMEOUT:
if publisher is None:
break
audio = publisher.check_and_get_audio()
if audio is None:
# release cpu
# sleep 20 ms ( 40ms => 1280 byte audio file,20ms => 640 byte audio file)
time.sleep(TTS_AUTO_PLAY_YIELD_CPU_TIME)
continue
if audio.status == "finish":
break
else:
start_listener_time = time.time()
yield MessageAudioStreamResponse(audio=audio.audio, task_id=task_id)
if publisher:
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
return
publisher.publish(None)
while True:
audio = publisher.check_and_get_audio(block=True)
assert audio is not None
if audio.status == "responding":
yield MessageAudioStreamResponse(audio=audio.audio, audio_type=audio.audio_type, task_id=task_id)
continue
if audio.status not in {"finish", "error"}:
raise RuntimeError(f"TTS publisher returned an unknown status: {audio.status}")
yield MessageAudioEndStreamResponse(audio="", task_id=task_id)
if audio.status == "error":
if audio.error is None:
raise RuntimeError("TTS publisher returned an error terminal without an exception")
yield ErrorStreamResponse(err=audio.error, task_id=task_id)
return
finally:
if publisher:
publisher.cancel()
def _process_stream_response(
self, publisher: AppGeneratorTTSPublisher | None, trace_manager: TraceQueueManager | None = None
@@ -380,8 +389,6 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
yield self.ping_stream_response()
case _:
continue
if publisher:
publisher.publish(None)
if self._conversation_name_generate_thread:
logger.debug("Conversation name generation running as daemon thread")
@@ -1,10 +1,8 @@
import base64
import concurrent.futures
import logging
import queue
import re
import threading
from collections.abc import Iterable
from core.app.entities.queue_entities import (
MessageQueueMessage,
@@ -14,42 +12,23 @@ from core.app.entities.queue_entities import (
QueueTextChunkEvent,
WorkflowQueueMessage,
)
from core.model_manager import ModelInstance, ModelManager
from core.base.tts.audio_mime import (
DEFAULT_TTS_AUDIO_MIME_TYPE,
get_model_audio_mime_type,
inspect_audio_stream,
)
from core.model_manager import ModelManager
from graphon.model_runtime.entities.message_entities import TextPromptMessageContent
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
class AudioTrunk:
def __init__(self, status: str, audio):
def __init__(self, status: str, audio, audio_type: str | None = None, error: Exception | None = None):
self.audio = audio
self.status = status
def _invoice_tts(text_content: str, model_instance: ModelInstance, voice: str):
if not text_content or text_content.isspace():
return
return model_instance.invoke_tts(content_text=text_content.strip(), voice=voice)
def _process_future(
future_queue: queue.Queue[concurrent.futures.Future[Iterable[bytes] | None] | None],
audio_queue: queue.Queue[AudioTrunk],
):
while True:
try:
future = future_queue.get()
if future is None:
break
invoke_result = future.result()
if not invoke_result:
continue
for audio in invoke_result:
audio_base64 = base64.b64encode(bytes(audio))
audio_queue.put(AudioTrunk("responding", audio=audio_base64))
except Exception as e:
logging.getLogger(__name__).warning(e)
break
audio_queue.put(AudioTrunk("finish", b""))
self.audio_type = audio_type
self.error = error
class AppGeneratorTTSPublisher:
@@ -64,6 +43,7 @@ class AppGeneratorTTSPublisher:
self.model_instance = self.model_manager.get_default_model_instance(
tenant_id=self.tenant_id, model_type=ModelType.TTS
)
self._declared_audio_mime_type = get_model_audio_mime_type(self.model_instance)
self.voices = self.model_instance.get_tts_voices(language=language)
values = [voice.get("value") for voice in self.voices]
self.voice = voice
@@ -71,26 +51,30 @@ class AppGeneratorTTSPublisher:
self.voice = self.voices[0].get("value")
self.max_sentence = 2
self._last_audio_event: AudioTrunk | None = None
# FIXME better way to handle this threading.start
threading.Thread(target=self._runtime).start()
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=3)
self._cancelled = threading.Event()
threading.Thread(target=self._runtime, daemon=True).start()
def publish(self, message: WorkflowQueueMessage | MessageQueueMessage | None, /):
self._msg_queue.put(message)
def cancel(self):
if self._cancelled.is_set():
return
self._cancelled.set()
self._msg_queue.put(None)
def _runtime(self):
future_queue: queue.Queue[concurrent.futures.Future[Iterable[bytes] | None] | None] = queue.Queue()
threading.Thread(target=_process_future, args=(future_queue, self._audio_queue)).start()
while True:
try:
audio_type = self._declared_audio_mime_type or DEFAULT_TTS_AUDIO_MIME_TYPE
resolved_audio_type: str | None = None
try:
while True:
message = self._msg_queue.get()
if self._cancelled.is_set():
return
text_content = ""
incremental_request = False
if message is None:
if self.msg_text and len(self.msg_text.strip()) > 0:
futures_result = self.executor.submit(
_invoice_tts, self.msg_text, self.model_instance, self.voice
)
future_queue.put(futures_result)
break
text_content = self.msg_text
else:
match message.event:
case QueueAgentMessageEvent() | QueueLLMChunkEvent():
@@ -113,34 +97,57 @@ class AppGeneratorTTSPublisher:
output = message.event.outputs.get("output", "")
if isinstance(output, str):
self.msg_text += output
self.last_message = message
sentence_arr, text_tmp = self._extract_sentence(self.msg_text)
if len(sentence_arr) >= min(self.max_sentence, 7):
self.max_sentence += 1
text_content = "".join(sentence_arr)
futures_result = self.executor.submit(_invoice_tts, text_content, self.model_instance, self.voice)
future_queue.put(futures_result)
if isinstance(text_tmp, str):
sentence_arr, text_tmp = self._extract_sentence(self.msg_text)
if self._declared_audio_mime_type == DEFAULT_TTS_AUDIO_MIME_TYPE and len(sentence_arr) >= min(
self.max_sentence, 7
):
self.max_sentence += 1
text_content = "".join(sentence_arr)
self.msg_text = text_tmp
else:
self.msg_text = ""
incremental_request = True
except Exception as e:
self.logger.warning(e)
break
future_queue.put(None)
if text_content and not text_content.isspace():
invoke_result = self.model_instance.invoke_tts(content_text=text_content.strip(), voice=self.voice)
audio_stream, next_audio_type = inspect_audio_stream(invoke_result, self._declared_audio_mime_type)
if self._cancelled.is_set():
return
if incremental_request and next_audio_type != DEFAULT_TTS_AUDIO_MIME_TYPE:
raise InvokeBadRequestError(
"The TTS model declared MP3 but returned a format that cannot be played incrementally"
)
if resolved_audio_type and resolved_audio_type != next_audio_type:
raise InvokeBadRequestError(
"TTS provider changed MIME type between audio responses: "
f"{resolved_audio_type} then {next_audio_type}"
)
resolved_audio_type = audio_type = next_audio_type
for audio in audio_stream:
# ponytail: reads stop at the next chunk; add transport cancellation when Graphon exposes it.
if self._cancelled.is_set():
return
self._audio_queue.put(
AudioTrunk(
"responding",
audio=base64.b64encode(audio),
audio_type=audio_type,
)
)
def check_and_get_audio(self):
if message is None:
break
except Exception as e:
self.logger.warning("TTS generation failed", exc_info=True)
self._audio_queue.put(AudioTrunk("error", b"", audio_type=audio_type, error=e))
return
self._audio_queue.put(AudioTrunk("finish", b"", audio_type=audio_type))
def check_and_get_audio(self, *, block: bool = False):
try:
if self._last_audio_event and self._last_audio_event.status == "finish":
if self.executor:
self.executor.shutdown(wait=False)
if self._last_audio_event and self._last_audio_event.status in {"finish", "error"}:
return self._last_audio_event
audio = self._audio_queue.get_nowait()
if audio and audio.status == "finish":
self.executor.shutdown(wait=False)
if audio:
self._last_audio_event = audio
audio = self._audio_queue.get(block=block)
self._last_audio_event = audio
return audio
except queue.Empty:
return None
+180
View File
@@ -0,0 +1,180 @@
import logging
from collections.abc import Generator, Iterable
from itertools import chain
from typing import TYPE_CHECKING
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
if TYPE_CHECKING:
from core.model_manager import ModelInstance
logger = logging.getLogger(__name__)
DEFAULT_TTS_AUDIO_MIME_TYPE = "audio/mpeg"
_SIGNATURE_SIZE = 32
_AUDIO_MIME_TYPE_ALIASES = {
"mp3": "audio/mpeg",
"audio/mp3": "audio/mpeg",
"audio/mpeg": "audio/mpeg",
"wav": "audio/wav",
"wave": "audio/wav",
"audio/wav": "audio/wav",
"audio/wave": "audio/wav",
"audio/x-wav": "audio/wav",
"ogg": "audio/ogg",
"oga": "audio/ogg",
"audio/ogg": "audio/ogg",
"flac": "audio/flac",
"audio/flac": "audio/flac",
"aac": "audio/aac",
"audio/aac": "audio/aac",
"m4a": "audio/mp4",
"mp4": "audio/mp4",
"audio/mp4": "audio/mp4",
"webm": "audio/webm",
"audio/webm": "audio/webm",
}
def normalize_audio_mime_type(audio_type: object | None) -> str | None:
"""Convert provider audio-type metadata into a browser MIME type."""
if not isinstance(audio_type, str):
return None
mime_type = audio_type.split(";", maxsplit=1)[0].strip().lower()
return _AUDIO_MIME_TYPE_ALIASES.get(mime_type)
def get_model_audio_mime_type(model_instance: "ModelInstance") -> str | None:
"""Read the model's declared TTS MIME type without making it mandatory."""
try:
model_schema = model_instance.get_model_schema()
audio_type = model_schema.model_properties.get(ModelPropertyKey.AUDIO_TYPE)
except Exception:
logger.debug("Unable to resolve the declared audio type for the TTS model", exc_info=True)
return None
return normalize_audio_mime_type(audio_type)
def sniff_audio_mime_type(audio: bytes | bytearray | memoryview) -> str | None:
"""Identify common audio containers from their leading bytes."""
signature = bytes(audio[:_SIGNATURE_SIZE])
if len(signature) >= 12 and signature[:4] == b"RIFF" and signature[8:12] == b"WAVE":
return "audio/wav"
if signature.startswith(b"OggS"):
return "audio/ogg"
if signature.startswith(b"fLaC"):
return "audio/flac"
if signature.startswith(b"\x1aE\xdf\xa3"):
return "audio/webm"
if len(signature) >= 8 and signature[4:8] == b"ftyp":
return "audio/mp4"
if len(signature) >= 2 and signature[0] == 0xFF:
if signature[1] & 0xF6 == 0xF0:
return "audio/aac"
if signature[1] & 0xE0 == 0xE0:
return "audio/mpeg"
return None
def _normalize_reported_mime_type(mime_type: object | None, source: str) -> str | None:
if mime_type is None:
return None
normalized_mime_type = normalize_audio_mime_type(mime_type)
if normalized_mime_type is None:
raise InvokeBadRequestError(f"TTS provider returned an unsupported {source} MIME type: {mime_type!r}")
return normalized_mime_type
def _extract_audio_chunk(chunk: bytes | bytearray | memoryview | TTSAudioChunk) -> tuple[bytes, str | None]:
"""Accept old byte chunks and the compatibility carrier for current Graphon."""
if isinstance(chunk, TTSAudioChunk):
return bytes(chunk), chunk.mime_type
if isinstance(chunk, (bytes, bytearray, memoryview)):
return bytes(chunk), None
raise InvokeBadRequestError("TTS provider returned a chunk that is not audio bytes")
def resolve_audio_mime_type(
audio: bytes | bytearray | memoryview,
declared_mime_type: str | None = None,
reported_mime_type: str | None = None,
) -> str:
"""Validate the provider MIME against magic bytes and choose the true type.
``reported_mime_type`` is emitted with a TTS chunk by current plugin
runtimes, and therefore takes precedence over schema metadata. Schema
metadata remains a fallback for old plugins that omit the new field.
"""
normalized_declared_mime_type = _normalize_reported_mime_type(declared_mime_type, "schema")
normalized_reported_mime_type = _normalize_reported_mime_type(reported_mime_type, "chunk")
detected_mime_type = sniff_audio_mime_type(audio)
expected_mime_type = normalized_reported_mime_type or normalized_declared_mime_type
if expected_mime_type and detected_mime_type and expected_mime_type != detected_mime_type:
raise InvokeBadRequestError(
"TTS provider output MIME does not match its audio bytes: "
f"declared {expected_mime_type}, detected {detected_mime_type}"
)
if normalized_reported_mime_type:
if normalized_declared_mime_type and normalized_declared_mime_type != normalized_reported_mime_type:
logger.info(
"TTS chunk MIME %s overrides schema MIME %s",
normalized_reported_mime_type,
normalized_declared_mime_type,
)
return normalized_reported_mime_type
if detected_mime_type:
return detected_mime_type
return normalized_declared_mime_type or DEFAULT_TTS_AUDIO_MIME_TYPE
def inspect_audio_stream(
audio_stream: Iterable[bytes | bytearray | memoryview | TTSAudioChunk], declared_mime_type: str | None = None
) -> tuple[Generator[bytes, None, None], str]:
"""Peek at and validate a TTS stream without dropping its leading bytes."""
iterator = iter(audio_stream)
leading_chunks: list[bytes] = []
signature = bytearray()
reported_mime_type: str | None = None
while len(signature) < _SIGNATURE_SIZE:
try:
chunk, chunk_mime_type = _extract_audio_chunk(next(iterator))
except StopIteration:
break
normalized_chunk_mime_type = _normalize_reported_mime_type(chunk_mime_type, "chunk")
if normalized_chunk_mime_type:
if reported_mime_type and reported_mime_type != normalized_chunk_mime_type:
raise InvokeBadRequestError(
"TTS provider changed MIME type within one audio response: "
f"{reported_mime_type} then {normalized_chunk_mime_type}"
)
reported_mime_type = normalized_chunk_mime_type
leading_chunks.append(chunk)
signature.extend(chunk[: _SIGNATURE_SIZE - len(signature)])
mime_type = resolve_audio_mime_type(signature, declared_mime_type, reported_mime_type)
def validated_stream() -> Generator[bytes, None, None]:
for chunk in chain(leading_chunks, iterator):
audio, chunk_mime_type = _extract_audio_chunk(chunk)
normalized_chunk_mime_type = _normalize_reported_mime_type(chunk_mime_type, "chunk")
if normalized_chunk_mime_type and normalized_chunk_mime_type != mime_type:
raise InvokeBadRequestError(
"TTS provider changed MIME type within one audio response: "
f"{mime_type} then {normalized_chunk_mime_type}"
)
yield audio
return validated_stream(), mime_type
@@ -3,6 +3,7 @@ from binascii import hexlify, unhexlify
from collections.abc import Generator
from typing import Any
from core.base.tts.audio_mime import get_model_audio_mime_type, inspect_audio_stream
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.model_manager import ModelManager
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
@@ -214,12 +215,11 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
model=payload.model,
)
# invoke model
response = model_instance.invoke_tts(content_text=payload.content_text, voice=payload.voice)
def handle() -> Generator[dict[str, Any], None, None]:
for chunk in response:
yield {"result": hexlify(chunk).decode("utf-8")}
response = model_instance.invoke_tts(content_text=payload.content_text, voice=payload.voice)
audio_stream, mime_type = inspect_audio_stream(response, get_model_audio_mime_type(model_instance))
for chunk in audio_stream:
yield {"result": hexlify(chunk).decode("utf-8"), "mime_type": mime_type}
return handle()
+22
View File
@@ -135,6 +135,28 @@ class PluginStringResultResponse(BaseModel):
result: str = Field(description="The result of the string.")
class PluginTTSResultResponse(PluginStringResultResponse):
"""One TTS data chunk returned by the plugin daemon."""
mime_type: str | None = Field(default=None, description="The MIME type of the audio chunk.")
class TTSAudioChunk(bytes):
"""A bytes-compatible TTS chunk carrying optional daemon MIME metadata.
The currently released Graphon runtime exposes TTS output as ``bytes``.
This carrier preserves that contract while retaining the daemon field until
the structured Graphon ``TTSChunk`` protocol is available in a release.
"""
mime_type: str | None
def __new__(cls, data: bytes | bytearray | memoryview, mime_type: str | None = None):
instance = super().__new__(cls, data)
instance.mime_type = mime_type
return instance
class PluginVoiceEntity(BaseModel):
name: str = Field(description="The name of the voice.")
value: str = Field(description="The value of the voice.")
+5 -3
View File
@@ -11,7 +11,9 @@ from core.plugin.entities.plugin_daemon import (
PluginModelSchemaEntity,
PluginStringResultResponse,
PluginTextEmbeddingNumTokensResponse,
PluginTTSResultResponse,
PluginVoicesResponse,
TTSAudioChunk,
)
from core.plugin.impl.base import BasePluginClient
from core.plugin.impl.exc import PluginInvokeError, PluginLLMPollingUnsupportedError
@@ -580,14 +582,14 @@ class PluginModelClient(BasePluginClient):
credentials: dict[str, Any],
content_text: str,
voice: str,
) -> Generator[bytes, None, None]:
) -> Generator[TTSAudioChunk, None, None]:
"""
Invoke tts
"""
response = self._request_with_plugin_daemon_response_stream(
method="POST",
path=f"plugin/{tenant_id}/dispatch/tts/invoke",
type_=PluginStringResultResponse,
type_=PluginTTSResultResponse,
data=jsonable_encoder(
self._dispatch_payload(
user_id=user_id,
@@ -611,7 +613,7 @@ class PluginModelClient(BasePluginClient):
try:
for result in response:
hex_str = result.result
yield binascii.unhexlify(hex_str)
yield TTSAudioChunk(binascii.unhexlify(hex_str), mime_type=result.mime_type)
except PluginDaemonInnerError as e:
raise ValueError(e.message + str(e.code))
@@ -1,9 +1,9 @@
import io
from collections.abc import Generator
from typing import Any, override
from sqlalchemy.orm import Session
from core.base.tts.audio_mime import get_model_audio_mime_type, inspect_audio_stream
from core.model_manager import ModelManager
from core.plugin.entities.parameters import PluginParameterOption
from core.tools.builtin_tool.tool import BuiltinTool
@@ -45,15 +45,12 @@ class TTSTool(BuiltinTool):
else:
raise ValueError("Sorry, no voice available.")
tts = model_instance.invoke_tts(content_text=tool_parameters.get("text"), voice=voice) # type: ignore[arg-type]
buffer = io.BytesIO()
for chunk in tts:
buffer.write(chunk)
wav_bytes = buffer.getvalue()
audio_stream, mime_type = inspect_audio_stream(tts, get_model_audio_mime_type(model_instance))
audio_bytes = b"".join(audio_stream)
yield self.create_text_message("Audio generated successfully")
yield self.create_blob_message(
blob=wav_bytes,
meta={"mime_type": "audio/x-wav"},
blob=audio_bytes,
meta={"mime_type": mime_type},
)
def get_available_models(self) -> list[tuple[str, str, list[Any]]]:
+1 -1
View File
@@ -300,7 +300,7 @@ Convert text to speech.
| Code | Description |
| ---- | ----------- |
| 200 | Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. |
| 200 | Returns the generated audio. The `Content-Type` header reflects the provider audio container, verified from the response bytes when recognizable. The binary response can be AAC, FLAC, MP4, MP3, Ogg, WAV, or WebM. |
| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model does not support this operation. - `completion_request_error` : Text-to-speech request failed. |
| 401 | Unauthorized - invalid API token |
| 403 | Forbidden - token scope, app, dataset, or workspace access denied |
+29 -10
View File
@@ -1,7 +1,7 @@
import io
import logging
import uuid
from collections.abc import Generator
from collections.abc import Iterable
from typing import cast
from flask import Response, stream_with_context
@@ -11,6 +11,7 @@ from werkzeug.datastructures import FileStorage
from constants import AUDIO_EXTENSIONS
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.base.tts.audio_mime import get_model_audio_mime_type, inspect_audio_stream, resolve_audio_mime_type
from core.model_manager import ModelManager
from graphon.model_runtime.entities.model_entities import ModelType
from models.agent_config_entities import AgentSoulConfig
@@ -37,6 +38,21 @@ _ASR_MIME_TYPE_ALIASES = {
logger = logging.getLogger(__name__)
def _create_tts_response(
audio: Iterable[bytes] | bytes | bytearray | memoryview, declared_mime_type: str | None
) -> Response:
"""Create a response whose Content-Type matches the returned audio container."""
if isinstance(audio, (bytes, bytearray, memoryview)):
audio_bytes = bytes(audio)
return Response(audio_bytes, content_type=resolve_audio_mime_type(audio_bytes, declared_mime_type))
audio_stream, mime_type = inspect_audio_stream(audio, declared_mime_type)
return Response(
stream_with_context(audio_stream), # pyrefly: ignore[no-matching-overload]
content_type=mime_type,
)
class AudioService:
@staticmethod
def _get_message_by_ref(session: Session, message_ref: MessageRef) -> Message | None:
@@ -209,7 +225,10 @@ class AudioService:
else:
raise ValueError("Sorry, no voice available.")
return model_instance.invoke_tts(content_text=text_content.strip(), voice=voice)
return (
model_instance.invoke_tts(content_text=text_content.strip(), voice=voice),
get_model_audio_mime_type(model_instance),
)
except Exception as e:
raise e
@@ -225,17 +244,17 @@ class AudioService:
return None
else:
response = invoke_tts(text_content=message.answer, app_model=app_model, voice=voice, is_draft=is_draft)
if isinstance(response, Generator):
return Response(stream_with_context(response), content_type="audio/mpeg") # type: ignore
return response
response, declared_mime_type = invoke_tts(
text_content=message.answer, app_model=app_model, voice=voice, is_draft=is_draft
)
return _create_tts_response(response, declared_mime_type)
else:
if text is None:
raise ValueError("Text is required")
response = invoke_tts(text_content=text, app_model=app_model, voice=voice, is_draft=is_draft)
if isinstance(response, Generator):
return Response(stream_with_context(response), content_type="audio/mpeg") # type: ignore
return response
response, declared_mime_type = invoke_tts(
text_content=text, app_model=app_model, voice=voice, is_draft=is_draft
)
return _create_tts_response(response, declared_mime_type)
@classmethod
def transcript_tts_voices(cls, tenant_id: str, language: str):
@@ -168,7 +168,9 @@ class TestAudioServiceTranscriptTTSMessageLookup:
voice="en-US-Neural",
)
assert result == b"audio from message"
assert result is not None
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"audio from message"
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Hello from message",
voice="en-US-Neural",
@@ -370,7 +370,7 @@ def test_service_openapi_documents_non_json_response_media_types():
"text/event-stream",
}
assert _response_content_types(paths["/workflow/{task_id}/events"]["get"]) == {"text/event-stream"}
assert _response_content_types(paths["/text-to-audio"]["post"]) == {"audio/mpeg"}
assert _response_content_types(paths["/text-to-audio"]["post"]) == {"application/octet-stream"}
assert _response_content_types(paths["/files/{file_id}/preview"]["get"]) == {
"application/octet-stream",
"application/pdf",
@@ -273,7 +273,7 @@ class TestAdvancedChatGenerateTaskPipeline:
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="responding", audio="data"))
response = pipeline._listen_audio_msg(publisher=publisher, task_id="task")
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
import pytest
@@ -196,7 +195,7 @@ class TestWorkflowGenerateTaskPipeline:
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="responding", audio="data"))
response = pipeline._listen_audio_msg(publisher=publisher, task_id="task")
@@ -477,6 +476,7 @@ class TestWorkflowGenerateTaskPipeline:
def test_wrapper_process_stream_response_emits_audio_end(self, monkeypatch: pytest.MonkeyPatch):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.stream = True
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
@@ -486,17 +486,23 @@ class TestWorkflowGenerateTaskPipeline:
def __init__(self, *args, **kwargs):
self.calls = 0
def check_and_get_audio(self):
def check_and_get_audio(self, *, block=False):
self.calls += 1
if self.calls == 1:
return AudioTrunk(status="stream", audio="data")
assert not block
return AudioTrunk(status="responding", audio="data")
if self.calls == 2:
assert not block
return None
assert block
return AudioTrunk(status="finish", audio="")
def publish(self, message):
return None
def cancel(self):
return None
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
@@ -626,33 +632,30 @@ class TestWorkflowGenerateTaskPipeline:
responses = list(pipeline._wrapper_process_stream_response())
assert responses == [PingStreamResponse(task_id="task")]
def test_wrapper_process_stream_response_final_audio_none_then_finish(self, monkeypatch: pytest.MonkeyPatch):
def test_wrapper_process_stream_response_uses_a_blocking_terminal_read(self, monkeypatch: pytest.MonkeyPatch):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.stream = True
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
pipeline._process_stream_response = lambda **kwargs: iter([])
sleep_spy = []
blocking_reads = []
class _Publisher:
def __init__(self, *args, **kwargs):
self.calls = 0
pass
def check_and_get_audio(self):
self.calls += 1
if self.calls == 1:
return None
def check_and_get_audio(self, *, block=False):
blocking_reads.append(block)
return AudioTrunk(status="finish", audio="")
def publish(self, message):
_ = message
time_values = iter([0.0, 0.0, 0.2])
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.time.time", lambda: next(time_values))
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.time.sleep", lambda _: sleep_spy.append(True)
)
def cancel(self):
return None
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
@@ -660,13 +663,14 @@ class TestWorkflowGenerateTaskPipeline:
responses = list(pipeline._wrapper_process_stream_response())
assert sleep_spy
assert blocking_reads == [True]
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_wrapper_process_stream_response_handles_audio_exception(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
def test_wrapper_process_stream_response_does_not_swallow_audio_queue_exceptions(
self, monkeypatch: pytest.MonkeyPatch
):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.stream = True
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
@@ -674,28 +678,25 @@ class TestWorkflowGenerateTaskPipeline:
class _Publisher:
def __init__(self, *args, **kwargs):
self.called = False
pass
def check_and_get_audio(self):
if not self.called:
self.called = True
raise RuntimeError("tts failure")
return AudioTrunk(status="finish", audio="")
def check_and_get_audio(self, *, block=False):
assert block
raise RuntimeError("tts failure")
def publish(self, message):
_ = message
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.time.time", lambda: 0.0)
def cancel(self):
return None
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
)
with caplog.at_level(logging.ERROR, logger="core.app.apps.workflow.generate_task_pipeline"):
responses = list(pipeline._wrapper_process_stream_response())
assert "Fails to get audio trunk, task_id: task" in caplog.messages
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
with pytest.raises(RuntimeError, match="tts failure"):
list(pipeline._wrapper_process_stream_response())
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_database_session_rolls_back_on_error(
@@ -856,7 +857,7 @@ class TestWorkflowGenerateTaskPipeline:
responses = list(pipeline._process_stream_response(tts_publisher=_Publisher()))
assert responses == ["started", "text", "dispatched", "error"]
assert publisher_calls == [None]
assert publisher_calls == []
def test_process_stream_response_break_paths(self):
pipeline = _make_pipeline()
@@ -361,10 +361,7 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
list(pipeline._process_stream_response(publisher=publisher, trace_manager=None))
# Assert
# Called once with message and once with None at the end
assert publisher.publish.call_count == 2
publisher.publish.assert_any_call(mock_queue_message)
publisher.publish.assert_any_call(None)
publisher.publish.assert_called_once_with(mock_queue_message)
def test_trace_manager_passed_to_save_message(self, pipeline, committed_sessions):
"""Test that trace manager is passed to _save_message."""
@@ -1026,9 +1026,16 @@ class TestEasyUiBasedGenerateTaskPipeline:
)
class _Publisher:
def check_and_get_audio(self):
def check_and_get_audio(self, *, block=False):
assert block
return AudioTrunk("finish", "")
def publish(self, message):
assert message is None
def cancel(self):
return None
inline_audio = MessageAudioStreamResponse(task_id="task", audio="inline")
audio_calls = iter([inline_audio, None])
payload = PingStreamResponse(task_id="task")
@@ -1044,8 +1051,9 @@ class TestEasyUiBasedGenerateTaskPipeline:
assert responses[0] == inline_audio
assert responses[1] == payload
assert isinstance(responses[-1], MessageAudioEndStreamResponse)
assert "audio_type" not in responses[-1].model_dump()
def test_wrapper_process_stream_response_timeout_yields_audio_chunk(self, monkeypatch: pytest.MonkeyPatch):
def test_wrapper_process_stream_response_waits_for_the_audio_terminal(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT)
@@ -1062,30 +1070,99 @@ class TestEasyUiBasedGenerateTaskPipeline:
class _Publisher:
def __init__(self):
self._events = iter([None, AudioTrunk("responding", "later"), AudioTrunk("finish", "")])
self._events = iter([AudioTrunk("responding", "later"), AudioTrunk("finish", "")])
def check_and_get_audio(self):
def check_and_get_audio(self, *, block=False):
assert block
return next(self._events)
clock = {"value": 0.0}
def publish(self, message):
assert message is None
def _fake_time():
clock["value"] += 0.1
return clock["value"]
def cancel(self):
return None
_set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([]))
monkeypatch.setattr(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher",
lambda tenant_id, voice, language: _Publisher(),
)
monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.time.time", _fake_time)
monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.time.sleep", lambda _: None)
responses = list(pipeline._wrapper_process_stream_response())
assert any(isinstance(item, MessageAudioStreamResponse) for item in responses)
assert isinstance(responses[-1], MessageAudioEndStreamResponse)
def test_wrapper_process_stream_response_ends_audio_before_reporting_tts_error(
self, monkeypatch: pytest.MonkeyPatch
):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT)
entity.app_config.app_model_config_dict = {
"text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"}
}
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=entity,
queue_manager=_FakeQueueManager(),
conversation=conversation,
message=message,
stream=True,
)
error = RuntimeError("tts failed")
class _Publisher:
def check_and_get_audio(self, *, block=False):
assert block
return AudioTrunk("error", b"", error=error)
def publish(self, message):
assert message is None
def cancel(self):
return None
_set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([]))
monkeypatch.setattr(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher",
lambda tenant_id, voice, language: _Publisher(),
)
responses = list(pipeline._wrapper_process_stream_response())
assert isinstance(responses[-2], MessageAudioEndStreamResponse)
assert isinstance(responses[-1], ErrorStreamResponse)
assert responses[-1].err is error
def test_wrapper_process_stream_response_ends_audio_before_a_main_error(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT)
entity.app_config.app_model_config_dict = {
"text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"}
}
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=entity,
queue_manager=_FakeQueueManager(),
conversation=conversation,
message=message,
stream=True,
)
publisher = Mock()
publisher.check_and_get_audio.return_value = None
error = ErrorStreamResponse(task_id="task", err=RuntimeError("generation failed"))
_set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([error]))
monkeypatch.setattr(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher",
lambda tenant_id, voice, language: publisher,
)
responses = list(pipeline._wrapper_process_stream_response())
assert isinstance(responses[-2], MessageAudioEndStreamResponse)
assert responses[-1] is error
assert publisher.cancel.called
publisher.publish.assert_not_called()
def test_process_stream_response_handles_stop_event_and_output_replacement(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
@@ -1,20 +1,15 @@
import base64
import queue
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from core.base.tts.app_generator_tts_publisher import (
AppGeneratorTTSPublisher,
AudioTrunk,
_invoice_tts,
_process_future,
)
# =========================
# Fixtures
# =========================
from core.app.entities.queue_entities import QueueNodeSucceededEvent, QueueTextChunkEvent
from core.base.tts.app_generator_tts_publisher import AppGeneratorTTSPublisher, AudioTrunk
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
@pytest.fixture
@@ -22,11 +17,12 @@ def mock_model_instance(mocker: MockerFixture):
model = mocker.MagicMock()
model.invoke_tts.return_value = [b"audio1", b"audio2"]
model.get_tts_voices.return_value = [{"value": "voice1"}, {"value": "voice2"}]
model.get_model_schema.return_value = SimpleNamespace(model_properties={ModelPropertyKey.AUDIO_TYPE: "mp3"})
return model
@pytest.fixture
def mock_model_manager(mocker, mock_model_instance):
def mock_model_manager(mocker: MockerFixture, mock_model_instance: MagicMock):
manager = mocker.MagicMock()
manager.get_default_model_instance.return_value = mock_model_instance
mocker.patch("core.base.tts.app_generator_tts_publisher.ModelManager.for_tenant", return_value=manager)
@@ -35,158 +31,88 @@ def mock_model_manager(mocker, mock_model_instance):
@pytest.fixture(autouse=True)
def patch_threads(mocker: MockerFixture):
"""Prevent real threads from starting during tests"""
"""Run the worker explicitly in tests."""
mocker.patch("threading.Thread.start", return_value=None)
# =========================
# AudioTrunk Tests
# =========================
def _text_event(text: str) -> MagicMock:
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = text
return event
def _run(publisher: AppGeneratorTTSPublisher, *messages: MagicMock) -> None:
for message in messages:
publisher._msg_queue.put(message)
publisher._msg_queue.put(None)
publisher._runtime()
class TestAudioTrunk:
def test_audio_trunk_initialization(self):
trunk = AudioTrunk("responding", b"data")
assert trunk.status == "responding"
assert trunk.audio == b"data"
def test_initialization(self):
error = RuntimeError("failed")
trunk = AudioTrunk("error", b"", error=error)
# =========================
# _invoice_tts Tests
# =========================
class TestInvoiceTTS:
@pytest.mark.parametrize(
"text",
[None, "", " "],
)
def test_invoice_tts_empty_or_none_returns_none(self, text, mock_model_instance):
result = _invoice_tts(text, mock_model_instance, "voice1")
assert result is None
mock_model_instance.invoke_tts.assert_not_called()
def test_invoice_tts_valid_text(self, mock_model_instance):
result = _invoice_tts(" hello ", mock_model_instance, "voice1")
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="hello",
voice="voice1",
)
assert result == [b"audio1", b"audio2"]
# =========================
# _process_future Tests
# =========================
class TestProcessFuture:
def test_process_future_normal_flow(self):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.return_value = [b"abc"]
future_queue.put(future)
future_queue.put(None)
_process_future(future_queue, audio_queue)
first = audio_queue.get()
assert first.status == "responding"
assert first.audio == base64.b64encode(b"abc")
finish = audio_queue.get()
assert finish.status == "finish"
def test_process_future_empty_result(self):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.return_value = None
future_queue.put(future)
future_queue.put(None)
_process_future(future_queue, audio_queue)
finish = audio_queue.get()
assert finish.status == "finish"
def test_process_future_exception(self, mocker: MockerFixture):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.side_effect = Exception("error")
future_queue.put(future)
_process_future(future_queue, audio_queue)
finish = audio_queue.get()
assert finish.status == "finish"
# =========================
# AppGeneratorTTSPublisher Tests
# =========================
assert trunk.status == "error"
assert trunk.audio == b""
assert trunk.error is error
class TestAppGeneratorTTSPublisher:
def test_initialization_valid_voice(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
assert publisher.voice == "voice1"
assert publisher.max_sentence == 2
assert publisher.msg_text == ""
def test_initialization_invalid_voice_fallback(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "invalid_voice")
assert publisher.voice == "voice1"
def test_publish_puts_message_in_queue(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
message = MagicMock()
publisher.publish(message)
assert publisher._msg_queue.get() == message
def test_check_and_get_audio_no_audio(self, mock_model_manager):
def test_cancel_discards_queued_text(self, mock_model_manager, mock_model_instance: MagicMock):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
result = publisher.check_and_get_audio()
assert result is None
publisher.publish(_text_event("First. Second."))
def test_check_and_get_audio_non_finish_event(self, mock_model_manager):
publisher.cancel()
publisher._runtime()
mock_model_instance.invoke_tts.assert_not_called()
assert publisher._audio_queue.empty()
def test_check_and_get_audio_returns_none_without_audio(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
trunk = AudioTrunk("responding", b"abc")
assert publisher.check_and_get_audio() is None
def test_check_and_get_audio_returns_the_resolved_mime_type(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
trunk = AudioTrunk("responding", b"abc", audio_type="audio/wav")
publisher._audio_queue.put(trunk)
result = publisher.check_and_get_audio()
assert result.status == "responding"
assert publisher._last_audio_event == trunk
assert result is trunk
assert result.audio_type == "audio/wav"
def test_check_and_get_audio_finish_event(self, mock_model_manager):
@pytest.mark.parametrize("status", ["finish", "error"])
def test_check_and_get_audio_caches_terminal_events(self, mock_model_manager, status: str):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
finish_trunk = AudioTrunk("finish", b"")
publisher._audio_queue.put(finish_trunk)
terminal = AudioTrunk(status, b"", error=RuntimeError("failed") if status == "error" else None)
publisher._audio_queue.put(terminal)
result = publisher.check_and_get_audio()
assert result.status == "finish"
publisher.executor.shutdown.assert_called_once()
def test_check_and_get_audio_cached_finish(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher._last_audio_event = AudioTrunk("finish", b"")
result = publisher.check_and_get_audio()
assert result.status == "finish"
publisher.executor.shutdown.assert_called_once()
assert publisher.check_and_get_audio(block=True) is terminal
assert publisher.check_and_get_audio() is terminal
@pytest.mark.parametrize(
("text", "expected_sentences", "expected_remaining"),
@@ -199,188 +125,160 @@ class TestAppGeneratorTTSPublisher:
)
def test_extract_sentence(self, mock_model_manager, text, expected_sentences, expected_remaining):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
sentences, remaining = publisher._extract_sentence(text)
assert sentences == expected_sentences
assert remaining == expected_remaining
def test_runtime_handles_none_message_with_buffer(self, mock_model_manager):
def test_runtime_generates_the_final_buffer(self, mock_model_manager, mock_model_instance: MagicMock):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher.msg_text = "Hello."
publisher.msg_text = " Hello. "
publisher._msg_queue.put(None)
publisher._runtime()
_run(publisher)
publisher.executor.submit.assert_called_once()
mock_model_instance.invoke_tts.assert_called_once_with(content_text="Hello.", voice="voice1")
assert publisher._audio_queue.get().audio == base64.b64encode(b"audio1")
assert publisher._audio_queue.get().audio == base64.b64encode(b"audio2")
assert publisher._audio_queue.get().status == "finish"
def test_runtime_handles_none_message_without_buffer(self, mock_model_manager):
def test_runtime_skips_an_empty_final_buffer(self, mock_model_manager, mock_model_instance: MagicMock):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher.msg_text = " "
publisher._msg_queue.put(None)
publisher._runtime()
_run(publisher)
publisher.executor.submit.assert_not_called()
mock_model_instance.invoke_tts.assert_not_called()
assert publisher._audio_queue.get().status == "finish"
def test_runtime_sentence_threshold_triggers_submit(self, mock_model_manager, mocker: MockerFixture):
def test_runtime_generates_incremental_mp3_after_the_sentence_threshold(
self, mock_model_manager, mock_model_instance: MagicMock
):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
# Force sentence extraction to hit threshold condition
mocker.patch.object(
publisher,
"_extract_sentence",
return_value=(["Hello world.", " Second sentence."], ""),
_run(publisher, _text_event("Hello world. Second sentence."))
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Hello world. Second sentence.", voice="voice1"
)
from core.app.entities.queue_entities import QueueTextChunkEvent
def test_runtime_waits_for_terminal_when_the_schema_has_no_audio_type(
self, mock_model_manager, mock_model_instance: MagicMock
):
mock_model_instance.get_model_schema.return_value = SimpleNamespace(model_properties={})
wav = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data"
mock_model_instance.invoke_tts.return_value = [TTSAudioChunk(wav, "audio/wav")]
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
event = _text_event("Hello. World.")
messages = iter([event, None])
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world. Second sentence."
def get_message():
message = next(messages)
if message is None:
mock_model_instance.invoke_tts.assert_not_called()
return message
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._msg_queue = MagicMock()
publisher._msg_queue.get.side_effect = get_message
publisher._runtime()
assert publisher.executor.submit.called
mock_model_instance.invoke_tts.assert_called_once_with(content_text="Hello. World.", voice="voice1")
assert publisher._audio_queue.get().audio_type == "audio/wav"
assert publisher._audio_queue.get().status == "finish"
def test_runtime_handles_text_chunk_event(self, mock_model_manager):
def test_runtime_waits_for_terminal_when_the_model_declares_wav(
self, mock_model_manager, mock_model_instance: MagicMock
):
mock_model_instance.get_model_schema.return_value = SimpleNamespace(
model_properties={ModelPropertyKey.AUDIO_TYPE: "wav"}
)
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueTextChunkEvent
_run(publisher, _text_event("Hello. World."))
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world."
mock_model_instance.invoke_tts.assert_called_once_with(content_text="Hello. World.", voice="voice1")
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.executor.submit.called
def test_runtime_handles_node_succeeded_event_with_output(self, mock_model_manager):
def test_runtime_rejects_a_non_mp3_incremental_response_before_emitting_audio(
self, mock_model_manager, mock_model_instance: MagicMock
):
wav = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data"
mock_model_instance.invoke_tts.return_value = [TTSAudioChunk(wav, "audio/wav")]
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueNodeSucceededEvent
_run(publisher, _text_event("First. Second. tail"))
terminal = publisher._audio_queue.get()
assert terminal.status == "error"
assert isinstance(terminal.error, InvokeBadRequestError)
assert publisher._audio_queue.empty()
def test_runtime_rejects_mime_changes_between_audio_responses(
self, mock_model_manager, mock_model_instance: MagicMock
):
mp3 = b"\xff\xfb" + b"\x00" * 30
wav = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data"
mock_model_instance.invoke_tts.side_effect = [
[TTSAudioChunk(mp3, "audio/mpeg")],
[TTSAudioChunk(wav, "audio/wav")],
]
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
_run(publisher, _text_event("First. Second. tail"))
responding = publisher._audio_queue.get()
terminal = publisher._audio_queue.get()
assert responding.status == "responding"
assert terminal.status == "error"
assert isinstance(terminal.error, InvokeBadRequestError)
assert "between audio responses" in str(terminal.error)
assert publisher._audio_queue.empty()
def test_runtime_turns_a_lazy_provider_failure_into_an_error_terminal(
self, mock_model_manager, mock_model_instance: MagicMock
):
def failing_stream():
raise RuntimeError("provider failed")
yield b"" # pragma: no cover
mock_model_instance.invoke_tts.return_value = failing_stream()
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.msg_text = "Hello"
_run(publisher)
terminal = publisher._audio_queue.get()
assert terminal.status == "error"
assert isinstance(terminal.error, RuntimeError)
assert publisher._audio_queue.empty()
def test_runtime_handles_node_succeeded_output(self, mock_model_manager, mock_model_instance: MagicMock):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
event = MagicMock()
event.event = MagicMock(spec=QueueNodeSucceededEvent)
event.event.outputs = {"output": "Hello world."}
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
_run(publisher, event)
publisher._runtime()
mock_model_instance.invoke_tts.assert_called_once()
assert publisher.executor.submit.called
def test_runtime_handles_node_succeeded_event_without_output(self, mock_model_manager):
def test_runtime_ignores_node_succeeded_without_output(self, mock_model_manager, mock_model_instance: MagicMock):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueNodeSucceededEvent
event = MagicMock()
event.event = MagicMock(spec=QueueNodeSucceededEvent)
event.event.outputs = None
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
_run(publisher, event)
publisher._runtime()
mock_model_instance.invoke_tts.assert_not_called()
publisher.executor.submit.assert_not_called()
def test_runtime_handles_agent_message_event_list_content(self, mock_model_manager, mocker: MockerFixture):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueAgentMessageEvent
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta
from graphon.model_runtime.entities.message_entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
TextPromptMessageContent,
)
chunk = LLMResultChunk(
model="model",
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(
content=[
TextPromptMessageContent(data="Hello "),
ImagePromptMessageContent(format="png", mime_type="image/png", base64_data="a"),
]
),
),
)
event = MagicMock(event=QueueAgentMessageEvent(chunk=chunk))
mocker.patch.object(publisher, "_extract_sentence", return_value=([], ""))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == "Hello "
def test_runtime_handles_agent_message_event_empty_content(self, mock_model_manager, mocker: MockerFixture):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueAgentMessageEvent
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
chunk = LLMResultChunk(
model="model",
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(content=""),
),
)
event = MagicMock(event=QueueAgentMessageEvent(chunk=chunk))
mocker.patch.object(publisher, "_extract_sentence", return_value=([], ""))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == ""
def test_runtime_resets_msg_text_when_text_tmp_not_str(self, mock_model_manager, mocker: MockerFixture):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueTextChunkEvent
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world. Another sentence."
mocker.patch.object(publisher, "_extract_sentence", return_value=(["A.", "B."], None))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == ""
def test_runtime_exception_path(self, mock_model_manager):
def test_runtime_turns_message_processing_failure_into_an_error_terminal(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher._msg_queue = MagicMock()
publisher._msg_queue.get.side_effect = Exception("error")
publisher._msg_queue.get.side_effect = RuntimeError("failed")
publisher._runtime()
terminal = publisher._audio_queue.get()
assert terminal.status == "error"
assert isinstance(terminal.error, RuntimeError)
@@ -0,0 +1,66 @@
from types import SimpleNamespace
from typing import cast
import pytest
from core.base.tts.audio_mime import (
get_model_audio_mime_type,
inspect_audio_stream,
resolve_audio_mime_type,
sniff_audio_mime_type,
)
from core.model_manager import ModelInstance
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
def test_inspect_audio_stream_preserves_the_prefix_with_matching_chunk_mime_type() -> None:
chunks = [b"RIFF\x24\x00\x00\x00", b"WAVEfmt ", b"audio-data"]
stream, mime_type = inspect_audio_stream(
iter([TTSAudioChunk(chunk, "audio/wav") for chunk in chunks]), "audio/mpeg"
)
assert mime_type == "audio/wav"
assert list(stream) == chunks
def test_inspect_audio_stream_rejects_mime_magic_mismatch() -> None:
with pytest.raises(InvokeBadRequestError, match="declared audio/mpeg, detected audio/wav"):
inspect_audio_stream([TTSAudioChunk(b"RIFF\x24\x00\x00\x00WAVEfmt ", "audio/mpeg")])
def test_inspect_audio_stream_rejects_unsupported_reported_mime_type() -> None:
with pytest.raises(InvokeBadRequestError, match="unsupported chunk MIME type"):
inspect_audio_stream([TTSAudioChunk(b"audio", "application/octet-stream")])
def test_resolve_audio_mime_type_falls_back_to_the_declared_model_type() -> None:
assert resolve_audio_mime_type(b"unrecognised", "audio/ogg") == "audio/ogg"
def test_model_audio_mime_type_normalizes_plugin_metadata() -> None:
model_instance = SimpleNamespace(
get_model_schema=lambda: SimpleNamespace(model_properties={ModelPropertyKey.AUDIO_TYPE: "audio/x-wav"})
)
assert get_model_audio_mime_type(cast(ModelInstance, model_instance)) == "audio/wav"
@pytest.mark.parametrize(
("signature", "mime_type"),
[
(b"\xff\xfb" + b"\x00" * 30, "audio/mpeg"),
(b"\xff\xf1" + b"\x00" * 30, "audio/aac"),
],
)
def test_sniff_audio_mime_type_distinguishes_mp3_frames_from_adts(signature: bytes, mime_type: str) -> None:
assert sniff_audio_mime_type(signature) == mime_type
def test_id3_metadata_is_not_treated_as_proof_of_an_mp3_codec() -> None:
signature = b"ID3" + b"\x00" * 29
assert sniff_audio_mime_type(signature) is None
assert resolve_audio_mime_type(signature, reported_mime_type="audio/aac") == "audio/aac"
@@ -511,7 +511,12 @@ class TestPluginModelClient:
mocker.patch.object(
client,
"_request_with_plugin_daemon_response_stream",
return_value=iter([SimpleNamespace(result="68656c6c6f"), SimpleNamespace(result="21")]),
return_value=iter(
[
SimpleNamespace(result="68656c6c6f", mime_type="audio/wav"),
SimpleNamespace(result="21", mime_type="audio/wav"),
]
),
)
result = list(
@@ -528,6 +533,7 @@ class TestPluginModelClient:
)
assert result == [b"hello", b"!"]
assert [chunk.mime_type for chunk in result] == ["audio/wav", "audio/wav"]
def test_invoke_tts_wraps_plugin_daemon_inner_error(self, mocker: MockerFixture):
client = PluginModelClient()
@@ -1,8 +1,14 @@
from unittest.mock import patch
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from core.plugin.backwards_invocation.model import PluginModelBackwardsInvocation
from core.plugin.entities.request import RequestInvokeSummary
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from core.plugin.entities.request import RequestInvokeSummary, RequestInvokeTTS
from graphon.model_runtime.entities.message_entities import UserPromptMessage
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
from models.account import Tenant
@@ -60,3 +66,73 @@ def test_invoke_summary_uses_same_user_scope_for_token_helpers():
prompt_messages=[UserPromptMessage(content="short")],
user_id="user-1",
)
def test_invoke_tts_emits_the_verified_mime_type_for_backwards_invocation():
tenant = Tenant(name="Test Workspace")
tenant.id = "tenant-1"
model_instance = MagicMock()
model_instance.invoke_tts.return_value = [b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data"]
model_instance.get_model_schema.return_value = SimpleNamespace(
model_properties={ModelPropertyKey.AUDIO_TYPE: "wav"}
)
payload = RequestInvokeTTS(
provider="provider-a",
model="qwen3-tts-flash",
content_text="hello",
voice="Cherry",
)
with patch.object(PluginModelBackwardsInvocation, "_get_bound_model_instance", return_value=model_instance):
result = list(PluginModelBackwardsInvocation.invoke_tts("user-1", tenant, payload))
assert result == [
{
"result": "524946462400000057415645666d742010000000617564696f2d64617461",
"mime_type": "audio/wav",
}
]
def test_invoke_tts_defers_provider_errors_until_the_response_generator_is_consumed():
tenant = Tenant(name="Test Workspace")
tenant.id = "tenant-1"
model_instance = MagicMock()
model_instance.invoke_tts.side_effect = RuntimeError("provider failed")
payload = RequestInvokeTTS(
provider="provider-a",
model="qwen3-tts-flash",
content_text="hello",
voice="Cherry",
)
with patch.object(PluginModelBackwardsInvocation, "_get_bound_model_instance", return_value=model_instance):
response = PluginModelBackwardsInvocation.invoke_tts("user-1", tenant, payload)
model_instance.invoke_tts.assert_not_called()
with pytest.raises(RuntimeError, match="provider failed"):
next(response)
def test_backwards_event_stream_serializes_a_deferred_mime_error():
tenant = Tenant(name="Test Workspace")
tenant.id = "tenant-1"
model_instance = MagicMock()
model_instance.invoke_tts.return_value = [
TTSAudioChunk(b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data", "audio/mpeg")
]
model_instance.get_model_schema.return_value = SimpleNamespace(
model_properties={ModelPropertyKey.AUDIO_TYPE: "mp3"}
)
payload = RequestInvokeTTS(
provider="provider-a",
model="qwen3-tts-flash",
content_text="hello",
voice="Cherry",
)
with patch.object(PluginModelBackwardsInvocation, "_get_bound_model_instance", return_value=model_instance):
response = PluginModelBackwardsInvocation.invoke_tts("user-1", tenant, payload)
chunks = list(PluginModelBackwardsInvocation.convert_to_event_stream(response))
assert json.loads(chunks[0])["error"].startswith("TTS provider output MIME does not match")
@@ -287,6 +287,7 @@ def test_tts_invoke_returns_messages(monkeypatch: pytest.MonkeyPatch, sqlite_ses
)
messages = list(tts.invoke(session=sqlite_session, user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
assert [m.type for m in messages] == [ToolInvokeMessage.MessageType.TEXT, ToolInvokeMessage.MessageType.BLOB]
assert messages[1].meta == {"mime_type": "audio/mpeg"}
assert captured_manager_kwargs == {"tenant_id": "tenant-1", "user_id": "u"}
@@ -60,9 +60,12 @@ from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.datastructures import FileStorage
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, MessageStatus
from models.model import App, AppMode, AppModelConfig, Message
@@ -581,7 +584,8 @@ class TestAudioServiceTTS:
)
# Assert
assert result == b"audio data"
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"audio data"
mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="user-123")
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Hello world",
@@ -619,7 +623,8 @@ class TestAudioServiceTTS:
)
# Assert
assert result == b"audio data"
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"audio data"
# Verify default voice was used
call_args = mock_model_instance.invoke_tts.call_args
assert call_args.kwargs["voice"] == "default-voice"
@@ -656,7 +661,8 @@ class TestAudioServiceTTS:
)
# Assert
assert result == b"audio data"
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"audio data"
call_args = mock_model_instance.invoke_tts.call_args
assert call_args.kwargs["voice"] == "auto-voice"
@@ -696,7 +702,8 @@ class TestAudioServiceTTS:
)
# Assert
assert result == b"draft audio"
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"draft audio"
mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app, session=sqlite_session)
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
@@ -762,12 +769,69 @@ class TestAudioServiceTTS:
)
# Assert
assert result == b"message audio"
assert result.content_type == "audio/mpeg"
assert result.get_data() == b"message audio"
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Message answer",
voice="message-voice",
)
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
def test_transcript_tts_uses_detected_wav_mime_type_for_streams(
self,
mock_model_manager_class,
factory: AudioServiceTestDataFactory,
sqlite_session: Session,
app: Flask,
):
app_model_config = factory.create_app_model_config_mock(
text_to_speech_dict={"enabled": True, "voice": "en-US-Neural"}
)
app_model = factory.create_app_mock(mode=AppMode.CHAT, app_model_config=app_model_config)
mock_model_instance = MagicMock()
mock_model_instance.invoke_tts.return_value = iter(
[b"RIFF\x24\x00\x00\x00WAVEfmt ", b"\x10\x00\x00\x00audio-data"]
)
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
with app.test_request_context("/text-to-audio", method="POST"):
result = AudioService.transcript_tts(
app_model=app_model,
session=sqlite_session,
text="Hello world",
voice="en-US-Neural",
)
assert result.content_type == "audio/wav"
assert result.get_data() == b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data"
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
def test_transcript_tts_returns_provider_output_error_for_mime_magic_mismatch(
self,
mock_model_manager_class,
factory: AudioServiceTestDataFactory,
sqlite_session: Session,
app: Flask,
):
app_model_config = factory.create_app_model_config_mock(
text_to_speech_dict={"enabled": True, "voice": "en-US-Neural"}
)
app_model = factory.create_app_mock(mode=AppMode.CHAT, app_model_config=app_model_config)
mock_model_instance = MagicMock()
mock_model_instance.invoke_tts.return_value = iter(
[TTSAudioChunk(b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00audio-data", "audio/mpeg")]
)
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
with app.test_request_context("/text-to-audio", method="POST"):
with pytest.raises(InvokeBadRequestError, match="output MIME does not match"):
AudioService.transcript_tts(
app_model=app_model,
session=sqlite_session,
text="Hello world",
voice="en-US-Neural",
)
def test_transcript_tts_raises_error_when_text_missing(
self,
factory: AudioServiceTestDataFactory,
@@ -3280,7 +3280,7 @@ export const zGetSiteResponse = zSite
export const zPostTextToAudioBody = zTextToAudioPayloadWithUser
/**
* Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly.
* Returns the generated audio. The `Content-Type` header reflects the provider audio container, verified from the response bytes when recognizable. The binary response can be AAC, FLAC, MP4, MP3, Ogg, WAV, or WebM.
*/
export const zPostTextToAudioResponse = z.custom<Blob | File>(
(value) => value instanceof Blob || value instanceof File,
@@ -37,6 +37,9 @@ type Reader = {
type AudioResponse = {
status: number
headers: {
get: (name: string) => string | null
}
body: {
getReader: () => Reader
}
@@ -152,7 +155,7 @@ const testState = {
}
class MockMediaSourceCtor extends MockMediaSource {
static isTypeSupported = vi.fn(() => true)
static isTypeSupported = vi.fn((_mimeType: string) => true)
constructor() {
super()
@@ -194,7 +197,11 @@ const setMediaSourceSupport = (options: { mediaSource: boolean; managedMediaSour
})
}
const makeAudioResponse = (status: number, reads: ReaderResult[]): AudioResponse => {
const makeAudioResponse = (
status: number,
reads: ReaderResult[],
contentType = 'audio/mpeg',
): AudioResponse => {
const read = vi.fn<() => Promise<ReaderResult>>()
reads.forEach((result) => {
read.mockResolvedValueOnce(result)
@@ -202,6 +209,9 @@ const makeAudioResponse = (status: number, reads: ReaderResult[]): AudioResponse
return {
status,
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? contentType : null),
},
body: {
getReader: () => ({ read }),
},
@@ -274,14 +284,15 @@ describe('AudioPlayer', () => {
})
describe('constructor behavior', () => {
it('should initialize media source, audio, and media element source when MediaSource exists', () => {
it('should initialize default MP3 streaming before the first play request', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
expect(player.mediaSource).toBe(mediaSource as unknown as MediaSource)
expect(player.mediaSource).toBe(testState.mediaSources[0])
expect(testState.mediaSources).toHaveLength(1)
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(audio!.src).toBe('blob:mock-url')
expect(audio!.autoplay).toBe(true)
@@ -289,36 +300,9 @@ describe('AudioPlayer', () => {
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
})
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const audio = testState.audios[0]
expect(player.mediaSource).toBeNull()
expect(audio!.src).toBe('')
expect(audio!.autoplay).toBe(false)
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
})
it('should use complete-audio fallback when MP3 MediaSource is unsupported', () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const audio = testState.audios[0]
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
expect(player.mediaSource).toBeNull()
expect(testState.mediaSources).toHaveLength(0)
expect(audio!.src).toBe('')
expect(audio!.autoplay).toBe(false)
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
})
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: true })
// Create with callback to ensure constructor path completes with fallback source.
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
const audio = testState.audios[0]
@@ -363,8 +347,9 @@ describe('AudioPlayer', () => {
expect(callback).toHaveBeenCalledWith('canplay')
})
it('should initialize source buffer only once when sourceopen fires multiple times', () => {
it('should initialize source buffer only once when sourceopen fires multiple times', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
@@ -391,20 +376,78 @@ describe('AudioPlayer', () => {
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
})
expect(mockTextToAudioStream).toHaveBeenCalledWith(
'/text-to-audio',
AppSourceType.webApp,
{ content_type: 'audio/mpeg' },
{
message_id: 'msg-1',
streaming: true,
voice: 'en-US',
text: 'hello',
},
)
expect(mockTextToAudioStream).toHaveBeenCalledWith('/text-to-audio', AppSourceType.webApp, {
message_id: 'msg-1',
streaming: true,
voice: 'en-US',
text: 'hello',
})
expect(player.isLoadData).toBe(true)
})
it('should replace pending MP3 playback with WAV without reporting its abort', async () => {
let rejectInitialPlay: ((reason: DOMException) => void) | undefined
let resolveWavPlay: (() => void) | undefined
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(
200,
[
{ value: new Uint8Array([1, 2]), done: false },
{ value: new Uint8Array([3, 4]), done: true },
],
'audio/wav; codecs=1',
),
)
vi.mocked(globalThis.URL.createObjectURL)
.mockReturnValueOnce('blob:media-source')
.mockReturnValueOnce('blob:wav')
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
audio!.play
.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectInitialPlay = reject
}),
)
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveWavPlay = resolve
}),
)
player.playAudio()
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
await waitFor(() => expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2))
expect(player.mediaSource).toBeNull()
expect(testState.mediaSources).toHaveLength(1)
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:media-source')
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
expect(audioBlob).toMatchObject({ type: 'audio/wav', size: 4 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
expect(audio!.play).toHaveBeenCalledTimes(2)
rejectInitialPlay?.(new DOMException('Playback aborted', 'AbortError'))
await Promise.resolve()
await Promise.resolve()
expect(callback).not.toHaveBeenCalledWith('error')
player.playAudio()
expect(audio!.play).toHaveBeenCalledTimes(2)
resolveWavPlay?.()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
audio!.emit('ended')
expect(callback.mock.calls.map(([event]) => event)).toEqual(['play', 'ended'])
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
})
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const callback = vi.fn()
@@ -484,6 +527,7 @@ describe('AudioPlayer', () => {
undefined,
callback,
)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
@@ -502,6 +546,7 @@ describe('AudioPlayer', () => {
it('should request media playback before a suspended audio context finishes resuming', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
let resolveResume: (() => void) | undefined
@@ -540,6 +585,7 @@ describe('AudioPlayer', () => {
undefined,
callback,
)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
@@ -559,6 +605,7 @@ describe('AudioPlayer', () => {
it('should report an error when the audio context remains interrupted and allow retry', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
@@ -586,6 +633,7 @@ describe('AudioPlayer', () => {
it('should play ended audio when data is already loaded', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
@@ -600,9 +648,10 @@ describe('AudioPlayer', () => {
})
})
it('should report loaded audio that is already playing without replaying it', () => {
it('should report loaded audio that is already playing without replaying it', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
@@ -629,6 +678,75 @@ describe('AudioPlayer', () => {
expect(player.isLoadData).toBe(false)
})
it('should discard partial HTTP audio before a successful retry', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValueOnce(true).mockReturnValue(false)
const callback = vi.fn()
const failedReader = {
read: vi
.fn()
.mockResolvedValueOnce({ value: new Uint8Array([1, 2]), done: false })
.mockRejectedValueOnce(new Error('stream lost')),
}
mockTextToAudioStream
.mockResolvedValueOnce({
status: 200,
headers: { get: () => 'audio/mpeg' },
body: { getReader: () => failedReader },
} as AudioResponse)
.mockResolvedValueOnce(
makeAudioResponse(200, [{ value: new Uint8Array([3, 4]), done: true }]),
)
const player = new AudioPlayer('/text-to-audio', false, 'msg-2', 'world', undefined, callback)
const audio = testState.audios[0]
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
expect(player.cacheBuffers).toHaveLength(0)
expect(player.mediaSource).toBeNull()
expect(audio!.src).toBe('')
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
audio!.paused = true
player.playAudio()
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(2))
expect(mockTextToAudioStream).toHaveBeenCalledTimes(2)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 2 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([3, 4]))
})
it('should ignore a pending HTTP response after destroy', async () => {
let resolveResponse: ((response: AudioResponse) => void) | undefined
const getReader = vi.fn()
const callback = vi.fn()
mockTextToAudioStream.mockImplementationOnce(
() =>
new Promise<AudioResponse>((resolve) => {
resolveResponse = resolve
}),
)
const player = new AudioPlayer('/text-to-audio', false, 'msg-2', 'world', undefined, callback)
player.playAudio()
player.destroy()
callback.mockClear()
resolveResponse?.({
status: 200,
headers: { get: () => 'audio/wav' },
body: { getReader },
} as unknown as AudioResponse)
await Promise.resolve()
await Promise.resolve()
expect(getReader).not.toHaveBeenCalled()
expect(testState.mediaSources).toHaveLength(1)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalled()
})
it('should call pause flow and notify paused event when pauseAudio is invoked', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
@@ -655,15 +773,18 @@ describe('AudioPlayer', () => {
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
await player.playAudioWithAudio('', true)
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(player.cacheBuffers).toHaveLength(1)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.emit('sourceopen')
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
@@ -674,13 +795,13 @@ describe('AudioPlayer', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
const audioBase64 = Buffer.from('hello').toString('base64')
mediaSource!.emit('sourceopen')
audio!.paused = true
audioContext!.state = 'suspended'
await player.playAudioWithAudio(audioBase64, true)
await player.playAudioWithAudio(audioBase64, true, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
expect(player.isLoadData).toBe(true)
expect(player.cacheBuffers).toHaveLength(0)
@@ -701,7 +822,7 @@ describe('AudioPlayer', () => {
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), false)
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), false, 'audio/mpeg')
expect(player.isLoadData).toBe(false)
expect(audioContext!.resume).not.toHaveBeenCalled()
@@ -733,6 +854,44 @@ describe('AudioPlayer', () => {
expect(callback).toHaveBeenCalledWith('play')
})
it('should combine automatic WAV TTS chunks into one playable blob', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
const audio = testState.audios[0]
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true, 'audio/wav')
await player.playAudioWithAudio(Buffer.from([3, 4]).toString('base64'), true, 'audio/wav')
expect(audio!.play).not.toHaveBeenCalled()
await player.playAudioWithAudio('', false)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
expect(player.mediaSource).toBeNull()
expect(testState.mediaSources).toHaveLength(1)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
expect(audioBlob).toMatchObject({ type: 'audio/wav', size: 4 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
})
it('should resume the audio context when WAV playback is requested while buffering', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true, 'audio/wav')
audioContext!.state = 'suspended'
player.playAudio()
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).not.toHaveBeenCalled()
audio!.play.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError'))
await player.playAudioWithAudio('', false)
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
})
it('should not start fallback playback after it is paused while buffering', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
@@ -750,6 +909,7 @@ describe('AudioPlayer', () => {
it('should fall back to a complete MP3 when addSourceBuffer throws', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
@@ -757,7 +917,6 @@ describe('AudioPlayer', () => {
})
mediaSource!.emit('sourceopen')
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
await player.playAudioWithAudio('', false)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
@@ -769,13 +928,12 @@ describe('AudioPlayer', () => {
it('should complete buffered fallback when addSourceBuffer throws after stream end', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
throw new DOMException('Unsupported type', 'NotSupportedError')
})
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
await player.playAudioWithAudio('', false)
audio!.paused = true
@@ -798,7 +956,7 @@ describe('AudioPlayer', () => {
audio!.paused = false
audio!.ended = true
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true, 'audio/mpeg')
expect(audio!.play).toHaveBeenCalledTimes(1)
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
@@ -812,7 +970,7 @@ describe('AudioPlayer', () => {
audio!.paused = false
audio!.ended = false
audio!.played = {}
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true, 'audio/mpeg')
expect(audio!.play).not.toHaveBeenCalled()
expect(callback).not.toHaveBeenCalledWith('play')
@@ -821,6 +979,7 @@ describe('AudioPlayer', () => {
it('should report a play failure and retry without requesting audio again', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const mediaSource = testState.mediaSources[0]
mockTextToAudioStream.mockResolvedValue(
@@ -849,6 +1008,7 @@ describe('AudioPlayer', () => {
it('should report a resume failure and allow playback to be retried', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
@@ -878,50 +1038,16 @@ describe('AudioPlayer', () => {
})
})
describe('buffering internals', () => {
it('should finish stream when receiveAudioData gets an undefined chunk', () => {
describe('MSE buffering', () => {
it('should preserve a chunk received before sourceopen', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const finishStream = vi
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
.mockImplementation(() => {})
;(
player as unknown as { receiveAudioData: (data: Uint8Array | undefined) => void }
).receiveAudioData(undefined)
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should finish stream when receiveAudioData gets empty bytes', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const finishStream = vi
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
.mockImplementation(() => {})
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array(0),
await player.playAudioWithAudio(
Buffer.from([1, 2, 3]).toString('base64'),
false,
'audio/mpeg',
)
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should queue incoming buffer when source buffer is updating', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.updating = true
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([1, 2, 3]),
)
expect(player.cacheBuffers.length).toBe(1)
})
it('should preserve audio received before sourceopen and append it once ready', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([1, 2, 3]),
)
expect(player.cacheBuffers).toHaveLength(1)
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
@@ -929,114 +1055,68 @@ describe('AudioPlayer', () => {
mediaSource!.emit('sourceopen')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
new Uint8Array([1, 2, 3]),
)
expect(player.cacheBuffers).toHaveLength(0)
})
it('should append queued buffers in order after updateend', () => {
it('should append queued chunks in order after updateend', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.updating = true
const first = new Uint8Array([1])
const second = new Uint8Array([2])
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
first,
)
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
second,
)
await player.playAudioWithAudio(Buffer.from([2]).toString('base64'), false, 'audio/mpeg')
await player.playAudioWithAudio(Buffer.from([3]).toString('base64'), false, 'audio/mpeg')
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
first,
)
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2)
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[1]![0])).toEqual(
second,
)
expect(
mediaSource!.sourceBuffer.appendBuffer.mock.calls.map(
([buffer]) => new Uint8Array(buffer)[0],
),
).toEqual([1, 2, 3])
})
it('should append previously queued buffer before new one when source buffer is idle', () => {
it('should end MediaSource only after every queued chunk is appended', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
await player.playAudioWithAudio(Buffer.from([2]).toString('base64'), false, 'audio/mpeg')
await player.playAudioWithAudio('', false)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
const existingBuffer = new ArrayBuffer(2)
player.cacheBuffers = [existingBuffer]
mediaSource!.sourceBuffer.updating = false
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([9]),
)
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledWith(existingBuffer)
expect(player.cacheBuffers.length).toBe(1)
})
it('should end the stream only after the final queued buffer is appended', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.updating = true
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
})
it('should end an open stream at most once', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
;(player as unknown as { finishStream: () => void }).finishStream()
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
})
it.each(['closed', 'ended'] as const)('should not end a %s media source', (readyState) => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.readyState = readyState
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
})
it('should stop buffering and release browser resources after destroy', async () => {
it('should release resources and stop pending buffering after destroy', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
await player.playAudioWithAudio(Buffer.from([1]).toString('base64'), false, 'audio/mpeg')
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
mediaSource!.emit('sourceopen')
player.destroy()
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([1]),
)
;(player as unknown as { finishStream: () => void }).finishStream()
await player.playAudioWithAudio(Buffer.from([2]).toString('base64'), false, 'audio/mpeg')
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.emit('updateend')
await Promise.resolve()
expect(testState.mediaSources).toHaveLength(1)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(mediaSource!.addSourceBuffer).not.toHaveBeenCalled()
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
expect(audio!.pause).toHaveBeenCalledTimes(1)
expect(audioContext!.close).toHaveBeenCalledTimes(1)
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
+97 -52
View File
@@ -1,6 +1,6 @@
import { AppSourceType, textToAudioStream } from '@/service/share'
const AUDIO_CONTENT_TYPE = 'audio/mpeg'
const DEFAULT_AUDIO_CONTENT_TYPE = 'audio/mpeg'
declare global {
// oxlint-disable-next-line typescript/consistent-type-definitions
@@ -25,9 +25,12 @@ export class AudioPlayer {
private streamEnded = false
private endOfStreamCalled = false
private destroyed = false
private playbackPending = false
private pendingPlaybackSource: string | null = null
private playWhenReady = false
private mediaSourceDisabled = false
private sourceOpenListener?: () => void
private audioMimeType = DEFAULT_AUDIO_CONTENT_TYPE
private reader?: ReadableStreamDefaultReader<Uint8Array>
constructor(
streamUrl: string,
isPublic: boolean,
@@ -43,25 +46,10 @@ export class AudioPlayer {
this.isPublic = isPublic
this.voice = voice
this.callback = callback
// Compatible with iphone ios17 ManagedMediaSource
const MediaSourceConstructor = window.ManagedMediaSource || window.MediaSource
const isManagedMediaSource = Boolean(
window.ManagedMediaSource && MediaSourceConstructor === window.ManagedMediaSource,
)
const supportsStreaming = Boolean(MediaSourceConstructor?.isTypeSupported?.(AUDIO_CONTENT_TYPE))
this.mediaSource =
supportsStreaming && MediaSourceConstructor ? new MediaSourceConstructor() : null
this.audio = new Audio()
this.mediaSource = null
this.initializeMediaSource()
this.setCallback(callback)
if (this.mediaSource && isManagedMediaSource) {
// if use ManagedMediaSource
this.audio.disableRemotePlayback = true
this.audio.controls = true
}
this.listenMediaSource(AUDIO_CONTENT_TYPE)
this.objectUrl = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
this.audio.src = this.objectUrl
this.audio.autoplay = Boolean(this.mediaSource)
const source = this.audioContext.createMediaElementSource(this.audio)
source.connect(this.audioContext.destination)
}
@@ -78,15 +66,76 @@ export class AudioPlayer {
this.sourceBuffer?.addEventListener('updateend', this.flushBuffers)
this.flushBuffers()
} catch {
this.mediaSource = null
this.audio.autoplay = false
this.releaseObjectUrl()
this.mediaSourceDisabled = true
this.releaseMediaSource()
if (this.streamEnded) this.finishBlobAudio()
}
}
this.mediaSource?.addEventListener('sourceopen', this.sourceOpenListener)
}
private initializeMediaSource() {
if (this.destroyed || this.mediaSourceDisabled) return
const MediaSourceConstructor = window.ManagedMediaSource || window.MediaSource
const isManagedMediaSource = Boolean(
window.ManagedMediaSource && MediaSourceConstructor === window.ManagedMediaSource,
)
const supportsStreaming =
this.audioMimeType === DEFAULT_AUDIO_CONTENT_TYPE &&
Boolean(MediaSourceConstructor?.isTypeSupported?.(this.audioMimeType))
if (!supportsStreaming || !MediaSourceConstructor) return
this.mediaSource = new MediaSourceConstructor()
if (isManagedMediaSource) {
this.audio.disableRemotePlayback = true
this.audio.controls = true
}
this.listenMediaSource(this.audioMimeType)
this.objectUrl = URL.createObjectURL(this.mediaSource)
this.audio.src = this.objectUrl
this.audio.autoplay = true
}
private setAudioMimeType(audioType: string | null | undefined) {
const mimeType = audioType?.split(';', 1)[0]?.trim() || DEFAULT_AUDIO_CONTENT_TYPE
if (mimeType !== this.audioMimeType) {
this.audioMimeType = mimeType
this.releaseMediaSource()
}
if (!this.streamEnded && !this.mediaSource && !this.objectUrl) this.initializeMediaSource()
if (this.mediaSource && this.playWhenReady) this.requestPlayback()
}
private releaseMediaSource() {
if (this.sourceOpenListener)
this.mediaSource?.removeEventListener('sourceopen', this.sourceOpenListener)
if (this.sourceBuffer) {
this.sourceBuffer.removeEventListener('updateend', this.flushBuffers)
if (this.mediaSource?.readyState === 'open') {
try {
this.sourceBuffer.abort()
} catch {}
}
}
this.sourceBuffer = undefined
this.mediaSource = null
this.endOfStreamCalled = false
this.audio.autoplay = false
this.releaseObjectUrl()
}
private failLoad() {
this.cacheBuffers = []
this.streamEnded = false
this.releaseMediaSource()
this.isLoadData = false
this.callback?.('error')
}
private flushBuffers = () => {
if (
this.destroyed ||
@@ -109,38 +158,40 @@ export class AudioPlayer {
}
private requestPlayback(reportIfPlaying = false) {
if (this.destroyed || this.playbackPending) return
const playbackSource = this.objectUrl
if (this.destroyed || !playbackSource || this.pendingPlaybackSource === playbackSource) return
if (!this.isAudioContextPaused() && !this.audio.paused && !this.audio.ended) {
if (reportIfPlaying) this.callback?.('play')
return
}
this.playbackPending = true
void this.resumeAndPlay()
this.pendingPlaybackSource = playbackSource
void this.resumeAndPlay(playbackSource)
}
private isAudioContextPaused() {
return this.audioContext.state === 'suspended' || this.audioContext.state === 'interrupted'
}
private async resumeAndPlay() {
private async resumeAndPlay(playbackSource: string) {
try {
const pendingOperations: Promise<unknown>[] = []
if (this.isAudioContextPaused()) pendingOperations.push(this.audioContext.resume())
if (this.audio.paused || this.audio.ended) pendingOperations.push(this.audio.play())
await Promise.all(pendingOperations)
if (this.destroyed) return
if (this.destroyed || !this.playWhenReady || playbackSource !== this.objectUrl) return
if (this.isAudioContextPaused()) {
this.callback?.('error')
return
}
if (!this.destroyed) this.callback?.('play')
this.callback?.('play')
} catch {
if (!this.destroyed) this.callback?.('error')
if (!this.destroyed && this.playWhenReady && playbackSource === this.objectUrl)
this.callback?.('error')
} finally {
this.playbackPending = false
if (this.pendingPlaybackSource === playbackSource) this.pendingPlaybackSource = null
}
}
@@ -204,7 +255,6 @@ export class AudioPlayer {
const audioResponse = (await textToAudioStream(
this.url,
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
{ content_type: 'audio/mpeg' },
{
message_id: this.msgId,
streaming: true,
@@ -212,15 +262,18 @@ export class AudioPlayer {
text: this.msgContent,
},
)) as Response
if (this.destroyed) return
if (audioResponse.status !== 200) {
this.isLoadData = false
this.callback?.('error')
this.failLoad()
return
}
this.setAudioMimeType(audioResponse.headers.get('content-type'))
if (!audioResponse.body) throw new Error('Audio response body is missing')
const reader = audioResponse.body.getReader()
this.reader = reader
while (true) {
const { value, done } = await reader.read()
if (this.destroyed) return
if (value?.byteLength) this.receiveAudioData(value)
if (done) {
this.finishStream()
@@ -228,22 +281,23 @@ export class AudioPlayer {
}
}
} catch {
this.isLoadData = false
this.callback?.('error')
if (!this.destroyed) this.failLoad()
} finally {
this.reader = undefined
}
}
// play audio
public playAudio() {
this.playWhenReady = true
if (this.isLoadData) {
if (!this.mediaSource && !this.objectUrl) {
this.playWhenReady = true
if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
return
}
this.requestPlayback(true)
} else {
this.isLoadData = true
this.playWhenReady = true
if (this.mediaSource) this.requestPlayback(true)
else if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
this.loadAudio()
@@ -261,11 +315,12 @@ export class AudioPlayer {
this.finishBlobAudio()
}
public async playAudioWithAudio(audio: string, play = true) {
public async playAudioWithAudio(audio: string, play = true, audioType?: string) {
if (!audio || !audio.length) {
this.finishStream()
return
}
this.setAudioMimeType(audioType)
const audioContent = Uint8Array.from(atob(audio), (char) => char.charCodeAt(0))
this.receiveAudioData(audioContent)
if (play) {
@@ -287,23 +342,13 @@ export class AudioPlayer {
this.destroyed = true
this.cacheBuffers = []
void this.reader?.cancel().catch(() => {})
this.reader = undefined
this.callback?.('paused')
this.audio.pause()
if (this.sourceOpenListener)
this.mediaSource?.removeEventListener('sourceopen', this.sourceOpenListener)
if (this.sourceBuffer) {
this.sourceBuffer.removeEventListener('updateend', this.flushBuffers)
if (this.mediaSource?.readyState === 'open') {
try {
this.sourceBuffer.abort()
} catch {}
}
}
this.releaseMediaSource()
void this.audioContext.close().catch(() => {})
this.releaseObjectUrl()
}
private receiveAudioData(unit8Array: Uint8Array | undefined) {
@@ -327,7 +372,7 @@ export class AudioPlayer {
return
}
const audioBlob = new Blob(this.cacheBuffers, { type: AUDIO_CONTENT_TYPE })
const audioBlob = new Blob(this.cacheBuffers, { type: this.audioMimeType })
this.cacheBuffers = []
this.releaseObjectUrl()
this.objectUrl = URL.createObjectURL(audioBlob)
@@ -70,7 +70,7 @@ type HookCallbacks = {
onHumanInputFormFilled: (filled: Record<string, unknown>) => void
onHumanInputFormTimeout: (timeout: Record<string, unknown>) => void
onWorkflowPaused: (workflowPaused: Record<string, unknown>) => void
onTTSChunk: (messageId: string, audio: string) => void
onTTSChunk: (messageId: string, audio: string, audioType?: string) => void
onTTSEnd: (messageId: string, audio: string) => void
onReasoning: (chunk: {
data: { message_id?: string; reasoning: string; node_id?: string; is_final?: boolean }
+4 -4
View File
@@ -899,11 +899,11 @@ export const useChat = (
responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any
})
},
onTTSChunk: (messageId: string, audio: string) => {
onTTSChunk: (messageId: string, audio: string, audioType?: string) => {
if (!audio || audio === '') return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
audioPlayer.playAudioWithAudio(audio, true, audioType)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
@@ -1636,11 +1636,11 @@ export const useChat = (
parentId: data.parent_message_id,
})
},
onTTSChunk: (messageId: string, audio: string) => {
onTTSChunk: (messageId: string, audio: string, audioType?: string) => {
if (!audio || audio === '') return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
audioPlayer.playAudioWithAudio(audio, true, audioType)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
@@ -136,9 +136,9 @@ describe('useWorkflowRun callbacks helpers', () => {
workflowData,
)
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
callbacks.onTTSChunk?.('message-1', 'audio-chunk', 'audio/wav')
expect(getOrCreatePlayer).toHaveBeenCalled()
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true, 'audio/wav')
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
@@ -210,7 +210,7 @@ describe('useWorkflowRun callbacks helpers', () => {
expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled()
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true, undefined)
expect(mockResetMsgId).toHaveBeenCalledWith('message-2')
finalCallbacks.onTTSChunk?.('message-3', '')
@@ -322,7 +322,7 @@ describe('useWorkflowRun callbacks helpers', () => {
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true, undefined)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
@@ -501,7 +501,7 @@ describe('useWorkflowRun callbacks helpers', () => {
finalCallbacks,
)
expect(mockSseGet).toHaveBeenCalledTimes(1)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true, undefined)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
@@ -245,11 +245,11 @@ export const createBaseWorkflowRunCallbacks = ({
onReasoning: (params) => {
handleWorkflowReasoning(params)
},
onTTSChunk: (messageId: string, audio: string) => {
onTTSChunk: (messageId: string, audio: string, audioType?: string) => {
if (!audio || audio === '') return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
audioPlayer.playAudioWithAudio(audio, true, audioType)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
@@ -430,9 +430,9 @@ export const createFinalWorkflowRunCallbacks = ({
onReasoning: (params) => {
handleWorkflowReasoning(params)
},
onTTSChunk: (messageId: string, audio: string) => {
onTTSChunk: (messageId: string, audio: string, audioType?: string) => {
if (!audio || audio === '') return
player?.playAudioWithAudio(audio, true)
player?.playAudioWithAudio(audio, true, audioType)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (_messageId: string, audio: string) => {
+38
View File
@@ -328,6 +328,44 @@ describe('handleStream', () => {
expect(onData).not.toHaveBeenCalled()
})
it('should pass the TTS MIME type through the stream boundary', async () => {
const onData = vi.fn()
const onCompleted = vi.fn()
const onTTSChunk = vi.fn()
const ttsEvent = {
event: 'tts_message',
message_id: 'message-1',
audio: 'audio-chunk',
audio_type: 'audio/wav',
}
const mockReader = {
read: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(ttsEvent)}\n`),
})
.mockResolvedValueOnce({ done: true, value: undefined }),
}
const mockResponse = {
ok: true,
body: { getReader: () => mockReader },
} as unknown as Response
const interveningNoops = Array.from({ length: 18 }, () => undefined)
;(handleStream as (...args: unknown[]) => void)(
mockResponse,
onData,
onCompleted,
...interveningNoops,
onTTSChunk,
)
await waitFor(() => {
expect(onTTSChunk).toHaveBeenCalledWith('message-1', 'audio-chunk', 'audio/wav')
})
})
it('should complete with error when the stream reader rejects', async () => {
const onData = vi.fn()
const onCompleted = vi.fn()
+1 -1
View File
@@ -116,7 +116,7 @@ type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinished
type IOnTextChunk = (textChunk: TextChunkResponse) => void
type IOnReasoning = (reasoningChunk: ReasoningChunkResponse) => void
type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void
type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void
type IOnTTSEnd = (messageId: string, audioStr: string) => void
type IOnTextReplace = (textReplace: TextReplaceResponse) => void
type IOnLoopStarted = (workflowStarted: LoopStartedResponse) => void
type IOnLoopNext = (workflowStarted: LoopNextResponse) => void
+1 -2
View File
@@ -401,7 +401,6 @@ export const audioToText = (
export const textToAudioStream = (
url: string,
appSourceType: AppSourceType,
header: { content_type: string },
body: {
streaming: boolean
voice?: string
@@ -409,7 +408,7 @@ export const textToAudioStream = (
text?: string | null | undefined
},
) => {
return getAction('post', appSourceType)(url, { body, header }, { needAllResponseContent: true })
return getAction('post', appSourceType)(url, { body }, { needAllResponseContent: true })
}
export const fetchAccessToken = async ({