diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c40475..efb0642 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,17 +30,23 @@ repos: rev: v3.1.0 hooks: - id: add-trailing-comma - exclude: ^tests/legacy/ + # examples/01_atomic_capabilities/audio is ported verbatim from the sibling repo's + # samples/ tree, whose hooks exempt samples/. + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy + # examples/01_atomic_capabilities/audio: mypy cannot map the + # digit-leading 01_atomic_capabilities segment to a module name + # (reports duplicate _common). exclude: (?x)( pb2\.py$ | grpc\.py$ | ^docs | ^tests/legacy/ + | ^examples/01_atomic_capabilities/audio/ | \.html$ | \.pyi$ ) @@ -61,13 +67,13 @@ repos: hooks: - id: black args: [ --line-length=79 ] - exclude: ^tests/legacy/ + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 hooks: - id: flake8 args: [ "--extend-ignore=E203"] - exclude: ^tests/legacy/ + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pylint-dev/pylint rev: v3.0.2 hooks: @@ -76,6 +82,7 @@ repos: (?x)( ^docs | ^tests/ + | ^examples/01_atomic_capabilities/audio/ | pb2\.py$ | grpc\.py$ | \.demo$ @@ -168,6 +175,15 @@ repos: # Long sequential arrange/act scripts are normal in integration # tests; splitting them for R0915 hurts readability. --disable=R0915, + # Table-driven tests are branchy by nature (R0912); test doubles + # deliberately hold resources open to assert lifecycle order + # (R1732); passthrough lambda seams read clearer than direct + # method refs (W0108); explicit trailing ``return`` marks the + # end of scripted callbacks (R1711). + --disable=R0912, + --disable=R1732, + --disable=W0108, + --disable=R1711, ] - repo: https://github.com/pre-commit/mirrors-eslint rev: v7.32.0 diff --git a/dashscope/__init__.pyi b/dashscope/__init__.pyi index 8af2cc0..538a6e2 100644 --- a/dashscope/__init__.pyi +++ b/dashscope/__init__.pyi @@ -11,6 +11,15 @@ from typing import ( Union, ) +# --------------------------------------------------------------------------- +# dashscope 2.0 +# --------------------------------------------------------------------------- +from dashscope.v2 import AsyncClient as AsyncClient +from dashscope.v2 import Client as Client +from dashscope.v2 import WsConnection as WsConnection +from dashscope.v2 import chat as chat +from dashscope.v2 import settings as settings + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- diff --git a/dashscope/v2/client.py b/dashscope/v2/client.py index 3302d31..55069f8 100644 --- a/dashscope/v2/client.py +++ b/dashscope/v2/client.py @@ -10,7 +10,6 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Union -import warnings import httpx @@ -117,7 +116,7 @@ def __init__( self.models = Models(self) self.images = Images(self) self.videos = Videos(self) - self._audio = None + self.audio = Audio(self) self.fine_tunes = FineTunes(self) self.deployments = Deployments(self) self.rl = Rl(self) @@ -125,25 +124,6 @@ def __init__( self.tingwu = TingWu(self) self._agentstudio = None - @property - def audio(self) -> "Audio": - """Audio capabilities (lazily mounted). - - Preview: the 2.0 audio API surface is not final and may change - in a future release. - """ - if self._audio is None: - warnings.warn( - "dashscope 2.0 audio API is a preview and may change " - "in a future release.", - FutureWarning, - stacklevel=2, - ) - self._audio = Audio(self) - audio = self._audio - assert audio is not None - return audio - @property def reinforcement_learning(self) -> "Rl": """Long-form alias for ``client.rl``.""" @@ -266,11 +246,11 @@ def ws_connection( Credentials, region and timeout come from this client's config. Pass the connection as ``connection=`` to the realtime audio sessions (``audio.asr.recognize`` / ``audio.tts.connect`` / - ``audio.translation.recognize`` / ``tingwu.realtime``) to run - tasks concurrently or back-to-back on one socket, saving the - handshake per task -- the 1.x Java-side connection-reuse pattern - for latency-sensitive audio traffic. The caller owns the - connection and closes it via ``WsConnection.close``. + ``tingwu.realtime``) to run tasks concurrently or back-to-back + on one socket, saving the handshake per task -- the 1.x + Java-side connection-reuse pattern for latency-sensitive audio + traffic. The caller owns the connection and closes it via + ``WsConnection.close``. """ config = self._config return WsConnection( @@ -358,7 +338,7 @@ def __init__( self.models = AsyncModels(self) self.images = AsyncImages(self) self.videos = AsyncVideos(self) - self._audio = None + self.audio = AsyncAudio(self) self.fine_tunes = AsyncFineTunes(self) self.deployments = AsyncDeployments(self) self.rl = AsyncRl(self) @@ -366,25 +346,6 @@ def __init__( self.tingwu = AsyncTingWu(self) self._agentstudio = None - @property - def audio(self) -> "AsyncAudio": - """Audio capabilities (lazily mounted). - - Preview: the 2.0 audio API surface is not final and may change - in a future release. - """ - if self._audio is None: - warnings.warn( - "dashscope 2.0 audio API is a preview and may change " - "in a future release.", - FutureWarning, - stacklevel=2, - ) - self._audio = AsyncAudio(self) - audio = self._audio - assert audio is not None - return audio - @property def reinforcement_learning(self) -> "AsyncRl": """Long-form alias for ``client.rl``.""" diff --git a/dashscope/v2/errors.py b/dashscope/v2/errors.py index a84cba8..c243553 100644 --- a/dashscope/v2/errors.py +++ b/dashscope/v2/errors.py @@ -374,28 +374,52 @@ def error_from_sdk_exception( def _sse_error_body(text: str) -> Any: - """Decode an SSE error body into its first ``data:`` JSON payload. - - A streaming request the gateway rejects before any output arrives - as a non-2xx response whose body is the error frame itself -- - ``event:error`` plus a ``data:`` JSON line -- instead of a plain - JSON document. 1.x parses the same shape in - ``common.utils._handle_http_failed_response``; like - ``resources/_sse.py``, the frame is detected by line shape rather - than content-type, so a mislabeled header still maps. Bodies with - no decodable frame yield None, leaving the raw-text fallback - intact. - """ - for line in text.splitlines(): - if not line.startswith("data:"): - continue + """Decode an SSE error payload, preferring codes over message-only frames.""" + data_lines = [] + fallback = None + + def flush() -> Any: + nonlocal fallback + if not data_lines: + return None try: - body = json.loads(line[len("data:") :]) + bodies = [json.loads("\n".join(data_lines))] except json.JSONDecodeError: - continue - if isinstance(body, dict): - return body - return None + # Older gateways omit blank lines between complete JSON frames. + bodies = [] + for data in data_lines: + try: + bodies.append(json.loads(data)) + except json.JSONDecodeError: + continue + for body in bodies: + if not isinstance(body, dict): + continue + error = _extract(body.get("error")) + if body.get("code") or any( + error.get(key) for key in ("code", "type", "message") + ): + return body + if ( + fallback is None + and isinstance(body.get("message"), str) + and body["message"] + ): + fallback = body + return None + + for line in text.splitlines(): + if line.startswith("data:"): + data_lines.append(line[len("data:") :]) + elif line == "data": + data_lines.append("") + elif not line: + body = flush() + data_lines.clear() + if body is not None: + return body + body = flush() + return body if body is not None else fallback def _parse_body(response: httpx.Response) -> Any: diff --git a/dashscope/v2/protocols/anthropic.py b/dashscope/v2/protocols/anthropic.py index 1b91613..6b7dc49 100644 --- a/dashscope/v2/protocols/anthropic.py +++ b/dashscope/v2/protocols/anthropic.py @@ -605,7 +605,10 @@ def build_chat_request( # unchanged, so this converges both entry points and keeps the # hatch-wins precedence. Copy first: never mutate the caller's # dict. - if extra_parameters and "tool_choice" in extra_parameters: + if ( + extra_parameters + and extra_parameters.get("tool_choice") is not None + ): extra_parameters = dict(extra_parameters) extra_parameters["tool_choice"] = _translate_tool_choice( extra_parameters["tool_choice"], diff --git a/dashscope/v2/resources/_agentstudio.py b/dashscope/v2/resources/_agentstudio.py index 9b7665c..a58700b 100644 --- a/dashscope/v2/resources/_agentstudio.py +++ b/dashscope/v2/resources/_agentstudio.py @@ -32,25 +32,19 @@ The proxy is transparent (``__class__`` spoofing plus attribute forwarding) so ``isinstance(client.agentstudio, AgentStudioClient)``, ``.transport`` access and the context-manager protocol keep working. - -Return values whose errors surface *after* the call are guarded too: -event streams raise on enter/iterate, and cursor pages raise on -auto-pagination and explicit ``get_next()`` (their ``fetch_next`` -closure captures the raw 1.x resource method, bypassing the method -guards), so both are wrapped to keep late failures translated. """ from __future__ import annotations import asyncio import functools +from collections.abc import AsyncIterator, Iterator from typing import Any, Dict, Optional +import httpx + from dashscope.agentstudio import exceptions as _as_exceptions -from dashscope.agentstudio.pagination import ( - AsyncCursorPage as _AsyncCursorPage, -) -from dashscope.agentstudio.pagination import CursorPage as _CursorPage +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage from dashscope.v2 import errors as _v2_errors _AGENTSTUDIO_FAMILY = "sdk.agentstudio" @@ -108,18 +102,17 @@ def translate_agentstudio_error( status = getattr(exc, "status_code", None) if isinstance(exc, _as_exceptions.APIStatusError) or status is not None: - # HTTP layer: keep the server's real status and let the catalog - # status map pick the class. agentstudio codes are CMA-shaped - # (``rate_limit_error``), not gateway wire codes, so the map keys - # off ``status_code``; the raw body still supplies message/code. + # V1 already resolved compatibility fields and header precedence. request_id = getattr(exc, "request_id", None) headers = {"x-request-id": request_id} if request_id else None - return _v2_errors.error_from_status( + error = _v2_errors.error_from_status( _effective_status(exc), message=getattr(exc, "message", None) or str(exc), - body=getattr(exc, "raw", None), headers=headers, ) + error.code = exc.code + error.body = exc.raw + return error if type(exc).__name__ in _SDK_ERROR_CLASSES: return _v2_errors.error_from_sdk_exception( @@ -141,8 +134,7 @@ def _is_resource(obj: Any) -> bool: def _is_sync_stream(obj: Any) -> bool: # CursorPage is iterable but not a context manager, so requiring both - # keeps pagination results out of the stream proxy; they get their - # own guard in ``_wrap_result``. + # keeps pagination results out of the stream proxy. return hasattr(obj, "__enter__") and hasattr(obj, "__iter__") @@ -151,16 +143,11 @@ def _is_async_stream(obj: Any) -> bool: def _wrap_result(result: Any) -> Any: - """Guard a method's return value if its failures surface lazily. + """Guard lazy stream and pagination calls without changing model data.""" - Streams raise on enter/iterate; cursor pages raise while iterating - (auto-pagination) and on explicit ``get_next()``. Plain values pass - through untouched. - """ - - if isinstance(result, _AsyncCursorPage): + if isinstance(result, AsyncCursorPage): return _AsyncPageProxy(result) - if isinstance(result, _CursorPage): + if isinstance(result, CursorPage): return _SyncPageProxy(result) if _is_async_stream(result): return _AsyncStreamProxy(result) @@ -169,6 +156,45 @@ def _wrap_result(result: Any) -> Any: return result +_STREAM_ERRORS = ( + _as_exceptions.AgentStudioError, + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, +) + + +def _translate_stream_error(exc: Exception) -> _v2_errors.DashScopeError: + if isinstance(exc, _as_exceptions.AgentStudioError): + return translate_agentstudio_error(exc) + cls = ( + _as_exceptions.APITimeoutError + if isinstance(exc, httpx.TimeoutException) + else _as_exceptions.APIConnectionError + ) + return translate_agentstudio_error(cls(str(exc))) + + +def _iterate_sync(target: Any) -> Any: + try: + yield from target + except _STREAM_ERRORS as exc: + raise _translate_stream_error(exc) from exc + + +async def _iterate_async(target: Any) -> Any: + iterator = None + try: + iterator = target.__aiter__() + async for item in iterator: + yield item + except _STREAM_ERRORS as exc: + raise _translate_stream_error(exc) from exc + finally: + if hasattr(iterator, "aclose"): + await iterator.aclose() + + def _guard_sync(func: Any) -> Any: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: @@ -204,7 +230,15 @@ def __class__(self) -> type: # noqa: D105 - transparency helper return type(object.__getattribute__(self, "_target")) def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) + value = getattr(object.__getattribute__(self, "_target"), name) + if isinstance(value, Iterator): + return _iterate_sync(value) + if callable(value): + return _guard_sync(value) + return value + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_target"), name, value) def __enter__(self) -> "_SyncStreamProxy": target = object.__getattribute__(self, "_target") @@ -219,12 +253,7 @@ def __exit__(self, *exc_info: Any) -> Optional[bool]: return target.__exit__(*exc_info) def __iter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc + return _iterate_sync(object.__getattribute__(self, "_target")) class _AsyncStreamProxy: @@ -238,7 +267,19 @@ def __class__(self) -> type: # noqa: D105 - transparency helper return type(object.__getattribute__(self, "_target")) def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) + value = getattr(object.__getattribute__(self, "_target"), name) + if isinstance(value, AsyncIterator): + return _iterate_async(value) + if callable(value): + return ( + _guard_async(value) + if asyncio.iscoroutinefunction(value) + else _guard_sync(value) + ) + return value + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_target"), name, value) async def __aenter__(self) -> "_AsyncStreamProxy": target = object.__getattribute__(self, "_target") @@ -252,85 +293,8 @@ async def __aexit__(self, *exc_info: Any) -> Optional[bool]: target = object.__getattribute__(self, "_target") return await target.__aexit__(*exc_info) - async def __aiter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - async for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - -class _SyncPageProxy: - """Guards a cursor page whose fetches surface on iterate/get_next. - - Iterating the raw page auto-paginates through the unguarded - ``fetch_next`` closure, so both dunder iteration and the explicit - ``get_next()`` need the translation here. - """ - - def __init__(self, target: Any) -> None: - object.__setattr__(self, "_target", target) - - @property # type: ignore[misc] - def __class__(self) -> type: # noqa: D105 - transparency helper - return type(object.__getattribute__(self, "_target")) - - def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) - - def __len__(self) -> int: - return len(object.__getattribute__(self, "_target")) - - def get_next(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - return _wrap_result(target.get_next()) - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - def __iter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - -class _AsyncPageProxy: - """Async twin of :class:`_SyncPageProxy`.""" - - def __init__(self, target: Any) -> None: - object.__setattr__(self, "_target", target) - - @property # type: ignore[misc] - def __class__(self) -> type: # noqa: D105 - transparency helper - return type(object.__getattribute__(self, "_target")) - - def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) - - def __len__(self) -> int: - return len(object.__getattribute__(self, "_target")) - - async def get_next(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - return _wrap_result(await target.get_next()) - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - def __aiter__(self) -> Any: - return self._aiter() - - async def _aiter(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - async for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc + return _iterate_async(object.__getattribute__(self, "_target")) class AgentStudioProxy: @@ -352,24 +316,24 @@ def __class__(self) -> type: # noqa: D105 - transparency helper def __getattr__(self, name: str) -> Any: target = object.__getattribute__(self, "_target") cache = object.__getattribute__(self, "_cache") - if name in cache: - return cache[name] value = getattr(target, name) + if _is_resource(value) and not callable(value): + cached = cache.get(name) + if cached is None or cached[0] is not value: + cached = cache[name] = (value, AgentStudioProxy(value)) + return cached[1] + cache.pop(name, None) if callable(value): - wrapped = ( + return ( _guard_async(value) if asyncio.iscoroutinefunction(value) else _guard_sync(value) ) - elif _is_resource(value): - wrapped = AgentStudioProxy(value) - else: - wrapped = value - cache[name] = wrapped - return wrapped + return value def __setattr__(self, name: str, value: Any) -> None: setattr(object.__getattribute__(self, "_target"), name, value) + object.__getattribute__(self, "_cache").pop(name, None) def __repr__(self) -> str: # pragma: no cover - debug helper return repr(object.__getattribute__(self, "_target")) @@ -391,6 +355,29 @@ async def __aexit__(self, *exc_info: Any) -> Optional[bool]: return await target.__aexit__(*exc_info) +class _SyncPageProxy(AgentStudioProxy): + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + def __iter__(self) -> Any: + try: + yield from object.__getattribute__(self, "_target") + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class _AsyncPageProxy(AgentStudioProxy): + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + async def __aiter__(self) -> Any: + try: + async for item in object.__getattribute__(self, "_target"): + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + def wrap_agentstudio(client: Any) -> AgentStudioProxy: """Wrap a constructed AgentStudio client in the v2 translation proxy.""" diff --git a/dashscope/v2/resources/_jobs.py b/dashscope/v2/resources/_jobs.py index 9d7d3ed..61b4254 100644 --- a/dashscope/v2/resources/_jobs.py +++ b/dashscope/v2/resources/_jobs.py @@ -265,7 +265,7 @@ def create( # pylint: disable=redefined-builtin ) -> Job: """Submit a task; returns immediately with a PENDING/RUNNING Job.""" body: Dict[str, Any] = {"model": model, "input": input} - if parameters: + if parameters is not None: body["parameters"] = parameters if resources is not None: body["resources"] = resources @@ -463,7 +463,7 @@ async def create( # pylint: disable=redefined-builtin workspace: Optional[str] = None, ) -> Job: body: Dict[str, Any] = {"model": model, "input": input} - if parameters: + if parameters is not None: body["parameters"] = parameters if resources is not None: body["resources"] = resources diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/v2/resources/audio/__init__.py index 3e5ef06..a399ab0 100644 --- a/dashscope/v2/resources/audio/__init__.py +++ b/dashscope/v2/resources/audio/__init__.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""client.audio resource group (asr/tts/realtime/translation, design doc -M3 3.4/3.5). +"""client.audio resource group (asr/tts/realtime, design doc M3 3.4/3.5). Assembly only — each sub-resource lives in its own module, so the tree here is two levels deep at most: ``client.audio.asr.transcribe``, ``client.audio.asr.recognize``, ``client.audio.tts.synthesize``, ``client.audio.tts.http.synthesize``, ``client.audio.realtime.connect``, -``client.audio.translation.recognize``, and the management plane -``client.audio.vocabularies`` / ``phrases`` / ``voices`` / -``voice_design``. +and the management plane ``client.audio.vocabularies`` / ``phrases`` / +``voices`` / ``voice_design``. """ from dashscope.v2.resources.audio.asr import ( @@ -40,10 +38,6 @@ Phrases, ) from dashscope.v2.resources.audio.realtime import Realtime, RealtimeSession -from dashscope.v2.resources.audio.translation import ( - Translation, - TranslationSession, -) from dashscope.v2.resources.audio.tts import SynthesisSession, Tts __all__ = [ @@ -64,8 +58,6 @@ "RealtimeSession", "Recognition", "SynthesisSession", - "Translation", - "TranslationSession", "Tts", "Vocabularies", "Vocabulary", @@ -78,17 +70,12 @@ class Audio: - """Sync audio surface. - - Preview: the 2.0 audio API surface is not final and may change in - a future release. - """ + """Sync audio surface.""" def __init__(self, client) -> None: self.asr = Asr(client) self.tts = Tts(client) self.realtime = Realtime(client) - self.translation = Translation(client) self.vocabularies = Vocabularies(client) self.phrases = Phrases(client) self.voices = Voices(client) @@ -101,9 +88,6 @@ class AsyncAudio: Only batch transcription is exposed here; the ws realtime resources (``asr.recognize``, ``tts``, ``realtime``) are sync-only by design (design doc M3 3.5). - - Preview: the 2.0 audio API surface is not final and may change in - a future release. """ def __init__(self, client) -> None: diff --git a/dashscope/v2/resources/audio/asr.py b/dashscope/v2/resources/audio/asr.py index 144f738..6e49078 100644 --- a/dashscope/v2/resources/audio/asr.py +++ b/dashscope/v2/resources/audio/asr.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import os import time from typing import ( @@ -27,10 +28,13 @@ from pydantic import BaseModel, ConfigDict, Field +from dashscope.v2.errors import DashScopeError, error_from_business_code from dashscope.v2.resources._jobs import ( + CANCELED, DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, + FAILED, AsyncJobs, Job, Jobs, @@ -86,10 +90,29 @@ def _phrase_resources( return [{"resource_id": phrase_id, "resource_type": "asr_phrase"}] +def _check_terminal(job: Job, *, raise_on_error: bool) -> Job: + """Apply ASR's error contract even to caller-supplied snapshots.""" + if job.status == FAILED and raise_on_error: + raise error_from_business_code( + message=job.message or f"task {job.task_id} failed", + code=job.code, + body=job.model_dump(), + headers=job.headers, + ) + if job.status == CANCELED and raise_on_error: + raise DashScopeError( + f"task {job.task_id} was canceled while waiting", + request_id=job.request_id, + headers=job.headers, + ) + return job + + class TranscriptionJobs: """Async task facade over the batch transcription service.""" def __init__(self, client) -> None: + self._client = client self._jobs = Jobs(client, SERVICE, MODULE) def create( @@ -106,7 +129,7 @@ def create( return self._jobs.create( model=model, input=_input_for(file_urls), - parameters=parameters, + parameters={} if parameters is None else parameters, resources=_phrase_resources(phrase_id), timeout=timeout, api_key=api_key, @@ -139,19 +162,41 @@ def wait( workspace: Optional[str] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: - return self._jobs.wait( - job, - poll_interval=poll_interval, - max_interval=max_interval, - timeout=timeout, - api_key=api_key, - workspace=workspace, - _sleep=_sleep, + """Poll with backoff, without starting another poll after the deadline.""" + task_id = job.task_id if isinstance(job, Job) else job + sleep_fn = _sleep or time.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, Job) + else self.fetch(task_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat-mode error envelopes have no terminal task status. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + sleep_fn(min(interval, remaining)) + if time.monotonic() >= deadline: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + interval = min(interval * 2, max_interval) + current = self.fetch(task_id, api_key=api_key, workspace=workspace) + return _check_terminal( + current, + raise_on_error=self._client.raise_on_error, ) class AsyncTranscriptionJobs: def __init__(self, client) -> None: + self._client = client self._jobs = AsyncJobs(client, SERVICE, MODULE) async def create( @@ -168,7 +213,7 @@ async def create( return await self._jobs.create( model=model, input=_input_for(file_urls), - parameters=parameters, + parameters={} if parameters is None else parameters, resources=_phrase_resources(phrase_id), timeout=timeout, api_key=api_key, @@ -201,14 +246,43 @@ async def wait( workspace: Optional[str] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: - return await self._jobs.wait( - job, - poll_interval=poll_interval, - max_interval=max_interval, - timeout=timeout, - api_key=api_key, - workspace=workspace, - _sleep=_sleep, + """Async twin of :meth:`TranscriptionJobs.wait`.""" + task_id = job.task_id if isinstance(job, Job) else job + sleep_fn = _sleep or asyncio.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, Job) + else await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + ) + interval = poll_interval + while not current.finished: + # Compat-mode error envelopes have no terminal task status. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + await sleep_fn(min(interval, remaining)) + if time.monotonic() >= deadline: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + interval = min(interval * 2, max_interval) + current = await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + return _check_terminal( + current, + raise_on_error=self._client.raise_on_error, ) @@ -385,26 +459,8 @@ def events(self) -> Iterator[Dict[str, Any]]: self.usage = payload["usage"] output = payload.get("output") or {} sentence = output.get("sentence") or {} - transcription = output.get("transcription") or {} - translations = output.get("translations") or [] - # First-package delay fires on the first frame carrying real - # recognized text. Plain ASR puts it in ``output.sentence``; the - # Gummy translation wire shares this base but reports text under - # ``output.transcription`` / ``output.translations`` instead, so - # all three shapes count. Heartbeat frames carry no text and so - # never trigger the timestamp. has_text = bool( - (sentence.get("text") and not sentence.get("heartbeat")) - or ( - transcription.get("text") - and not transcription.get("heartbeat") - ) - or any( - isinstance(item, dict) - and item.get("text") - and not item.get("heartbeat") - for item in translations - ), + sentence.get("text") and not sentence.get("heartbeat"), ) if ( has_text @@ -457,7 +513,7 @@ def transcribe( self, file_urls: List[str], *, - model: str = "paraformer-v2", + model: str = "qwen-audio-3.1-asr-flash-filetrans", parameters: Optional[Dict[str, Any]] = None, phrase_id: Optional[str] = None, timeout: float = DEFAULT_WAIT_TIMEOUT, @@ -594,7 +650,7 @@ async def transcribe( self, file_urls: List[str], *, - model: str = "paraformer-v2", + model: str = "qwen-audio-3.1-asr-flash-filetrans", parameters: Optional[Dict[str, Any]] = None, phrase_id: Optional[str] = None, timeout: float = DEFAULT_WAIT_TIMEOUT, diff --git a/dashscope/v2/resources/audio/customization.py b/dashscope/v2/resources/audio/customization.py index bde917f..8967547 100644 --- a/dashscope/v2/resources/audio/customization.py +++ b/dashscope/v2/resources/audio/customization.py @@ -19,11 +19,11 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import check_response from dashscope.v2.types.common import ( JobStatus, ResponseBase, flatten_envelope, + response_body, with_headers, ) @@ -113,11 +113,9 @@ def _call( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> Any: - input_ = {"action": action} - input_.update(fields) body: Dict[str, Any] = { "model": model or self.DEFAULT_MODEL, - "input": input_, + "input": {**fields, "action": action}, } if parameters is not None: body["parameters"] = parameters @@ -131,9 +129,16 @@ def _call( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) return response + def _parse_response(self, response: Any) -> Dict[str, Any]: + return flatten_envelope( + response_body( + response, + raise_on_error=self._client.raise_on_error, + ), + ) + class Vocabularies(_CustomizationBase): """``client.audio.vocabularies`` -- asr hot-word tables.""" @@ -170,7 +175,7 @@ def create( workspace=workspace, ) return with_headers( - Vocabulary(**flatten_envelope(response.json())), + Vocabulary(**self._parse_response(response)), response, ) @@ -203,7 +208,7 @@ def list( workspace=workspace, ) return with_headers( - VocabularyList(**flatten_envelope(response.json())), + VocabularyList(**self._parse_response(response)), response, ) @@ -229,7 +234,7 @@ def get( workspace=workspace, ) return with_headers( - Vocabulary(**flatten_envelope(response.json())), + Vocabulary(**self._parse_response(response)), response, ) @@ -256,7 +261,7 @@ def update( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -282,7 +287,7 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -330,7 +335,7 @@ def create( workspace=workspace, ) return with_headers( - Voice(**flatten_envelope(response.json())), + Voice(**self._parse_response(response)), response, ) @@ -363,7 +368,7 @@ def list( workspace=workspace, ) return with_headers( - VoiceList(**flatten_envelope(response.json())), + VoiceList(**self._parse_response(response)), response, ) @@ -389,7 +394,7 @@ def get( workspace=workspace, ) return with_headers( - Voice(**flatten_envelope(response.json())), + Voice(**self._parse_response(response)), response, ) @@ -416,7 +421,7 @@ def update( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -442,7 +447,7 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -498,7 +503,7 @@ def create( workspace=workspace, ) return with_headers( - DesignedVoice(**flatten_envelope(response.json())), + DesignedVoice(**self._parse_response(response)), response, ) @@ -524,7 +529,7 @@ def list( workspace=workspace, ) return with_headers( - DesignedVoiceList(**flatten_envelope(response.json())), + DesignedVoiceList(**self._parse_response(response)), response, ) @@ -550,7 +555,7 @@ def get( workspace=workspace, ) return with_headers( - DesignedVoice(**flatten_envelope(response.json())), + DesignedVoice(**self._parse_response(response)), response, ) @@ -576,6 +581,6 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) diff --git a/dashscope/v2/resources/audio/http_tts.py b/dashscope/v2/resources/audio/http_tts.py index c3a9208..cc0edad 100644 --- a/dashscope/v2/resources/audio/http_tts.py +++ b/dashscope/v2/resources/audio/http_tts.py @@ -4,28 +4,39 @@ V2 form of the two 1.x HTTP TTS families, sharing one entry: -- sambert/cosyvoice (1.x ``HttpSpeechSynthesizer``) -> - ``services/audio/tts/SpeechSynthesizer`` +- qwen-audio / cosyvoice -> ``services/audio/tts/SpeechSynthesizer`` - qwen-tts family (1.x qwen ``SpeechSynthesizer``: qwen-tts, qwen3-tts-flash) -> ``services/aigc/multimodal-generation/generation`` Both POST the same ``{model, input}`` envelope and answer with ``output.audio``; streaming rides SSE via the ``X-DashScope-SSE: enable`` -header, framed exactly like the 1.x ``StreamEventMixin`` wire (see -``resources/_sse.py``). +header. Audio uses standard SSE framing and strict error handling locally, +without changing the legacy job-event stream contract. """ from __future__ import annotations import base64 import json -from typing import Any, Dict, Iterator, Mapping, Optional - -from pydantic import BaseModel, ConfigDict, Field - -from dashscope.v2.errors import DashScopeError, check_response -from dashscope.v2.resources._sse import _iter_sse, _stream_error -from dashscope.v2.types.common import with_headers +from typing import ( + Any, + Dict, + Generator, + Iterator, + List, + Mapping, + Optional, + Tuple, +) + +from dashscope.v2.errors import ( + APIStatusError, + DashScopeError, + error_from_business_code, + error_from_status, +) +from dashscope.v2.resources._sse import _try_json +from dashscope.v2.types.common import ResponseBase, response_body, with_headers __all__ = [ "HttpSynthesis", @@ -41,9 +52,108 @@ _QWEN_PATH = "/services/aigc/multimodal-generation/generation" +_Frame = Tuple[bool, Optional[int], str] + + +class _SSEDecoder: + """Standard SSE framing for synchronous HTTP audio only.""" + + def __init__(self) -> None: + self.is_error = False + self.status_code: Optional[int] = None + self.data: List[str] = [] + + def feed(self, line: str) -> Optional[_Frame]: + if not line: + frame = None + if self.data: + data = "\n".join(self.data) + body = _try_json(data) + is_error = self.is_error or ( + isinstance(body, dict) and bool(body.get("code")) + ) + frame = (is_error, self.status_code, data) + self.is_error = False + self.status_code = None + self.data = [] + return frame + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field == "event": + self.is_error = value == "error" + elif field == "status": + self.status_code = int(value.strip()) + elif field == "data": + self.data.append(value) + return None + + +def _iter_audio_sse(response) -> Iterator[_Frame]: + # Non-2xx audio bodies can look like valid SSE but are still failures. + if not 200 <= response.status_code < 300: + response.read() + response_body(response) + return + media_type = response.headers.get("content-type", "").split(";", 1)[0] + media_type = media_type.strip().lower() + if media_type == "application/json" or media_type.endswith("+json"): + response.read() + response_body(response) + return + decoder = _SSEDecoder() + for line in response.iter_lines(): + frame = decoder.feed(line) + if frame is not None: + yield frame + if frame[0]: + return + # Some services omit the final blank line; retain the final audio/error part. + frame = decoder.feed("") + if frame is not None: + yield frame + + +def _with_audio_request_id(error: DashScopeError) -> DashScopeError: + """Keep audio's outer/output/header precedence out of shared errors.""" + body = error.body if isinstance(error.body, dict) else {} + output = body.get("output") + output = output if isinstance(output, dict) else {} + error.request_id = ( + body.get("request_id") + or output.get("request_id") + or error.headers.get("x-request-id") + or error.request_id + ) + return error + + +def _audio_stream_error( + status_code: Optional[int], + data: str, + headers: Mapping[str, str], +) -> DashScopeError: + body = _try_json(data) + message = (body.get("message") or data) if isinstance(body, dict) else data + if status_code is None: + error = error_from_business_code( + message=message, + body=body, + headers=headers, + ) + else: + error = error_from_status( + status_code, + message=message, + body=body, + headers=headers, + ) + return _with_audio_request_id(error) + + def _path_for(model: str) -> str: - # qwen-tts / qwen3-tts-flash ride the multimodal-generation service; - # sambert/cosyvoice ride the audio tts service. + if model.startswith("qwen-audio-"): + return _DEFAULT_PATH if model.startswith("qwen"): return _QWEN_PATH return _DEFAULT_PATH @@ -87,17 +197,12 @@ def _usage_dict(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: return usage if isinstance(usage, dict) else None -class HttpSynthesis(BaseModel): +class HttpSynthesis(ResponseBase): """Non-streaming result: a URL reference to the rendered audio.""" - model_config = ConfigDict(extra="allow") - output: Dict[str, Any] = {} usage: Optional[Dict[str, Any]] = None request_id: str = "" - # Raw HTTP response headers, 1.x parity (``GenerationResponse.headers``); - # filled via ``with_headers`` like every other non-streaming success. - headers: Dict[str, str] = Field(default_factory=dict) @property def audio_url(self) -> Optional[str]: @@ -114,11 +219,9 @@ def expires_at(self) -> Optional[int]: return _audio_dict(self.output).get("expires_at") -class HttpSynthesisChunk(BaseModel): +class HttpSynthesisChunk(ResponseBase): """One SSE part of a streaming synthesis.""" - model_config = ConfigDict(extra="allow") - output: Dict[str, Any] = {} audio: Optional[bytes] = None usage: Optional[Dict[str, Any]] = None @@ -159,9 +262,12 @@ def _synthesis_from_body( ) -> HttpSynthesis: output = body.get("output") return HttpSynthesis( - output=output if isinstance(output, dict) else {}, - usage=_usage_dict(body), - request_id=body.get("request_id") or header_request_id, + **{ + **body, + "output": output if isinstance(output, dict) else {}, + "usage": _usage_dict(body), + "request_id": body.get("request_id") or header_request_id, + }, ) @@ -180,12 +286,20 @@ def _chunk_from_data( output = body.get("output") output = output if isinstance(output, dict) else {} payload = _audio_dict(output).get("data") - return HttpSynthesisChunk( - output=output, - audio=base64.b64decode(payload) if payload else None, - usage=_usage_dict(body), - request_id=body.get("request_id") or header_request_id, + chunk = HttpSynthesisChunk( + **{ + **body, + "output": output, + "audio": base64.b64decode(payload, validate=True) + if payload + else None, + "usage": _usage_dict(body), + "request_id": body.get("request_id") or header_request_id, + }, ) + if chunk.status_code is not None and not 200 <= chunk.status_code < 300: + chunk.is_error = True + return chunk class HttpSynthesisStream: @@ -193,14 +307,14 @@ class HttpSynthesisStream: def __init__( self, - inner: Iterator[HttpSynthesisChunk], + inner: Generator[HttpSynthesisChunk, None, None], response: Any, ) -> None: self._inner = inner self._response = response - def __iter__(self) -> Iterator[HttpSynthesisChunk]: - return iter(self._inner) + def __iter__(self) -> "HttpSynthesisStream": + return self def __next__(self) -> HttpSynthesisChunk: return next(self._inner) @@ -211,7 +325,10 @@ def headers(self) -> Dict[str, str]: return dict(self._response.headers) def close(self) -> None: - self._inner.close() + try: + self._inner.close() + finally: + self._response.close() class HttpTts: @@ -256,10 +373,12 @@ def synthesize( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( _synthesis_from_body( - response.json(), + response_body( + response, + raise_on_error=self._client.raise_on_error, + ), response.headers.get("x-request-id", ""), ), response, @@ -291,33 +410,38 @@ def stream( sse_headers = dict(_SSE_HEADER) if headers: sse_headers.update(headers) - response = self._client.transport.open_stream( - "POST", - _path_for(model), - json=_build_body( - text, - model=model, - voice=voice, - format=format, - sample_rate=sample_rate, - language_type=language_type, - parameters=parameters, - extra_input=extra_input, - ), - headers=sse_headers, - timeout=timeout, - sdk_module=MODULE, - api_key=api_key, - workspace=workspace, - ) + try: + response = self._client.transport.open_stream( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + language_type=language_type, + parameters=parameters, + extra_input=extra_input, + ), + headers=sse_headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + except APIStatusError as error: + # >=400 is raised (and closed) before open_stream returns a response. + _with_audio_request_id(error) + raise return HttpSynthesisStream(self._events(response), response) - def _events(self, response) -> Iterator[HttpSynthesisChunk]: + def _events(self, response) -> Generator[HttpSynthesisChunk, None, None]: header_request_id = response.headers.get("x-request-id", "") try: - for is_error, status, data in _iter_sse(response): + for is_error, status, data in _iter_audio_sse(response): if is_error: - raise _stream_error(status, data) + raise _audio_stream_error(status, data, response.headers) yield _chunk_from_data(data, header_request_id) finally: response.close() diff --git a/dashscope/v2/resources/audio/phrases.py b/dashscope/v2/resources/audio/phrases.py index 084f24b..6af2687 100644 --- a/dashscope/v2/resources/audio/phrases.py +++ b/dashscope/v2/resources/audio/phrases.py @@ -3,9 +3,10 @@ """Paraformer hot-phrase management (1.x ``AsrPhraseManager``). A compile job in disguise: ``create``/``update`` POST ``/fine-tunes`` -with ``mode=compile_asr_phrase`` and an empty file set; ``get``/ -``list``/``delete`` hit ``/fine-tunes/outputs``. The resulting job id -doubles as the ``phrase_id`` that ``asr.recognize`` consumes. 1.x +with ``training_type=compile_asr_phrase`` and an empty file set; ``get``/ +``list``/``delete`` hit ``/fine-tunes/outputs``. ``finetuned_output`` is +the compiled resource ID that ``asr.recognize`` consumes as ``phrase_id``; +``job_id`` identifies the compile job and is only a legacy fallback. 1.x ``list_phrases`` accepted ``page=`` but dropped it in ``**kwargs`` before it reached the wire; v2 sends the documented ``page_no``. """ @@ -16,11 +17,11 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import check_response from dashscope.v2.types.common import ( JobStatus, ResponseBase, flatten_envelope, + response_body, with_headers, ) @@ -66,14 +67,14 @@ def _compile_body( class Phrase(ResponseBase): - """One compiled hot-phrase set; the job id doubles as ``phrase_id``.""" + """One compiled phrase set; prefer its resource ID over the compile job ID.""" phrase_id: Optional[str] = Field( None, validation_alias=AliasChoices( "phrase_id", - "job_id", "finetuned_output", + "job_id", ), ) status: Optional[str] = None @@ -88,7 +89,12 @@ class PhraseList(ResponseBase): ) phrases: List[Phrase] = Field( default_factory=list, - validation_alias=AliasChoices("phrases", "jobs", "outputs"), + validation_alias=AliasChoices( + "phrases", + "jobs", + "outputs", + "finetuned_outputs", + ), ) @@ -126,11 +132,11 @@ def create( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def update( self, @@ -162,11 +168,11 @@ def update( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def get( self, @@ -188,11 +194,11 @@ def get( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def list( self, @@ -215,11 +221,11 @@ def list( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - PhraseList(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(PhraseList(**flatten_envelope(body)), response) def delete( self, @@ -241,8 +247,8 @@ def delete( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobStatus(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(JobStatus(**flatten_envelope(body)), response) diff --git a/dashscope/v2/resources/audio/realtime.py b/dashscope/v2/resources/audio/realtime.py index e2decb9..a45fca8 100644 --- a/dashscope/v2/resources/audio/realtime.py +++ b/dashscope/v2/resources/audio/realtime.py @@ -52,25 +52,19 @@ class RealtimeSession: Usage:: with client.audio.realtime.connect( - model="qwen-omni-turbo-realtime", + model="qwen3.8-omni-flash-realtime", ) as rt: rt.send_event({"type": "session.update", "session": {...}}) for event in rt.events(): ... - Unlike the run-task family (asr/tts/translation/tingwu), the + Unlike the run-task family (asr/tts/tingwu), the realtime protocol has no task id: one socket IS one session and the server closes it when the session ends, so connection reuse does not apply here -- every session opens its own socket (see ``WsConnection`` for the multiplexable family). Latency-sensitive callers can shave the handshake by connecting ahead of use rather than by pooling. - - This one-socket-per-session rule is a published contract: it is - pinned by tests/v2/test_audio_omni_session.py and relied on by - examples/04_real_world_scenarios/ - realtime_audio_meeting_harness.py, which sizes its meeting - concurrency on it. """ def __init__( @@ -126,7 +120,9 @@ def __init__( all_headers.update(user_headers) self._model = model wire_model = next(value for key, value in query if key == "model") - if wire_model.startswith("qwen-audio-"): + if wire_model.startswith("qwen3.8-omni-"): + self._protocol = "omni_v38" + elif wire_model.startswith("qwen-audio-"): self._protocol = "qwen_audio" elif wire_model.startswith("qwen3.8-livetranslate-"): self._protocol = "livetranslate_v38" @@ -141,6 +137,8 @@ def __init__( self._connector = connector self._timeout = config.timeout if timeout is None else timeout self._lock = threading.RLock() + self._recv_lock = threading.Lock() + self._generation = 0 self._ws = None self._reset_state() @@ -149,6 +147,7 @@ def _reset_state(self) -> None: self._finish_requested = False self._finished = False self._response_active = False + self._pending_response_ids: List[Any] = [] self.session_id: Optional[str] = None self.last_message: Optional[Dict[str, Any]] = None self.last_response_id: Optional[str] = None @@ -169,6 +168,7 @@ def connect(self) -> "RealtimeSession": with self._lock: if self._ws is not None: raise RuntimeError("realtime session is already open") + self._generation += 1 try: if self._connector is None: self._ws = _default_connector( @@ -209,6 +209,8 @@ def send_event(self, event: Dict[str, Any]) -> None: self._finish_requested = True try: self._ws.send(raw) + if payload.get("type") == "response.create": + self._pending_response_ids.append(payload["event_id"]) except BaseException: self.close() raise @@ -256,7 +258,7 @@ def update_session( ), **kwargs: Any, ) -> None: - """Patch Qwen-Audio/Live settings; Omni retains its legacy defaults. + """Patch modern sessions; pre-3.8 Omni retains its legacy defaults. ``**kwargs`` is the protocol-evolution escape hatch: extra keys merge into the wire ``session`` object after the named helpers, @@ -390,7 +392,9 @@ def _omni_session( session.update(kwargs) return session - def _incremental_session( # pylint: disable=too-many-branches,too-many-statements + # Protocol event routing is a deliberate branch table. + # pylint: disable-next=too-many-branches,too-many-statements + def _incremental_session( self, options: Dict[str, Any], kwargs: Dict[str, Any], @@ -434,7 +438,7 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen if enabled is True and self._protocol != "livetranslate_v38": transcription = ( {"model": "qwen3-asr-flash-realtime"} - if self._protocol == "livetranslate" + if self._protocol in {"livetranslate", "omni_v38"} else {} ) if "input_audio_transcription_model" in options: @@ -486,7 +490,7 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen audio[direction] = {"format": options[key]} if audio: session["audio"] = audio - if self._protocol != "livetranslate_v38": + if self._protocol not in {"livetranslate_v38", "omni_v38"}: for key in ( "input_audio_format", "output_audio_format", @@ -514,12 +518,12 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen "Livetranslate 3.8 does not support " "same_language_skip_options", ) - session = self._nested_translation_session(session) + if self._protocol in {"livetranslate_v38", "omni_v38"}: + session = self._nested_audio_session(session) return session - @staticmethod - def _nested_translation_session(session: Dict[str, Any]) -> Dict[str, Any]: - if "modalities" in session: + def _nested_audio_session(self, session: Dict[str, Any]) -> Dict[str, Any]: + if self._protocol == "livetranslate_v38" and "modalities" in session: session["output_modalities"] = session.pop("modalities") audio: Dict[str, Any] = {} for field, direction, nested_key in ( @@ -531,12 +535,15 @@ def _nested_translation_session(session: Dict[str, Any]) -> Dict[str, Any]: audio.setdefault(direction, {}).setdefault("format", {})[ nested_key ] = session.pop(field) - for field, direction in ( - ("turn_detection", "input"), - ("voice", "output"), + if "voice" in session: + audio.setdefault("output", {})["voice"] = session.pop("voice") + if ( + self._protocol == "livetranslate_v38" + and "turn_detection" in session ): - if field in session: - audio.setdefault(direction, {})[field] = session.pop(field) + audio.setdefault("input", {})["turn_detection"] = session.pop( + "turn_detection", + ) raw_audio = session.pop("audio", _UNSET) if isinstance(raw_audio, dict): for direction, config in raw_audio.items(): @@ -630,7 +637,10 @@ def create_response( ): response["modalities"] = modalities response.update(kwargs) - self.send_event({"type": "response.create", "response": response}) + event: Dict[str, Any] = {"type": "response.create"} + if response or self._protocol != "omni_v38": + event["response"] = response + self.send_event(event) def cancel_response(self) -> None: self.send_event({"type": "response.cancel"}) @@ -644,60 +654,100 @@ def end_session(self) -> None: ) self.send_event({"type": "session.finish"}) + # pylint: disable-next=too-many-branches,too-many-statements def recv_event(self) -> Optional[Dict[str, Any]]: """Receive a raw event; unfinished responses cannot end silently.""" - ws = self._ws - if ws is None: - self._check_unfinished() - if self._closed: - return None - raise RuntimeError("realtime session is not open") from websocket import ( # pylint: disable=import-outside-toplevel WebSocketConnectionClosedException, ) - try: + # Network reads must not hold the lock needed by send_event() or close(). + with self._recv_lock: + with self._lock: + ws, generation = self._ws, self._generation + if ws is None: + self._check_unfinished() + if self._closed: + return None + raise RuntimeError("realtime session is not open") + failure = None try: - raw = ws.recv() - except WebSocketConnectionClosedException: - raw = None - if raw is None or raw == "" or raw == b"": - self.close() - self._check_unfinished() - return None - if isinstance(raw, (bytes, bytearray)): - raw = raw.decode("utf-8") - event = json.loads(raw) - if not isinstance(event, dict): - raise ValueError("expected a JSON event object") - self.last_message = event - self._track_event(event) - if event.get("type") == "session.finished": - self._finished = True - self.close() - except BaseException: - self.close() - raise - if event.get("type") == "error": - error = event.get("error") or {} - if ( - error.get("type") != "invalid_request_error" - or self._finish_requested - ): - self.close() - raise error_from_business_code( - message=error.get("message") or "realtime error", - code=error.get("code"), - body=event, - ) - return event + try: + raw = ws.recv() + except WebSocketConnectionClosedException: + raw = None + with self._lock: + if generation != self._generation: + return None + if ws is not self._ws: + self._check_unfinished() + return None + if raw is None or raw == "" or raw == b"": + self.close() + self._check_unfinished() + return None + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + event = json.loads(raw) + if not isinstance(event, dict): + raise ValueError("expected a JSON event object") + self.last_message = event + self._track_event(event) + if event.get("type") == "session.finished": + self._finished = True + self.close() + self._check_unfinished() + elif event.get("type") == "error": + error = event.get("error") + if error is None: + error = {} + if not isinstance(error, dict): + raise ValueError("expected a JSON error object") + code = error.get("code") + if code is not None and not isinstance(code, str): + raise ValueError("expected a string error code") + failure = error_from_business_code( + message=error.get("message") or "realtime error", + code=code, + body=event, + ) + recoverable = ( + error.get("type") == "invalid_request_error" + ) + if recoverable: + # The nested event_id identifies the rejected request. + request_id = error.get("event_id") + if ( + isinstance(request_id, str) + and request_id in self._pending_response_ids + ): + self._pending_response_ids.remove(request_id) + if not recoverable or self._finish_requested: + self.close() + except BaseException: + with self._lock: + if ws is self._ws and generation == self._generation: + self.close() + raise + # Malformed errors require cleanup; parsed request errors may recover. + if failure is not None: + raise failure + return event def _check_unfinished(self) -> None: - if self._finish_requested and not self._finished: + # Omni 3.8 documents session.finish without a session.finished ACK. + if ( + self._finish_requested + and not self._finished + and self._protocol != "omni_v38" + ): raise APIConnectionError( "realtime connection closed before session.finished", ) - if self._response_active and not self._finished: + # Unlike legacy protocols, Omni 3.8's optional ACK cannot finish a response. + if (self._response_active or self._pending_response_ids) and ( + not self._finished or self._protocol == "omni_v38" + ): raise APIConnectionError( "realtime connection closed before response.done", ) @@ -716,6 +766,9 @@ def _track_event(self, event: Dict[str, Any]) -> None: self.last_first_audio_delay = None self.last_first_text_delay = None self._response_active = True + # response.created has no client ID; acknowledge in wire order. + if self._pending_response_ids: + self._pending_response_ids.pop(0) elif kind == "response.done": response_id = (event.get("response") or {}).get( "id", diff --git a/dashscope/v2/resources/audio/translation.py b/dashscope/v2/resources/audio/translation.py deleted file mode 100644 index 3d097a3..0000000 --- a/dashscope/v2/resources/audio/translation.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""client.audio.translation resource (Gummy realtime speech translation). - -Same duplex wire as ``asr.recognize`` (task_group=audio, task=asr, -function=recognition) — the 1.x ``TranslationRecognizerRealtime`` and -``TranslationRecognizerChat`` pair rides it too -(translation_recognizer.py:451/987). This resource names the translation -parameters and adds the bilingual typed view; the chat models -(gummy-chat-v1) share the wire and use the same entry — 1.x Chat only -differs by dropping audio after the first sentence end, which callers -express through ``TranslationResult.is_sentence_end()``. - -The newer qwen3-livetranslate realtime models are a different protocol -and live under ``client.audio.realtime.connect`` instead. -""" - -from __future__ import annotations - -import os -from typing import Any, Dict, Iterator, List, Mapping, Optional - -from dashscope.v2.resources.audio.asr import ( - Recognition, - RecognitionResult, - _stream_file_frames, -) -from dashscope.v2.transport.ws import EVENT_GENERATED, Connector -from dashscope.v2.transport.ws_connection import WsConnection - -__all__ = [ - "Translation", - "TranslationResult", - "TranslationSession", -] - - -class TranslationResult(RecognitionResult): - """Typed view over one bilingual ``result-generated`` payload. - - Gummy frames carry ``output.transcription`` (source-language - sentence) and/or ``output.translations`` (one dict per target - language, ``lang``/``text``/``words``/...), unlike the plain ASR - ``output.sentence`` shape ``RecognitionResult.get_sentence`` reads. - """ - - def get_transcription(self) -> Optional[Dict[str, Any]]: - """The source-language sentence dict, or None on this frame.""" - transcription = self.output.get("transcription") - return transcription if isinstance(transcription, dict) else None - - def get_translations(self) -> List[Dict[str, Any]]: - """Per-target-language sentence dicts (possibly empty).""" - translations = self.output.get("translations") - return translations if isinstance(translations, list) else [] - - def is_sentence_end(self) -> bool: - """True when transcription or any translation ends its sentence.""" - transcription = self.get_transcription() - if transcription and transcription.get("sentence_end"): - return True - return any( - item.get("sentence_end") for item in self.get_translations() - ) - - -def _results_from_events( - events: Iterator[Dict[str, Any]], -) -> Iterator[TranslationResult]: - for event in events: - if event["header"].get("event") != EVENT_GENERATED: - continue - data = dict(event.get("payload") or {}) - data.setdefault("request_id", event["header"].get("request_id")) - result = TranslationResult.model_validate(data) - transcription = result.get_transcription() - if transcription and transcription.get("heartbeat"): - continue - yield result - - -class TranslationSession(Recognition): - """Duplex translation session; lifecycle identical to ``Recognition``.""" - - def __init__( - self, - client, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - # pylint: disable-next=redefined-builtin - input: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - connection: Optional[WsConnection] = None, - timeout: Optional[float] = None, - headers: Optional[Mapping[str, str]] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> None: - merged = dict(parameters or {}) - # Named kwargs win over the ``parameters=`` escape hatch; the keys - # match the 1.x TranslationRecognizerRealtime wire names. Omitted - # parameters fall to the service defaults (transcription on, - # translation off, source language auto). - for key, value in ( - ("source_language", source_language), - ("transcription_enabled", transcription_enabled), - ("translation_enabled", translation_enabled), - ("translation_target_languages", translation_target_languages), - ("max_end_silence", max_end_silence), - ): - if value is not None: - merged[key] = value - super().__init__( - client, - model=model, - format=format, - sample_rate=sample_rate, - parameters=merged, - input=input, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - connection=connection, - timeout=timeout, - headers=headers, - api_key=api_key, - workspace=workspace, - ) - - def results(self) -> Iterator[TranslationResult]: - """Yield typed bilingual results until the task finishes. - - Non-result events (task-finished, ...) are skipped. Frames whose - ``transcription`` is only a heartbeat are dropped too — a v2 - convenience so callers see real sentences; unlike the plain-ASR - path, the 1.x translation recognizer forwards such frames to - ``on_event`` rather than filtering them. - """ - return _results_from_events(self.events()) - - -class Translation: - """``client.audio.translation``: realtime speech translation (Gummy).""" - - def __init__(self, client) -> None: - self._client = client - - def recognize( - self, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - # pylint: disable-next=redefined-builtin - input: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - connection: Optional[WsConnection] = None, - timeout: Optional[float] = None, - headers: Optional[Mapping[str, str]] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> TranslationSession: - """Open translation; send audio and consume events concurrently.""" - return TranslationSession( - self._client, - model=model, - format=format, - sample_rate=sample_rate, - source_language=source_language, - transcription_enabled=transcription_enabled, - translation_enabled=translation_enabled, - translation_target_languages=translation_target_languages, - max_end_silence=max_end_silence, - parameters=parameters, - input=input, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - connection=connection, - timeout=timeout, - headers=headers, - api_key=api_key, - workspace=workspace, - ) - - def recognize_file( - self, - path: str, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - timeout: Optional[float] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> Iterator[TranslationResult]: - """Translate a local audio file, yielding typed bilingual results. - - The 1.x ``TranslationRecognizerRealtime.call(file)`` equivalent - (its ``TranslationRecognizerResultPack`` items arrive one - ``TranslationResult`` at a time): the file is auto-chunked and - driven to completion in one session. Unlike - ``asr.recognize_file`` this yields typed results — the bilingual - nesting is the point of this facade. Validation happens eagerly; - the session itself opens on first iteration. - """ - if not os.path.isfile(path): - raise ValueError(f"audio file not found: {path}") - if os.path.getsize(path) == 0: - raise ValueError(f"audio file is empty: {path}") - session = self.recognize( - model=model, - format=format, - sample_rate=sample_rate, - source_language=source_language, - transcription_enabled=transcription_enabled, - translation_enabled=translation_enabled, - translation_target_languages=translation_target_languages, - max_end_silence=max_end_silence, - parameters=parameters, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - timeout=timeout, - api_key=api_key, - workspace=workspace, - ) - return _results_from_events(_stream_file_frames(session, path)) diff --git a/dashscope/v2/resources/audio/tts.py b/dashscope/v2/resources/audio/tts.py index c2f03bb..cf8c532 100644 --- a/dashscope/v2/resources/audio/tts.py +++ b/dashscope/v2/resources/audio/tts.py @@ -170,7 +170,7 @@ def start(self) -> "SynthesisSession": input={}, ) self._record_response(response) - except Exception as error: + except BaseException as error: self._record_response(getattr(error, "body", None)) self.close() raise @@ -229,11 +229,9 @@ def _record_response(self, response: Any) -> None: def events(self) -> Generator[Union[Dict[str, Any], bytes], None, None]: """Yield JSON and audio through task-finished, raising on failure.""" session = self.session - # The lock guards the iterator's whole lifetime and is released in - # its finally block, so a ``with`` scope cannot express it. - if not self._reader_lock.acquire( # pylint: disable=consider-using-with - blocking=False, - ): + # Non-blocking acquire, released in the finally below — no ``with``. + # pylint: disable=consider-using-with + if not self._reader_lock.acquire(blocking=False): raise RuntimeError( "only one events() or audio_chunks() iterator may read " "the synthesis session", diff --git a/dashscope/v2/transport/http.py b/dashscope/v2/transport/http.py index 8dbcb6d..ffc58c4 100644 --- a/dashscope/v2/transport/http.py +++ b/dashscope/v2/transport/http.py @@ -102,9 +102,10 @@ def _merge_auth_headers( """ if api_key is None and workspace is None: return headers - merged = dict(headers or {}) - merged.update(_build_headers(api_key, workspace, None)) - return merged + return _with_sdk_headers( + headers or {}, + _build_headers(api_key, workspace, None), + ) def _abs_url(base_url: Optional[str], path: str) -> str: @@ -276,9 +277,11 @@ def open_stream( except httpx.TransportError as exc: raise _map_transport_error(exc) from exc if response.status_code >= 400: - response.read() - response.close() - check_response(response) + try: + response.read() + check_response(response) + finally: + response.close() logger.debug( "stream %s %s -> %d (request_id=%s)", method, @@ -436,9 +439,11 @@ async def open_stream( except httpx.TransportError as exc: raise _map_transport_error(exc) from exc if response.status_code >= 400: - await response.aread() - await response.aclose() - check_response(response) + try: + await response.aread() + check_response(response) + finally: + await response.aclose() logger.debug( "stream %s %s -> %d (request_id=%s)", method, diff --git a/dashscope/v2/transport/ws.py b/dashscope/v2/transport/ws.py index 4b311d5..71b2c9f 100644 --- a/dashscope/v2/transport/ws.py +++ b/dashscope/v2/transport/ws.py @@ -76,14 +76,21 @@ def _assemble_headers( headers: Optional[Mapping[str, str]], module: Optional[str], ) -> Dict[str, str]: - all_headers = dict(headers or {}) + auth = {} if api_key: - all_headers["Authorization"] = f"Bearer {api_key}" + auth["Authorization"] = f"Bearer {api_key}" if workspace: - all_headers["X-DashScope-WorkSpace"] = workspace - all_headers.setdefault("user-agent", default_user_agent()) + auth["X-DashScope-WorkSpace"] = workspace + auth_keys = {key.lower() for key in auth} + all_headers = { + key: value + for key, value in (headers or {}).items() + if key.lower() not in auth_keys + } + all_headers.update(auth) provided = {key.lower() for key in all_headers} - for key, value in sdk_headers(module).items(): + defaults = {"user-agent": default_user_agent(), **sdk_headers(module)} + for key, value in defaults.items(): if key.lower() not in provided: all_headers[key] = value return all_headers @@ -140,6 +147,12 @@ def _process_event(self, event: Dict[str, Any]) -> Dict[str, Any]: ): raise WsEventError("expected a websocket event header") header = event["header"] + if header.get("task_id") not in (None, self.task_id): + raise WsEventError( + "websocket event belongs to a different task", + request_id=self._request_id, + body=event, + ) self._request_id = ( header.get("request_id") or header.get("task_id") diff --git a/dashscope/v2/transport/ws_connection.py b/dashscope/v2/transport/ws_connection.py index 6204059..450873d 100644 --- a/dashscope/v2/transport/ws_connection.py +++ b/dashscope/v2/transport/ws_connection.py @@ -23,10 +23,9 @@ opening a dedicated connection per session. """ -# pylint: disable=redefined-builtin -# WsTask and WsConnection are one protocol split across two classes; the -# cross-class private access is intra-module by design. -# pylint: disable=protected-access +# WsConnection and WsTask deliberately share the owning client's +# internals (send/claim/detach) — they are one transport layer. +# pylint: disable=redefined-builtin,protected-access from __future__ import annotations @@ -43,6 +42,8 @@ ) from dashscope.v2.errors import APIConnectionError from dashscope.v2.transport.ws import ( + EVENT_FAILED, + EVENT_FINISHED, EVENT_STARTED, Connector, WsEventError, @@ -86,6 +87,10 @@ def __init__( self._cancel_sent = False self._finished = False self._closed = False + # Reader-side routing uses the connection lock, not the send lock. + self._routing_active = False + self._routing_finished = False + self._error: Optional[BaseException] = None @property def task_id(self) -> str: @@ -100,7 +105,10 @@ def finished(self) -> bool: return self._finished def _enqueue(self, item: _QueueItem) -> None: - if not self._closed: + """Called with the connection lock held; the first error ends input.""" + if not self._closed and self._error is None: + if item[0] == _ERROR: + self._error = item[1] self._queue.put(item) def _frame(self, action: str, payload: Dict[str, Any]) -> Dict[str, Any]: @@ -212,11 +220,9 @@ def recv_event(self) -> Dict[str, Any]: return frame def recv(self) -> Union[Dict[str, Any], bytes, None]: - """Read the next demuxed frame for this task.""" + """Read queued frames before surfacing a persistent close/error.""" if self._finished: return None - if self._closed: - raise RuntimeError("websocket task is closed") try: kind, payload = self._queue.get(timeout=self._timeout) except queue.Empty: @@ -224,20 +230,16 @@ def recv(self) -> Union[Dict[str, Any], bytes, None]: "timed out waiting for a websocket event", ) from None if kind == _ERROR: + # Requeue the terminal marker so subsequent readers also fail. + self._queue.put((kind, payload)) raise payload if isinstance(payload, (bytes, bytearray)): return bytes(payload) - try: - return self._process_event(payload) - finally: - if self._finished: - self._conn._release_binary(self) + return self._process_event(payload) def close(self) -> None: - """Detach from the connection; the shared socket stays open.""" - if not self._closed: - self._closed = True - self._conn._detach(self) + """Detach and wake receivers; the shared socket stays open.""" + self._conn._detach(self) class WsConnection: @@ -284,15 +286,19 @@ def __init__( # are enforced on the demux queues, so the socket itself must # block forever -- a socket read timeout would kill the reader # during any quiet period longer than the timeout. - settimeout = getattr(self._ws, "settimeout", None) - if callable(settimeout): - settimeout(None) - self._reader = threading.Thread( - target=self._reader_main, - name="dashscope-ws-reader", - daemon=True, - ) - self._reader.start() + try: + settimeout = getattr(self._ws, "settimeout", None) + if callable(settimeout): + settimeout(None) + self._reader = threading.Thread( + target=self._reader_main, + name="dashscope-ws-reader", + daemon=True, + ) + self._reader.start() + except BaseException: + self.close() + raise @property def closed(self) -> bool: @@ -325,8 +331,21 @@ def _send(self, data: Any, *, opcode: int = 1) -> None: self.close() raise + def _check_binary_owner(self, task: WsTask) -> None: + """Validate under the connection lock; pre-start claims are allowed.""" + if self._closed: + raise RuntimeError("websocket connection is closed") + if ( + self._tasks.get(task.task_id) is not task + or task._closed + or task._finished + or task._routing_finished + ): + raise RuntimeError("websocket task is not active") + def _claim_binary_uplink(self, task: WsTask) -> None: with self._lock: + self._check_binary_owner(task) owner = self._binary_uplink_owner if owner is not None and owner is not task: raise RuntimeError( @@ -336,6 +355,7 @@ def _claim_binary_uplink(self, task: WsTask) -> None: def _claim_binary_downlink(self, task: WsTask) -> None: with self._lock: + self._check_binary_owner(task) owner = self._binary_downlink_owner if owner is not None and owner is not task: raise RuntimeError( @@ -344,16 +364,20 @@ def _claim_binary_downlink(self, task: WsTask) -> None: self._binary_downlink_owner = task def _release_binary(self, task: WsTask) -> None: - with self._lock: - if self._binary_uplink_owner is task: - self._binary_uplink_owner = None - if self._binary_downlink_owner is task: - self._binary_downlink_owner = None + """Release both directions with the connection lock already held.""" + if self._binary_uplink_owner is task: + self._binary_uplink_owner = None + if self._binary_downlink_owner is task: + self._binary_downlink_owner = None def _detach(self, task: WsTask) -> None: with self._lock: - self._tasks.pop(task.task_id, None) - self._release_binary(task) + task._enqueue((_ERROR, RuntimeError("websocket task is closed"))) + task._closed = True + task._routing_active = False + if self._tasks.get(task.task_id) is task: + self._tasks.pop(task.task_id) + self._release_binary(task) def _reader_main(self) -> None: try: @@ -372,37 +396,47 @@ def _reader_main(self) -> None: def _route_event(self, event: Any) -> None: header = event.get("header") if isinstance(event, dict) else None - task_id = header.get("task_id") if isinstance(header, dict) else None + if not isinstance(header, dict): + raise WsEventError("expected a websocket event header") + task_id = header.get("task_id") + if not isinstance(task_id, str) or not task_id: + raise WsEventError("expected a non-empty websocket task_id") with self._lock: task = self._tasks.get(task_id) - if task is None: - # Late event for a detached task; the task is gone, so drop it. - return - task._enqueue((_FRAME, event)) + if self._closed or task is None or task._routing_finished: + # Late events for detached or terminal tasks are harmless. + return + name = header.get("event") + if name == EVENT_STARTED: + task._routing_active = True + elif name in (EVENT_FINISHED, EVENT_FAILED): + task._routing_active = False + task._routing_finished = True + self._release_binary(task) + task._enqueue((_FRAME, event)) def _route_binary(self, data: bytes) -> None: with self._lock: + if self._closed: + return owner = self._binary_downlink_owner if owner is None: active = [ task for task in self._tasks.values() - if task._started - and not task._finished - and not task._closed + if task._routing_active ] - if len(active) == 1: - owner = active[0] - self._binary_downlink_owner = owner - if owner is None: - if not active: - # Late or garbage binary with no task to receive it. - return - raise WsEventError( - "binary frame with multiple active tasks; claim the " - "downlink with claim_binary_downlink()", - ) - owner._enqueue((_FRAME, data)) + if not active: + # Late or garbage binary with no task to receive it. + return + if len(active) != 1: + raise WsEventError( + "binary frame with multiple active tasks; claim the " + "downlink with claim_binary_downlink()", + ) + owner = active[0] + self._binary_downlink_owner = owner + owner._enqueue((_FRAME, data)) def _fail(self, exc: BaseException) -> None: with self._lock: @@ -411,24 +445,24 @@ def _fail(self, exc: BaseException) -> None: # just the shutdown echo. return self._closed = True + self._binary_uplink_owner = None + self._binary_downlink_owner = None _close_websocket(self._ws) self._broadcast(exc) def _broadcast(self, exc: BaseException) -> None: with self._lock: - tasks = list(self._tasks.values()) - for task in tasks: - task._enqueue((_ERROR, exc)) + for task in self._tasks.values(): + task._routing_active = False + task._enqueue((_ERROR, exc)) def close(self) -> None: - with self._lock: - if self._closed: - return - self._closed = True - _close_websocket(self._ws) - self._broadcast(APIConnectionError("websocket connection closed")) + self._fail(APIConnectionError("websocket connection closed")) + # Initialization may fail before the reader is created or started. + reader = getattr(self, "_reader", None) if ( - self._reader.is_alive() - and threading.current_thread() is not self._reader + reader is not None + and reader.is_alive() + and threading.current_thread() is not reader ): - self._reader.join(timeout=2) + reader.join(timeout=2) diff --git a/dashscope/v2/types/common.py b/dashscope/v2/types/common.py index 2707125..5f15904 100644 --- a/dashscope/v2/types/common.py +++ b/dashscope/v2/types/common.py @@ -6,13 +6,17 @@ from typing import Any, Dict, Optional, Protocol, TypeVar +import httpx from pydantic import BaseModel, ConfigDict, Field, model_validator +from dashscope.v2.errors import error_from_business_code, error_from_status + __all__ = [ "JobStatus", "ResponseBase", "Usage", "flatten_envelope", + "response_body", "response_envelope", "with_headers", ] @@ -133,6 +137,57 @@ def flatten_envelope( return flat +def response_body( + response: httpx.Response, + *, + raise_on_error: bool = True, +) -> Dict[str, Any]: + """Opt in to strict HTTP envelopes, retaining non-JSON service failures.""" + success = 200 <= response.status_code < 300 + try: + raw = response.json() + except ValueError: + if success: + raise + raw = None + if isinstance(raw, dict): + body = raw + else: + if success: + raise ValueError("expected a JSON object response") + body = {"message": response.text} + output = body.get("output") + output = output if isinstance(output, dict) else {} + request_id = ( + body.get("request_id") + or output.get("request_id") + or response.headers.get("x-request-id") + ) + error = None + if not success: + error = error_from_status( + response.status_code, + message=response.text, + body=raw, + headers=response.headers, + ) + elif body.get("code"): + error = error_from_business_code( + message=body.get("message") or response.text, + body=raw, + headers=response.headers, + ) + if error is not None and raise_on_error: + error.request_id = request_id + raise error + body = {**body, "status_code": response.status_code} + if request_id is not None: + body["request_id"] = request_id + if error is not None: + body["is_error"] = True + return body + + def response_envelope(body: Dict[str, Any]) -> Dict[str, Any]: """Response-level fields, for payloads ``flatten_envelope`` cannot merge. diff --git a/dashscope/version.py b/dashscope/version.py index bc1bd91..31f70e7 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "2.0.0" +__version__ = "2.0.1" diff --git a/examples/01_atomic_capabilities/audio/README.md b/examples/01_atomic_capabilities/audio/README.md new file mode 100644 index 0000000..9066e21 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/README.md @@ -0,0 +1,220 @@ +# 语音接口调用示例 + +所有命令在仓库根目录执行,使用 V2 SDK:`from dashscope import Client`。 + +## 准备 + +```bash +python -m pip install -e . +# 使用麦克风时安装,并允许终端或 IDE 访问系统麦克风 +python -m pip install sounddevice + +OUT="$(mktemp -d)" +``` + +- 配置环境变量 `DASHSCOPE_API_KEY`,按需设置 `DASHSCOPE_REGION`、`DASHSCOPE_WORKSPACE`;示例不自动加载 `.env`。真实服务调用可能计费。 +- 输出不覆盖已有文件;`--output-dir` 必须是尚不存在的目录,父目录需已存在。 +- 本地 ASR WAV 使用 **16 kHz、16-bit PCM、单声道**,不自动重采样。 +- TTS 默认模型为 `qwen-audio-3.1-tts-flash`,音色为 `longanhuan_v3.1`;默认保存 WAV 后播放,添加 `--no-play` 只保存。 +- 各命令可加 `--help` 查看完整参数;均支持 `--region`、`--workspace`、`--timeout`。 + +## 直接调用版(不使用 `with`) + +`direct_call/` 提供全部 12 个调用示例的同名副本,参数和业务流程不变。将原版命令中的 `examples.01_atomic_capabilities.audio.` 替换为 `examples.01_atomic_capabilities.audio.direct_call.` 即可;原版仍保留。 + +```bash +python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket tests/data/asr_example.wav \ + --transcript "$OUT/asr-direct.txt" +python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni \ + --wav tests/data/asr_example.wav --output "$OUT/omni-direct.wav" --no-play +``` + +副本显式调用 ASR/TTS 会话的 `start()`、Realtime 会话的 `connect()`,用 `try/finally` 关闭文件、客户端和会话;先关闭会话,再 `shutdown()` 等待接收线程退出。HTTP 结果下载仍使用不携带 SDK 凭据的独立客户端。 + +## asr_file.py — HTTP 录音文件识别 + +接口:`client.audio.asr.jobs.create()` / `jobs.wait()`。默认模型:`qwen-audio-3.1-asr-flash-filetrans`。 + +将服务可下载的、你拥有或获授权使用的 HTTPS 音频地址设置为 `AUDIO_URL`,每个任务传一个 URL;不接受本地路径,也不上传文件。输出任务记录 `task.json` 和逐文件转写 JSON/TXT。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_file \ + --url "$AUDIO_URL" --output-dir "$OUT/batch" +``` + +超时或中断后,从 `task.json` 取出 `task_id` 设置为 `TASK_ID`,在同一地域和工作空间恢复,不重复提交: + +```bash +python -m examples.01_atomic_capabilities.audio.asr_file \ + --task-id "$TASK_ID" --output-dir "$OUT/batch-resumed" +``` + +## asr_http_stream.py — RESTful 流式返回识别 + +接口:`client.transport.open_stream()`,POST `/services/aigc/multimodal-generation/generation`,通过 SSE 接收文本。默认模型:`qwen-audio-3.1-asr-flash`。 + +一次提交完整音频,不是双工上传;返回累计文本,TXT 始终保存最新结果。短音频可能只返回一次文本。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_http_stream \ + --file tests/data/asr_example.wav --transcript "$OUT/asr-rest.txt" +``` + +可将 `--file` 替换为 `--url "$AUDIO_URL"`;加 `--events "$OUT/asr-rest.jsonl"` 保存原始事件。 + +## asr_websocket.py — WebSocket 双工识别 + +接口:`client.audio.asr.recognize()`。默认模型:`qwen-audio-3.1-asr-flash-streaming`。 + +读取本地 WAV,分片发送 PCM,同时接收识别结果;最终文本保存到 TXT。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_websocket tests/data/asr_example.wav \ + --transcript "$OUT/asr-wav.txt" --partials +``` + +`--partials` 显示临时结果,不写入最终 TXT。 + +## asr_microphone.py — 麦克风实时识别 + +接口:`client.audio.asr.recognize()`,模型同上。通过 `sounddevice` 采集系统麦克风,实时识别并保存录音与转写。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_microphone --list-devices +python -m examples.01_atomic_capabilities.audio.asr_microphone --seconds 5 \ + --recording "$OUT/microphone.wav" --transcript "$OUT/microphone.txt" +``` + +`--device` 指定设备;用 `--no-recording` 替换 `--recording` 可不保存录音。采集中 Ctrl+C 停止输入并等待最终结果。 + +## tts_http.py — HTTP 一次性合成 + +接口:`client.audio.tts.http.synthesize()`。一次提交文本,下载返回的音频 URL,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_http \ + --text "你好,这是 HTTP 一次性合成。" --output "$OUT/tts-http.wav" +``` + +## tts_http_stream.py — HTTP/SSE 流式合成 + +接口:`client.audio.tts.http.stream()`。一次提交完整文本,流式接收音频分片,保存为一个 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_http_stream \ + --text "你好,这是 HTTP 流式合成。" --output "$OUT/tts-http-stream.wav" +``` + +两个 HTTP TTS 示例均支持用 `--text-file article.txt` 替换 `--text`。 + +## tts_websocket.py — WebSocket 双工流式合成 + +接口:`client.audio.tts.connect()`,通过 `send_text()` 追加文本,`stop()` 结束输入;同时接收音频并保存 WAV,完成后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --text "你好,这是第一段。" --text "这是第二段。" \ + --output "$OUT/tts-websocket.wav" +``` + +`--text-file article.txt` 可按行发送;`--events` 可保存 JSONL 事件。也可朗读 LLM 流式回答,此时额外调用 `client.chat.completions.create(stream=True)`: + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --prompt "请用三句话介绍春天。" --llm-model qwen-plus \ + --output "$OUT/tts-llm.wav" +``` + +`--text`、`--text-file`、`--prompt` 三选一。 + +## voice_clone.py — 声音复刻管理 + +接口:`client.audio.voices.create()` / `get()` / `list()` / `delete()`。默认目标模型:`qwen-audio-3.0-tts-flash`。 + +将自己或获授权的参考语音 HTTPS 地址设置为 `CLONE_AUDIO_URL`;推荐 10~20 秒清晰语音。本脚本不上传音频、不播放、不自动删除音色。 + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone create \ + --url "$CLONE_AUDIO_URL" --prefix demo --output "$OUT/voice.json" +``` + +将输出的 `voice_id` 设置为 `VOICE_ID`,查询状态或列出音色: + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone get --voice-id "$VOICE_ID" +python -m examples.01_atomic_capabilities.audio.voice_clone list --prefix demo +``` + +状态为 `OK` 后,用创建时相同的模型合成: + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --model qwen-audio-3.0-tts-flash --voice "$VOICE_ID" \ + --text "你好,这是复刻音色。" --output "$OUT/cloned.wav" +``` + +仅在确定不再使用时手动删除: + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone delete --voice-id "$VOICE_ID" --yes +``` + +## realtime_omni.py — Omni 单轮语音对话 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen3.8-omni-flash-realtime`,默认音色:`longanlingxin`。 + +输入语音,通过 `commit()` / `create_response()` 请求回答,接收至 `response.done`,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_omni \ + --wav tests/data/asr_example.wav --output "$OUT/omni-reply.wav" +``` + +## realtime_qwen_audio.py — Qwen-Audio 单轮语音对话 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen-audio-3.0-realtime-plus`,默认音色:`longanqian`。 + +按 Qwen-Audio 的音频格式配置会话,输入结束后 `commit()` / `create_response()`,接收至 `response.done`,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio \ + --wav tests/data/asr_example.wav --output "$OUT/qwen-audio-reply.wav" +``` + +## realtime_translation.py — 实时语音翻译 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen3.8-livetranslate-flash-realtime`;`--language` 指定目标语种,默认 `en`(英语)。 + +边输入边翻译,打印原文与译文,保存翻译音频后播放。使用服务端默认说话人检测,不调用 `commit()` / `create_response()`;输入结束调用 `end_session()`,继续接收至 `session.finished`,不在第一条 `response.done` 提前退出。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_translation \ + --wav tests/data/asr_example.wav --language en --output "$OUT/translation.wav" +``` + +以上三个示例均可将 `--wav` 替换为 `--microphone --seconds 5`,加 `--no-play` 只保存。输入为 0.1~20 秒、16 kHz、16-bit PCM、单声道 WAV,输出为 24 kHz WAV;麦克风输入不会另存录音。各文件固定对应模型,不再使用原 `realtime.py` 的 `--model` 切换;新拆分示例尚未做云端验证。 + +## connection_reuse.py — 同一连接运行五个任务 + +接口:`client.ws_connection()`,通过 `connection=` 传给 `client.audio.asr.recognize()` 或 `client.audio.tts.connect()`。 + +ASR 使用 `qwen-audio-3.1-asr-flash-streaming`,TTS 使用 `qwen-audio-3.1-tts-flash` / `longanhuan_v3.1`。每次运行创建一条 WebSocket,顺序完成五个独立任务,输出逐轮文件与 `summary.json`;不录音、不播放,失败后不自动重连。 + +```bash +python -m examples.01_atomic_capabilities.audio.connection_reuse asr tests/data/asr_example.wav \ + --output-dir "$OUT/reuse-asr" +python -m examples.01_atomic_capabilities.audio.connection_reuse tts \ + --text "你好,这是连接复用。" --output-dir "$OUT/reuse-tts" +``` + +## tingwu.py — 听悟任务 + +接口:`client.tingwu`。听悟离线任务与实时会话示例。 + +## _common.py — 本地音频工具 + +提供 WAV 读写、麦克风采集、系统播放和参数校验,供上述示例使用;不调用云端接口,无独立命令行入口。 + +## 生产化模式 + +以上示例保持最小可运行;生产化请参考 `02_advanced_engineering/local_mock_debugging.py`(离线 mock 调试)、`02_advanced_engineering/circuit_breaker_retry.py`(退避与熔断容灾)、`04_real_world_scenarios/realtime_audio_meeting_harness.py`(实时音频会议 harness)。 diff --git a/examples/01_atomic_capabilities/audio/_common.py b/examples/01_atomic_capabilities/audio/_common.py new file mode 100644 index 0000000..4cfe41b --- /dev/null +++ b/examples/01_atomic_capabilities/audio/_common.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Local PCM capture, WAV containers, and operating-system playback.""" + +import argparse +import math +import shutil +import subprocess +import sys +import wave +from contextlib import contextmanager +from pathlib import Path + + +def positive_seconds(value): + seconds = float(value) + if not math.isfinite(seconds) or seconds <= 0: + raise argparse.ArgumentTypeError("秒数必须是大于 0 的有限数值") + return seconds + + +@contextmanager +def open_wav(path, sample_rates=(8000, 16000)): + with wave.open(str(path), "rb") as audio: + if ( + audio.getnchannels() != 1 + or audio.getsampwidth() != 2 + or audio.getcomptype() != "NONE" + or audio.getframerate() not in sample_rates + ): + raise ValueError( + f"需要单声道、16-bit PCM WAV,采样率为 {sample_rates};" + f"实际格式:{audio.getparams()}。可先使用 ffmpeg 转换。" + ) + if not audio.getnframes(): + raise ValueError("WAV 文件没有音频帧") + yield audio + + +@contextmanager +def wav_output(path, sample_rate): + path = Path(path) + if path.suffix.lower() != ".wav": + raise ValueError("PCM 输出必须使用 .wav 扩展名") + with path.open("xb") as raw: + with wave.open(raw, "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(sample_rate) + yield audio + + +def input_device(value): + return int(value) if value.isdecimal() else value + + +def load_sounddevice(): + try: + import sounddevice + except (ImportError, OSError) as error: + raise RuntimeError( + "录音需要 sounddevice 和 PortAudio;请在仓库根目录运行 " + "python -m pip install -e '.[acli-voice]'。" + "Linux 还需安装系统 PortAudio 库。" + ) from error + return sounddevice + + +@contextmanager +def microphone(sample_rate, device=None): + sd = load_sounddevice() + sd.check_input_settings( + device=device, channels=1, dtype="int16", samplerate=sample_rate, + ) + with sd.RawInputStream( + samplerate=sample_rate, + blocksize=sample_rate // 10, + device=device, + channels=1, + dtype="int16", + ) as stream: + yield stream + + +def player_command(path): + path = str(Path(path).resolve()) + if sys.platform == "darwin" and shutil.which("afplay"): + return ["afplay", path] + if sys.platform == "win32" and shutil.which("powershell"): + return [ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + "$p = New-Object System.Media.SoundPlayer; " + "$p.SoundLocation = $env:DASHSCOPE_SAMPLE_WAV; $p.PlaySync()", + ] + if shutil.which("aplay"): + return ["aplay", path] + if shutil.which("ffplay"): + return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", path] + raise RuntimeError( + "找不到 WAV 播放器(afplay / PowerShell / aplay / ffplay);" + "可使用 --no-play 仅保存文件。" + ) + + +def play_wav(path): + import os + + command = player_command(path) + environment = dict(os.environ, DASHSCOPE_SAMPLE_WAV=str(Path(path).resolve())) + process = subprocess.Popen(command, env=environment) + try: + returncode = process.wait() + if returncode: + raise subprocess.CalledProcessError(returncode, command) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/01_atomic_capabilities/audio/asr_file.py b/examples/01_atomic_capabilities/audio/asr_file.py new file mode 100644 index 0000000..ac66cc8 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_file.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""Submit or resume URL-based batch transcription using the V2 speech API.""" + +import argparse +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use an HTTPS audio URL without userinfo or fragments, not a local " + "path. Upload your own WAV to a service-reachable URL yourself; " + "this sample never uploads files." + ) + return value + + +def task_id(value): + if not value or any(char.isspace() or char in "/\\?#" for char in value): + raise argparse.ArgumentTypeError("task ID must be a nonempty ID, not a URL") + return value + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def transcript_text(document): + if (not isinstance(document, dict) + or not isinstance(document.get("transcripts"), list)): + raise ValueError("transcription JSON has no transcripts list") + texts = [] + for transcript in document["transcripts"]: + text = transcript.get("text") + if text is None: + text = "".join(sentence["text"] for sentence in transcript["sentences"]) + if not isinstance(text, str): + raise ValueError("transcript text must be a string") + texts.append(text) + return "\n".join(texts) + + +def download_results(job, args, metadata, output): + results = (job.output or {}).get("results") + if not isinstance(results, list) or not results: + raise RuntimeError( + "Task returned no per-file results; see task.json for its status" + ) + failures = 0 + # Never reuse the SDK transport: signed download URLs need no API key. + with httpx.Client(timeout=args.timeout, follow_redirects=True) as downloader: + for index, result in enumerate(results, 1): + record = {"index": index} + try: + if not isinstance(result, dict): + raise ValueError("invalid per-file result") + record["subtask_status"] = result.get("subtask_status") + if result.get("subtask_status") != "SUCCEEDED": + raise RuntimeError("subtask did not succeed") + url = https_url(result.get("transcription_url") or "") + response = downloader.get(url) + response.raise_for_status() + document = response.json() + json_path = args.output_dir / f"{index:03d}.json" + with json_path.open("x", encoding="utf-8") as artifact: + json.dump(document, artifact, ensure_ascii=False, indent=2) + artifact.write("\n") + record["json"] = json_path.name + text = transcript_text(document) + text_path = args.output_dir / f"{index:03d}.txt" + with text_path.open("x", encoding="utf-8") as artifact: + artifact.write(text + "\n") + record["text"] = text_path.name + print(f"File {index}: saved {json_path.name} and {text_path.name}", + flush=True) + except (httpx.HTTPError, OSError, ValueError, RuntimeError, + argparse.ArgumentTypeError, KeyError, TypeError, + AttributeError) as error: + failures += 1 + # Exception messages can contain presigned URLs; keep them out of logs. + record["error"] = type(error).__name__ + if isinstance(error, httpx.HTTPStatusError): + record["http_status"] = error.response.status_code + print(f"File {index}: FAILED ({record})", file=sys.stderr, flush=True) + metadata["files"].append(record) + checkpoint(output, metadata) + return failures + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP recorded-file ASR: submit a URL, poll, download transcripts. " + "Default: qwen-audio-3.1-asr-flash-filetrans; no WebSocket or SSE.", + epilog=( + "Upload your own WAV to an HTTPS URL reachable by the service first; " + "local paths are NOT file_urls. This sample never uploads audio.\n" + "Submit: python -m examples.01_atomic_capabilities.audio.asr_file --url \"$AUDIO_URL\" " + "--output-dir batch-new\n" + "Resume without a new paid submission: python -m examples.01_atomic_capabilities.audio.asr_file " + "--task-id TASK_ID --output-dir batch-resumed\n" + "Use your configured DASHSCOPE_API_KEY and the same region/workspace " + "when resuming. Real service usage may incur charges." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--url", action="append", type=https_url, + help="HTTPS audio URL; repeat for multiple files") + source.add_argument("--task-id", type=task_id, help="resume an existing task") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash-filetrans", + choices=("qwen-audio-3.1-asr-flash-filetrans", + "fun-asr", "paraformer-v2"), + help="submission model (ignored when resuming)") + parser.add_argument("--output-dir", type=Path, required=True, + help="new directory in an existing parent; never overwrite") + parser.add_argument("--wait-timeout", type=positive_seconds, default=600.0, + help="maximum polling seconds (default: 600)") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="SDK/download request timeout seconds (default: 30)") + parser.add_argument("--region", help="override DASHSCOPE_REGION") + parser.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if (args.url and args.model == "qwen-audio-3.1-asr-flash-filetrans" + and len(args.url) != 1): + parser.error("qwen-audio-3.1-asr-flash-filetrans requires exactly one --url") + args.output_dir = args.output_dir.resolve() + args.output_dir.mkdir() # Reserve the destination before any paid request. + task_path = args.output_dir / "task.json" + with task_path.open("x", encoding="utf-8") as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, raise_on_error=False, + ) as client: + metadata = { + "model": None if args.task_id else args.model, + "region": client.config.region, + "workspace": client.config.workspace, + "files": [], + } + if args.task_id: + job = args.task_id + metadata.update(task_id=args.task_id, status="RESUMING") + else: + print("Submitting a paid batch task; input URLs will not be logged.", + file=sys.stderr, flush=True) + job = client.audio.asr.jobs.create( + model=args.model, file_urls=args.url, parameters={}, + ) + if not job.task_id: + raise RuntimeError( + f"No task ID returned (request_id={job.request_id}); " + "check the service before submitting again" + ) + metadata.update(task_id=job.task_id, status=job.status) + print(f"task_id: {metadata['task_id']}", flush=True) + checkpoint(output, metadata) + print("Saved task.json; resume with --task-id and a NEW --output-dir " + "in the same region/workspace.", flush=True) + job = client.audio.asr.jobs.wait(job, timeout=args.wait_timeout) + metadata.update(status=job.status, request_id=job.request_id, code=job.code) + checkpoint(output, metadata) + failed = job.status != "SUCCEEDED" or job.is_error + print(f"Task status: {job.status}", flush=True) + # Even a failed batch may contain usable completed files. + failures = download_results(job, args, metadata, output) + if failed or failures: + print(f"Batch unsuccessful; {failures} per-file failure(s). " + "Completed outputs are preserved.", file=sys.stderr) + return 1 + print(f"Saved all transcripts in {args.output_dir}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted; keep task.json and resume, rather than resubmit.", + file=sys.stderr) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + print(f"Request failed ({type(error).__name__}); keep task.json to resume. " + "If submission returned no ID, check the service before retrying.", + file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print(f"Batch failed: {error}. Existing outputs are preserved.", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/asr_http_stream.py b/examples/01_atomic_capabilities/audio/asr_http_stream.py new file mode 100644 index 0000000..f625ebf --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_http_stream.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""REST ASR: submit complete WAV audio once and receive cumulative text via SSE.""" + +import argparse +import base64 +import json +import sys +from contextlib import closing +from pathlib import Path + +from httpx_sse import EventSource + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import open_wav, positive_seconds +from .asr_file import https_url + + +PATH = "/services/aigc/multimodal-generation/generation" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="RESTful ASR:一次提交完整 WAV,以 HTTP/SSE 接收累计转写。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.asr_http_stream " + "--file tests/data/asr_example.wav --transcript rest.txt\n" + "本地 WAV 以 Base64 随请求发送,或使用 --url 传服务可访问的 HTTPS 地址。" + "这不是实时音频输入;官方仅对至少 60 秒的音频分批返回。真实服务可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--file", type=Path, help="本地单声道 16-bit PCM WAV") + source.add_argument("--url", type=https_url, help="服务可下载的 HTTPS WAV URL") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash", + choices=("qwen-audio-3.1-asr-flash", + "qwen-audio-3.0-asr-flash")) + parser.add_argument("--sample-rate", type=int, default=16000, choices=(16000,), + help="此示例只发送 16 kHz WAV,不自动重采样") + parser.add_argument("--transcript", type=Path, required=True, + help="新的 UTF-8 TXT;每个累计结果覆盖本次文件内容") + parser.add_argument("--events", type=Path, help="可选:保存服务端原始 JSONL 事件") + parser.add_argument("--timeout", type=positive_seconds, default=120.0, + help="网络超时秒数;长音频首个 SSE 事件可能较慢,默认 120") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def receive_transcript(response, transcript, events=None): + text = "" + count = 0 + request_id = response.headers.get("x-request-id") + usage = None + for event in EventSource(response).iter_sse(): + if not event.data: + continue + body = json.loads(event.data) + if not isinstance(body, dict): + raise ValueError("ASR SSE 事件必须为 JSON 对象") + request_id = body.get("request_id") or request_id + if event.event == "error" or body.get("code"): + raise DashScopeError("ASR SSE 返回业务错误", request_id=request_id, + code=body.get("code")) + if events is not None: + events.write(json.dumps(body, ensure_ascii=False) + "\n") + events.flush() + if body.get("usage") is not None: + usage = body["usage"] + output = body.get("output") + if not isinstance(output, dict) or "text" not in output: + continue + text = output["text"] + if not isinstance(text, str): + raise ValueError("ASR output.text 必须为字符串") + count += 1 + # output.text is cumulative and may revise an earlier partial result. + transcript.seek(0) + transcript.write(text) + transcript.truncate() + transcript.flush() + print(f"[累计结果 {count}] {text}", flush=True) + if not count: + raise RuntimeError("HTTP 流结束,但没有收到 output.text") + print(json.dumps({ + "request_id": request_id, "text_events": count, "usage": usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + return text + + +def main(argv=None): + args = make_parser().parse_args(argv) + audio_data = args.url + if args.file is not None: + with open_wav(args.file, sample_rates=(args.sample_rate,)) as audio: + pcm = audio.readframes(audio.getnframes()) + if len(pcm) != audio.getnframes() * 2 or audio.readframes(1): + raise ValueError("WAV 的 PCM 数据长度与文件头不一致") + audio_data = "data:audio/wav;base64," + base64.b64encode( + args.file.read_bytes(), + ).decode("ascii") + body = { + "model": args.model, + "input": {"messages": [{ + "role": "user", + "content": [{"type": "input_audio", + "input_audio": {"data": audio_data}}], + }]}, + "parameters": {"format": "wav", "sample_rate": str(args.sample_rate)}, + } + with args.transcript.open("x+", encoding="utf-8") as transcript: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + with Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) as client: + # Generic chat normalization would change this ASR-specific schema. + with closing(client.transport.open_stream( + "POST", PATH, json=body, headers={"X-DashScope-SSE": "enable"}, + timeout=args.timeout, sdk_module="audio", + )) as response: + receive_transcript(response, transcript, events) + finally: + if events is not None: + events.close() + print(f"HTTP 流已结束,最后累计文本已保存到 {args.transcript.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消;输出可能是未完成的累计结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"REST ASR 失败({type(error).__name__}," + f"request_id={getattr(error, 'request_id', None)});" + "保留已有结果,请检查输入、网络、模型和地域。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/asr_microphone.py b/examples/01_atomic_capabilities/audio/asr_microphone.py new file mode 100644 index 0000000..6e95d77 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_microphone.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Capture a system microphone and stream PCM to V2 ASR in the main thread.""" + +import argparse +import math +import signal +import sys +import time +from pathlib import Path + +from ._common import ( + input_device, + load_sounddevice, + microphone, + positive_seconds, + wav_output, +) +from .asr_websocket import ( + SampleError, + add_recognition_arguments, + check_receiver, + run_recognition, +) + + +def send_microphone(stream, recording, args, frame_count, session, receiver): + interrupted = False + + def request_stop(_signum, _frame): + nonlocal interrupted + interrupted = True + + # Raising during an SDK send would close the socket before results can drain. + previous_handler = signal.signal(signal.SIGINT, request_stop) + try: + stream.start() + deadline = time.monotonic() + args.seconds + print( + f"Recording up to {args.seconds:g}s; Ctrl+C stops capture and drains ASR.", + file=sys.stderr, flush=True, + ) + remaining = frame_count + while remaining and not interrupted and time.monotonic() < deadline: + check_receiver(receiver) + count = min(args.sample_rate // 10, remaining) + data, overflowed = stream.read(count) + check_receiver(receiver) + if overflowed: + raise SampleError( + "Microphone input overflowed; audio was lost. " + "Reduce system load or check the network before retrying." + ) + pcm = bytes(data) + if len(pcm) != count * 2: + raise SampleError("Microphone returned an incomplete PCM frame.") + if recording is not None: + recording.writeframesraw(pcm) + session.send_audio_frame(pcm) + remaining -= count + finally: + try: + stream.stop() + finally: + signal.signal(signal.SIGINT, previous_handler) + if interrupted: + print("Capture interrupted; draining final ASR results...", file=sys.stderr) + + +def recognize_microphone(args, transcript, recording, frame_count): + with microphone(args.sample_rate, device=args.device) as stream: + # Preflight input before connecting, then avoid buffering during setup. + stream.stop() + + def send_audio(session, receiver): + send_microphone( + stream, recording, args, frame_count, session, receiver, + ) + + run_recognition(args, args.sample_rate, transcript, send_audio) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Recognize system-microphone audio with Qwen-Audio 3.1 streaming ASR " + "by default. Capture uses blocking 100 ms mono int16 reads, " + "never network calls in an audio callback. " + "Uses existing SDK credentials. Ctrl+C during capture stops input, " + "then waits for final sentences. Final text goes to stdout and the " + "transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e '.[acli-voice]'\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --list-devices\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --seconds 10 " + "--transcript microphone.txt\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --device 1 --seconds 5 " + "--recording take.wav --transcript take.txt --partials\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --model fun-asr-realtime " + "--no-recording --transcript fun.txt\n\n" + "PortAudio and OS microphone permission are required for recording. " + "Linux may need a system PortAudio package.\n" + "--list-devices is offline: no Client, recording, or output files.\n" + "Existing outputs are never replaced; failed runs may leave partial " + "files. No resampling is performed." + ), + ) + add_recognition_arguments(parser) + parser.add_argument( + "--seconds", type=positive_seconds, default=10.0, + help="maximum capture duration in seconds (default: 10)", + ) + parser.add_argument( + "--sample-rate", type=int, choices=(8000, 16000), default=16000, + help="microphone PCM sample rate (default: 16000); " + "8000 is only enabled for Paraformer in this example", + ) + parser.add_argument( + "--device", type=input_device, + help="sounddevice input index or name substring (default: system input)", + ) + parser.add_argument( + "--list-devices", action="store_true", + help="list local audio devices and exit without connecting or recording", + ) + outputs = parser.add_mutually_exclusive_group() + outputs.add_argument( + "--recording", type=Path, default=Path("recording.wav"), + help="new WAV file for captured PCM (default: recording.wav)", + ) + outputs.add_argument( + "--no-recording", dest="recording", action="store_const", const=None, + help="do not save the captured audio", + ) + args = parser.parse_args(argv) + try: + if args.list_devices: + print(load_sounddevice().query_devices()) + return 0 + if args.model != "paraformer-realtime-v2" and args.sample_rate != 16000: + raise SampleError( + f"This example uses --sample-rate 16000 for {args.model}." + ) + frame_count = max(1, math.ceil(args.seconds * args.sample_rate)) + load_sounddevice() + with args.transcript.open("x", encoding="utf-8") as transcript: + if args.recording is not None: + with wav_output(args.recording, args.sample_rate) as recording: + recognize_microphone(args, transcript, recording, frame_count) + else: + recognize_microphone(args, transcript, None, frame_count) + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/asr_websocket.py b/examples/01_atomic_capabilities/audio/asr_websocket.py new file mode 100644 index 0000000..ccd50de --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_websocket.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex ASR: send PCM from WAV while receiving sentences.""" + +import argparse +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError +from pathlib import Path + +from ._common import open_wav, positive_seconds + + +class SampleError(RuntimeError): + """A user-facing sample error with no transport or credential details.""" + + +def add_recognition_arguments(parser): + parser.add_argument( + "--model", choices=("qwen-audio-3.1-asr-flash-streaming", + "paraformer-realtime-v2", "fun-asr-realtime"), + default="qwen-audio-3.1-asr-flash-streaming", + help="ASR model (default: qwen-audio-3.1-asr-flash-streaming); " + "this example uses 16 kHz for Qwen-Audio and Fun-ASR", + ) + parser.add_argument( + "--transcript", type=Path, default=Path("transcript.txt"), + help="new UTF-8 file for final sentences only (default: transcript.txt)", + ) + parser.add_argument( + "--partials", action="store_true", + help="also display provisional text on stderr; never save it", + ) + parser.add_argument( + "--timeout", type=positive_seconds, default=30.0, + help="socket timeout and final-result wait budget in seconds (default: 30)", + ) + parser.add_argument("--region", help="DashScope region; default: SDK configuration") + parser.add_argument("--workspace", help="DashScope workspace ID") + parser.add_argument("--vocabulary-id", help="existing ASR vocabulary ID") + + +def check_receiver(receiver): + if receiver.done(): + receiver.result() + raise SampleError("ASR finished before all audio was sent.") + + +def consume_results(session, transcript, partials): + for result in session.results(): + sentence = result.get_sentence() + if not sentence or not sentence.get("text"): + continue + text = sentence["text"] + if result.is_sentence_end(): + transcript.write(text + "\n") + transcript.flush() + print(text, flush=True) + elif partials: + print(f"[partial] {text}", file=sys.stderr, flush=True) + + +def run_recognition(args, sample_rate, transcript, send_audio): + try: + from dashscope import Client + except ImportError: + raise SampleError( + "The V2 SDK is required; run python -m pip install -e . " + "from the repository root." + ) from None + + try: + with Client( + timeout=args.timeout, region=args.region, workspace=args.workspace, + ) as client: + session = client.audio.asr.recognize( + model=args.model, + format="pcm", + sample_rate=sample_rate, + vocabulary_id=args.vocabulary_id, + timeout=args.timeout, + ) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = None + try: + session.start() + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_audio(session, receiver) + check_receiver(receiver) + session.stop() + try: + receiver.result(timeout=args.timeout) + except FutureTimeoutError: + if receiver.done(): + receiver.result() + raise SampleError( + f"No task-finished within {args.timeout:g}s after stop." + ) from None + except BaseException: + if receiver is not None and receiver.done(): + receiver.result() + raise + finally: + # Close before executor shutdown so a blocked receiver can exit. + session.close() + print(json.dumps({ + "request_id": session.request_id, + "first_package_delay_ms": session.first_package_delay, + "last_package_delay_ms": session.last_package_delay, + "usage": session.usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + except SampleError: + raise + except Exception as error: + # Transport exceptions can include authorization headers or response bodies. + raise SampleError( + f"ASR failed ({type(error).__name__}). Check network, SDK credentials, " + "model/region access, audio device/output availability and --timeout. " + "Output files may be incomplete." + ) from None + + +def wav_frames(audio): + remaining = audio.getnframes() + block_frames = audio.getframerate() // 10 + while remaining: + count = min(block_frames, remaining) + pcm = audio.readframes(count) + if len(pcm) != count * 2: + raise SampleError("Truncated WAV: fewer PCM bytes than declared frames.") + yield pcm + remaining -= count + if audio.readframes(1): + raise SampleError("Invalid WAV: PCM data is not aligned to 16-bit frames.") + + +def validate_wav_data(audio): + for _ in wav_frames(audio): + pass + audio.rewind() + + +def wait_until(deadline, receiver): + check_receiver(receiver) + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + check_receiver(receiver) + + +def send_wav(audio, session, receiver): + deadline = time.monotonic() + for pcm in wav_frames(audio): + wait_until(deadline, receiver) + deadline = time.monotonic() + len(pcm) / (2 * audio.getframerate()) + session.send_audio_frame(pcm) + wait_until(deadline, receiver) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "WebSocket duplex ASR with Qwen-Audio 3.1 streaming by default. " + "Send a mono, 16-bit, 16 kHz PCM WAV while receiving text; not HTTP. " + "WAV headers are removed and audio is paced in 100 ms frames. " + "Uses existing SDK credentials. Final sentences go to stdout and " + "the transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e .\n" + " python -m examples.01_atomic_capabilities.audio.asr_websocket speech.wav " + "--transcript speech.txt\n" + " python -m examples.01_atomic_capabilities.audio.asr_websocket speech.wav " + "--model fun-asr-realtime --transcript fun.txt --partials\n\n" + "Existing output files are never replaced. Failed runs may leave " + "partial files.\n" + "This example uses 16 kHz for Qwen-Audio and Fun-ASR; " + "Paraformer also accepts 8 kHz. No resampling is performed." + ), + ) + parser.add_argument("file", type=Path, help="local uncompressed PCM WAV file") + add_recognition_arguments(parser) + args = parser.parse_args(argv) + try: + rates = (8000, 16000) if args.model == "paraformer-realtime-v2" else (16000,) + with open_wav(args.file, sample_rates=rates) as audio: + validate_wav_data(audio) + with args.transcript.open("x", encoding="utf-8") as transcript: + def send_audio(session, receiver): + send_wav(audio, session, receiver) + + run_recognition(args, audio.getframerate(), transcript, send_audio) + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except EOFError: + print("error: invalid or truncated WAV header.", file=sys.stderr) + return 1 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/connection_reuse.py b/examples/01_atomic_capabilities/audio/connection_reuse.py new file mode 100644 index 0000000..65e2bcc --- /dev/null +++ b/examples/01_atomic_capabilities/audio/connection_reuse.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +"""Run five sequential ASR or TTS tasks on one real WebSocket connection.""" + +import argparse +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from pathlib import Path +from threading import Event, Timer + +from dashscope import Client +from ._common import open_wav, positive_seconds, wav_output +from .asr_websocket import ( + check_receiver, + consume_results, + send_wav, + validate_wav_data, +) +from .tts_websocket import receive_audio + + +RUNS = 5 + + +def completed_task(session): + task_id = session.session.task_id + header = (session.last_response or {}).get("header", {}) + if header.get("event") != "task-finished" or header.get("task_id") != task_id: + raise RuntimeError("任务未收到匹配的 task-finished,停止复用连接") + return { + "task_id": task_id, + "request_id": session.session.request_id, + "final_event": header["event"], + "first_package_delay_ms": session.first_package_delay, + "usage": session.usage, + } + + +def recognize_one(client, connection, args, source, index): + source.rewind() + path = args.output_dir / f"asr-{index:02d}.txt" + with path.open("x+", encoding="utf-8") as transcript: + with client.audio.asr.recognize( + model=args.model, format="pcm", sample_rate=16000, + connection=connection, timeout=args.timeout, + ) as session: + print(f"ASR {index}/{RUNS}: task_id={session.session.task_id}", flush=True) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_wav(source, session, receiver) + check_receiver(receiver) + session.stop() + receiver.result(timeout=args.timeout) + result = completed_task(session) + result["last_package_delay_ms"] = session.last_package_delay + finally: + # Close the task before joining a possibly blocked receiver. + session.close() + transcript.seek(0) + text = transcript.read().strip() + if not text: + raise RuntimeError("没有最终转写,请使用包含清晰语音的 WAV") + result.update(transcript=path.name, text=text) + return result + + +def synthesize_one(client, connection, args, index): + path = args.output_dir / f"tts-{index:02d}.wav" + events_path = args.output_dir / f"tts-{index:02d}-events.jsonl" + text = f"第{index}次。{args.text}" + with wav_output(path, 24000) as output: + with events_path.open("x", encoding="utf-8") as events: + with client.audio.tts.connect( + model=args.model, voice=args.voice, format="pcm", sample_rate=24000, + connection=connection, timeout=args.timeout, + ) as session: + print( + f"TTS {index}/{RUNS}: task_id={session.session.task_id}", + flush=True, + ) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + receive_audio, session, output, events, + ) + session.send_text(text) + session.stop() + size = receiver.result(timeout=args.timeout) + result = completed_task(session) + finally: + # Close the task before joining a possibly blocked receiver. + session.close() + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + result.update(audio=path.name, events=events_path.name, input_text=text, + audio_bytes=size, audio_seconds=size / 48000) + return result + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="在一条真实 WebSocket 上顺序执行 5 次 ASR 或 TTS,不自动重连。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.connection_reuse asr tests/data/asr_example.wav " + "--output-dir reuse-asr\n" + "python -m examples.01_atomic_capabilities.audio.connection_reuse tts --output-dir reuse-tts\n" + "ASR 与 TTS 分别运行,各创建一条连接;不是混合并发测试。" + "使用已配置的 SDK 凭据,真实调用可能计费。不会录音或播放。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--output-dir", type=Path, required=True, + help="新的输出目录,父目录必须存在,不覆盖已有目录") + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="握手、任务接收及结束输入后等待超时秒数,默认 30") + common.add_argument("--task-timeout", type=positive_seconds, default=120.0, + help="每轮任务总预算秒数,超时关闭整条连接,默认 120") + common.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + common.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="kind", required=True) + asr = commands.add_parser("asr", parents=[common], help="同一 WAV 识别 5 次") + asr.add_argument("file", type=Path, help="16 kHz、16-bit、单声道 PCM WAV") + asr.add_argument("--model", default="qwen-audio-3.1-asr-flash-streaming", + choices=("qwen-audio-3.1-asr-flash-streaming",)) + asr.add_argument("--partials", action="store_true", help="显示临时识别文本") + tts = commands.add_parser("tts", parents=[common], help="带轮次编号的文本合成 5 次") + tts.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash",)) + tts.add_argument("--voice", default="longanhuan_v3.1", help="与 TTS 模型匹配的音色") + tts.add_argument("--text", default="你好,这是连接复用测试。", + help="每轮自动在文本前加“第N次。”") + return parser + + +def run_tasks(args, source=None): + args.output_dir.mkdir() + with (args.output_dir / "summary.json").open("x", encoding="utf-8") as output: + data = {"kind": args.kind, "model": args.model, "runs_per_connection": RUNS, + "status": "running", "connection_count": 0, "runs": []} + checkpoint(output, data) + try: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + data.update( + region=client.config.region, workspace=client.config.workspace, + ) + with closing(client.ws_connection( + timeout=args.timeout, module="audio", + )) as connection: + data["connection_count"] = 1 + checkpoint(output, data) + try: + for index in range(1, RUNS + 1): + expired = Event() + + def abort_task(): + expired.set() + connection.close() + + watchdog = Timer(args.task_timeout, abort_task) + watchdog.daemon = True + watchdog.start() + try: + if args.kind == "asr": + result = recognize_one( + client, connection, args, source, index, + ) + else: + result = synthesize_one( + client, connection, args, index, + ) + finally: + watchdog.cancel() + watchdog.join() + if expired.is_set(): + raise TimeoutError("单轮任务超过 --task-timeout") + if connection.closed: + raise RuntimeError("共享连接已断开,停止后续任务") + if any(item["task_id"] == result["task_id"] + for item in data["runs"]): + raise RuntimeError("不同轮次使用了相同 task_id") + result.update(index=index, connection_open_after_task=True) + data["runs"].append(result) + checkpoint(output, data) + print(json.dumps(result, ensure_ascii=False), flush=True) + finally: + connection.close() + data["connection_closed"] = connection.closed + data["status"] = "completed" + except BaseException as error: + data.update(status="failed", error_type=type(error).__name__) + raise + finally: + checkpoint(output, data) + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.kind == "asr": + with open_wav(args.file, (16000,)) as source: + validate_wav_data(source) + run_tasks(args, source) + else: + if not args.text.strip(): + parser.error("--text 不能为空") + run_tasks(args) + print(f"完成:1 条连接,{RUNS} 个独立任务;{args.output_dir.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消并关闭连接;保留已完成结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"复用测试失败({type(error).__name__});保留已有结果,不自动重试。", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/_common.py b/examples/01_atomic_capabilities/audio/direct_call/_common.py new file mode 100644 index 0000000..0675c4e --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/_common.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Local PCM capture, WAV containers, and operating-system playback.""" + +import argparse +import math +import shutil +import subprocess +import sys +import wave +from pathlib import Path + + +def positive_seconds(value): + seconds = float(value) + if not math.isfinite(seconds) or seconds <= 0: + raise argparse.ArgumentTypeError("秒数必须是大于 0 的有限数值") + return seconds + + +def open_wav(path, sample_rates=(8000, 16000)): + audio = wave.open(str(path), "rb") + try: + if ( + audio.getnchannels() != 1 + or audio.getsampwidth() != 2 + or audio.getcomptype() != "NONE" + or audio.getframerate() not in sample_rates + ): + raise ValueError( + f"需要单声道、16-bit PCM WAV,采样率为 {sample_rates};" + f"实际格式:{audio.getparams()}。可先使用 ffmpeg 转换。" + ) + if not audio.getnframes(): + raise ValueError("WAV 文件没有音频帧") + return audio + except BaseException: + audio.close() + raise + + +def wav_output(path, sample_rate): + """Return (raw_file, WAV_writer); close the writer before the raw file.""" + path = Path(path) + if path.suffix.lower() != ".wav": + raise ValueError("PCM 输出必须使用 .wav 扩展名") + raw = path.open("xb") + try: + audio = wave.open(raw, "wb") + try: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(sample_rate) + except BaseException: + audio.close() + raise + return raw, audio + except BaseException: + raw.close() + raise + + +def input_device(value): + return int(value) if value.isdecimal() else value + + +def load_sounddevice(): + try: + import sounddevice + except (ImportError, OSError) as error: + raise RuntimeError( + "录音需要 sounddevice 和 PortAudio;请在仓库根目录运行 " + "python -m pip install -e '.[acli-voice]'。" + "Linux 还需安装系统 PortAudio 库。" + ) from error + return sounddevice + + +def microphone(sample_rate, device=None): + """Return an unstarted input stream; the caller starts, stops and closes it.""" + sd = load_sounddevice() + sd.check_input_settings( + device=device, channels=1, dtype="int16", samplerate=sample_rate, + ) + return sd.RawInputStream( + samplerate=sample_rate, + blocksize=sample_rate // 10, + device=device, + channels=1, + dtype="int16", + ) + + +def player_command(path): + path = str(Path(path).resolve()) + if sys.platform == "darwin" and shutil.which("afplay"): + return ["afplay", path] + if sys.platform == "win32" and shutil.which("powershell"): + return [ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + "$p = New-Object System.Media.SoundPlayer; " + "$p.SoundLocation = $env:DASHSCOPE_SAMPLE_WAV; $p.PlaySync()", + ] + if shutil.which("aplay"): + return ["aplay", path] + if shutil.which("ffplay"): + return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", path] + raise RuntimeError( + "找不到 WAV 播放器(afplay / PowerShell / aplay / ffplay);" + "可使用 --no-play 仅保存文件。" + ) + + +def play_wav(path): + import os + + command = player_command(path) + environment = dict(os.environ, DASHSCOPE_SAMPLE_WAV=str(Path(path).resolve())) + process = subprocess.Popen(command, env=environment) + try: + returncode = process.wait() + if returncode: + raise subprocess.CalledProcessError(returncode, command) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_file.py b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py new file mode 100644 index 0000000..7394aef --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +"""Submit or resume URL-based batch transcription using the V2 speech API.""" + +import argparse +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use an HTTPS audio URL without userinfo or fragments, not a local " + "path. Upload your own WAV to a service-reachable URL yourself; " + "this sample never uploads files." + ) + return value + + +def task_id(value): + if not value or any(char.isspace() or char in "/\\?#" for char in value): + raise argparse.ArgumentTypeError("task ID must be a nonempty ID, not a URL") + return value + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def transcript_text(document): + if (not isinstance(document, dict) + or not isinstance(document.get("transcripts"), list)): + raise ValueError("transcription JSON has no transcripts list") + texts = [] + for transcript in document["transcripts"]: + text = transcript.get("text") + if text is None: + text = "".join(sentence["text"] for sentence in transcript["sentences"]) + if not isinstance(text, str): + raise ValueError("transcript text must be a string") + texts.append(text) + return "\n".join(texts) + + +def download_results(job, args, metadata, output): + results = (job.output or {}).get("results") + if not isinstance(results, list) or not results: + raise RuntimeError( + "Task returned no per-file results; see task.json for its status" + ) + failures = 0 + # Never reuse the SDK transport: signed download URLs need no API key. + downloader = httpx.Client(timeout=args.timeout, follow_redirects=True) + try: + for index, result in enumerate(results, 1): + record = {"index": index} + try: + if not isinstance(result, dict): + raise ValueError("invalid per-file result") + record["subtask_status"] = result.get("subtask_status") + if result.get("subtask_status") != "SUCCEEDED": + raise RuntimeError("subtask did not succeed") + url = https_url(result.get("transcription_url") or "") + response = downloader.get(url) + response.raise_for_status() + document = response.json() + json_path = args.output_dir / f"{index:03d}.json" + artifact = json_path.open("x", encoding="utf-8") + try: + json.dump(document, artifact, ensure_ascii=False, indent=2) + artifact.write("\n") + finally: + artifact.close() + record["json"] = json_path.name + text = transcript_text(document) + text_path = args.output_dir / f"{index:03d}.txt" + artifact = text_path.open("x", encoding="utf-8") + try: + artifact.write(text + "\n") + finally: + artifact.close() + record["text"] = text_path.name + print(f"File {index}: saved {json_path.name} and {text_path.name}", + flush=True) + except (httpx.HTTPError, OSError, ValueError, RuntimeError, + argparse.ArgumentTypeError, KeyError, TypeError, + AttributeError) as error: + failures += 1 + # Exception messages can contain presigned URLs; keep them out of logs. + record["error"] = type(error).__name__ + if isinstance(error, httpx.HTTPStatusError): + record["http_status"] = error.response.status_code + print(f"File {index}: FAILED ({record})", file=sys.stderr, flush=True) + metadata["files"].append(record) + checkpoint(output, metadata) + finally: + downloader.close() + return failures + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP recorded-file ASR: submit a URL, poll, download transcripts. " + "Default: qwen-audio-3.1-asr-flash-filetrans; no WebSocket or SSE.", + epilog=( + "Upload your own WAV to an HTTPS URL reachable by the service first; " + "local paths are NOT file_urls. This sample never uploads audio.\n" + "Submit: python -m examples.01_atomic_capabilities.audio.direct_call.asr_file --url \"$AUDIO_URL\" " + "--output-dir batch-new\n" + "Resume without a new paid submission: " + "python -m examples.01_atomic_capabilities.audio.direct_call.asr_file " + "--task-id TASK_ID --output-dir batch-resumed\n" + "Use your configured DASHSCOPE_API_KEY and the same region/workspace " + "when resuming. Real service usage may incur charges." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--url", action="append", type=https_url, + help="HTTPS audio URL; repeat for multiple files") + source.add_argument("--task-id", type=task_id, help="resume an existing task") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash-filetrans", + choices=("qwen-audio-3.1-asr-flash-filetrans", + "fun-asr", "paraformer-v2"), + help="submission model (ignored when resuming)") + parser.add_argument("--output-dir", type=Path, required=True, + help="new directory in an existing parent; never overwrite") + parser.add_argument("--wait-timeout", type=positive_seconds, default=600.0, + help="maximum polling seconds (default: 600)") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="SDK/download request timeout seconds (default: 30)") + parser.add_argument("--region", help="override DASHSCOPE_REGION") + parser.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if (args.url and args.model == "qwen-audio-3.1-asr-flash-filetrans" + and len(args.url) != 1): + parser.error("qwen-audio-3.1-asr-flash-filetrans requires exactly one --url") + args.output_dir = args.output_dir.resolve() + args.output_dir.mkdir() # Reserve the destination before any paid request. + task_path = args.output_dir / "task.json" + output = task_path.open("x", encoding="utf-8") + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, raise_on_error=False, + ) + try: + metadata = { + "model": None if args.task_id else args.model, + "region": client.config.region, + "workspace": client.config.workspace, + "files": [], + } + if args.task_id: + job = args.task_id + metadata.update(task_id=args.task_id, status="RESUMING") + else: + print("Submitting a paid batch task; input URLs will not be logged.", + file=sys.stderr, flush=True) + job = client.audio.asr.jobs.create( + model=args.model, file_urls=args.url, parameters={}, + ) + if not job.task_id: + raise RuntimeError( + f"No task ID returned (request_id={job.request_id}); " + "check the service before submitting again" + ) + metadata.update(task_id=job.task_id, status=job.status) + print(f"task_id: {metadata['task_id']}", flush=True) + checkpoint(output, metadata) + print("Saved task.json; resume with --task-id and a NEW --output-dir " + "in the same region/workspace.", flush=True) + job = client.audio.asr.jobs.wait(job, timeout=args.wait_timeout) + metadata.update(status=job.status, request_id=job.request_id, code=job.code) + checkpoint(output, metadata) + failed = job.status != "SUCCEEDED" or job.is_error + print(f"Task status: {job.status}", flush=True) + # Even a failed batch may contain usable completed files. + failures = download_results(job, args, metadata, output) + if failed or failures: + print(f"Batch unsuccessful; {failures} per-file failure(s). " + "Completed outputs are preserved.", file=sys.stderr) + return 1 + finally: + client.close() + finally: + output.close() + print(f"Saved all transcripts in {args.output_dir}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted; keep task.json and resume, rather than resubmit.", + file=sys.stderr) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + print(f"Request failed ({type(error).__name__}); keep task.json to resume. " + "If submission returned no ID, check the service before retrying.", + file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print(f"Batch failed: {error}. Existing outputs are preserved.", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py b/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py new file mode 100644 index 0000000..1f5c26d --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""REST ASR: submit complete WAV audio once and receive cumulative text via SSE.""" + +import argparse +import base64 +import json +import sys +from pathlib import Path + +from httpx_sse import EventSource + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import open_wav, positive_seconds +from .asr_file import https_url + + +PATH = "/services/aigc/multimodal-generation/generation" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="RESTful ASR:一次提交完整 WAV,以 HTTP/SSE 接收累计转写。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.asr_http_stream " + "--file tests/data/asr_example.wav --transcript rest.txt\n" + "本地 WAV 以 Base64 随请求发送,或使用 --url 传服务可访问的 HTTPS 地址。" + "这不是实时音频输入;官方仅对至少 60 秒的音频分批返回。真实服务可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--file", type=Path, help="本地单声道 16-bit PCM WAV") + source.add_argument("--url", type=https_url, help="服务可下载的 HTTPS WAV URL") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash", + choices=("qwen-audio-3.1-asr-flash", + "qwen-audio-3.0-asr-flash")) + parser.add_argument("--sample-rate", type=int, default=16000, choices=(16000,), + help="此示例只发送 16 kHz WAV,不自动重采样") + parser.add_argument("--transcript", type=Path, required=True, + help="新的 UTF-8 TXT;每个累计结果覆盖本次文件内容") + parser.add_argument("--events", type=Path, help="可选:保存服务端原始 JSONL 事件") + parser.add_argument("--timeout", type=positive_seconds, default=120.0, + help="网络超时秒数;长音频首个 SSE 事件可能较慢,默认 120") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def receive_transcript(response, transcript, events=None): + text = "" + count = 0 + request_id = response.headers.get("x-request-id") + usage = None + for event in EventSource(response).iter_sse(): + if not event.data: + continue + body = json.loads(event.data) + if not isinstance(body, dict): + raise ValueError("ASR SSE 事件必须为 JSON 对象") + request_id = body.get("request_id") or request_id + if event.event == "error" or body.get("code"): + raise DashScopeError("ASR SSE 返回业务错误", request_id=request_id, + code=body.get("code")) + if events is not None: + events.write(json.dumps(body, ensure_ascii=False) + "\n") + events.flush() + if body.get("usage") is not None: + usage = body["usage"] + output = body.get("output") + if not isinstance(output, dict) or "text" not in output: + continue + text = output["text"] + if not isinstance(text, str): + raise ValueError("ASR output.text 必须为字符串") + count += 1 + # output.text is cumulative and may revise an earlier partial result. + transcript.seek(0) + transcript.write(text) + transcript.truncate() + transcript.flush() + print(f"[累计结果 {count}] {text}", flush=True) + if not count: + raise RuntimeError("HTTP 流结束,但没有收到 output.text") + print(json.dumps({ + "request_id": request_id, "text_events": count, "usage": usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + return text + + +def main(argv=None): + args = make_parser().parse_args(argv) + audio_data = args.url + if args.file is not None: + audio = open_wav(args.file, sample_rates=(args.sample_rate,)) + try: + pcm = audio.readframes(audio.getnframes()) + if len(pcm) != audio.getnframes() * 2 or audio.readframes(1): + raise ValueError("WAV 的 PCM 数据长度与文件头不一致") + finally: + audio.close() + audio_data = "data:audio/wav;base64," + base64.b64encode( + args.file.read_bytes(), + ).decode("ascii") + body = { + "model": args.model, + "input": {"messages": [{ + "role": "user", + "content": [{"type": "input_audio", + "input_audio": {"data": audio_data}}], + }]}, + "parameters": {"format": "wav", "sample_rate": str(args.sample_rate)}, + } + transcript = args.transcript.open("x+", encoding="utf-8") + try: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + client = Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) + try: + # Generic chat normalization would change this ASR-specific schema. + response = client.transport.open_stream( + "POST", PATH, json=body, headers={"X-DashScope-SSE": "enable"}, + timeout=args.timeout, sdk_module="audio", + ) + try: + receive_transcript(response, transcript, events) + finally: + response.close() + finally: + client.close() + finally: + if events is not None: + events.close() + finally: + transcript.close() + print(f"HTTP 流已结束,最后累计文本已保存到 {args.transcript.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消;输出可能是未完成的累计结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"REST ASR 失败({type(error).__name__}," + f"request_id={getattr(error, 'request_id', None)});" + "保留已有结果,请检查输入、网络、模型和地域。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py b/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py new file mode 100644 index 0000000..af12301 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""Capture a system microphone and stream PCM to V2 ASR in the main thread.""" + +import argparse +import math +import signal +import sys +import time +from pathlib import Path + +from ._common import ( + input_device, + load_sounddevice, + microphone, + positive_seconds, + wav_output, +) +from .asr_websocket import ( + SampleError, + add_recognition_arguments, + check_receiver, + run_recognition, +) + + +def send_microphone(stream, recording, args, frame_count, session, receiver): + interrupted = False + + def request_stop(_signum, _frame): + nonlocal interrupted + interrupted = True + + # Raising during an SDK send would close the socket before results can drain. + previous_handler = signal.signal(signal.SIGINT, request_stop) + try: + stream.start() + deadline = time.monotonic() + args.seconds + print( + f"Recording up to {args.seconds:g}s; Ctrl+C stops capture and drains ASR.", + file=sys.stderr, flush=True, + ) + remaining = frame_count + while remaining and not interrupted and time.monotonic() < deadline: + check_receiver(receiver) + count = min(args.sample_rate // 10, remaining) + data, overflowed = stream.read(count) + check_receiver(receiver) + if overflowed: + raise SampleError( + "Microphone input overflowed; audio was lost. " + "Reduce system load or check the network before retrying." + ) + pcm = bytes(data) + if len(pcm) != count * 2: + raise SampleError("Microphone returned an incomplete PCM frame.") + if recording is not None: + recording.writeframesraw(pcm) + session.send_audio_frame(pcm) + remaining -= count + finally: + try: + stream.stop() + finally: + signal.signal(signal.SIGINT, previous_handler) + if interrupted: + print("Capture interrupted; draining final ASR results...", file=sys.stderr) + + +def recognize_microphone(args, transcript, recording, frame_count): + stream = microphone(args.sample_rate, device=args.device) + try: + def send_audio(session, receiver): + send_microphone( + stream, recording, args, frame_count, session, receiver, + ) + + run_recognition(args, args.sample_rate, transcript, send_audio) + finally: + stream.close() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Recognize system-microphone audio with Qwen-Audio 3.1 streaming ASR " + "by default. Capture uses blocking 100 ms mono int16 reads, " + "never network calls in an audio callback. " + "Uses existing SDK credentials. Ctrl+C during capture stops input, " + "then waits for final sentences. Final text goes to stdout and the " + "transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e '.[acli-voice]'\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone --list-devices\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone --seconds 10 " + "--transcript microphone.txt\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone " + "--device 1 --seconds 5 " + "--recording take.wav --transcript take.txt --partials\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone " + "--model fun-asr-realtime " + "--no-recording --transcript fun.txt\n\n" + "PortAudio and OS microphone permission are required for recording. " + "Linux may need a system PortAudio package.\n" + "--list-devices is offline: no Client, recording, or output files.\n" + "Existing outputs are never replaced; failed runs may leave partial " + "files. No resampling is performed." + ), + ) + add_recognition_arguments(parser) + parser.add_argument( + "--seconds", type=positive_seconds, default=10.0, + help="maximum capture duration in seconds (default: 10)", + ) + parser.add_argument( + "--sample-rate", type=int, choices=(8000, 16000), default=16000, + help="microphone PCM sample rate (default: 16000); " + "8000 is only enabled for Paraformer in this example", + ) + parser.add_argument( + "--device", type=input_device, + help="sounddevice input index or name substring (default: system input)", + ) + parser.add_argument( + "--list-devices", action="store_true", + help="list local audio devices and exit without connecting or recording", + ) + outputs = parser.add_mutually_exclusive_group() + outputs.add_argument( + "--recording", type=Path, default=Path("recording.wav"), + help="new WAV file for captured PCM (default: recording.wav)", + ) + outputs.add_argument( + "--no-recording", dest="recording", action="store_const", const=None, + help="do not save the captured audio", + ) + args = parser.parse_args(argv) + try: + if args.list_devices: + print(load_sounddevice().query_devices()) + return 0 + if args.model != "paraformer-realtime-v2" and args.sample_rate != 16000: + raise SampleError( + f"This example uses --sample-rate 16000 for {args.model}." + ) + frame_count = max(1, math.ceil(args.seconds * args.sample_rate)) + load_sounddevice() + transcript = args.transcript.open("x", encoding="utf-8") + try: + if args.recording is not None: + raw, recording = wav_output(args.recording, args.sample_rate) + try: + recognize_microphone(args, transcript, recording, frame_count) + finally: + try: + recording.close() + finally: + raw.close() + else: + recognize_microphone(args, transcript, None, frame_count) + finally: + transcript.close() + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py b/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py new file mode 100644 index 0000000..3a1a7be --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex ASR: send PCM from WAV while receiving sentences.""" + +import argparse +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError +from pathlib import Path + +from ._common import open_wav, positive_seconds + + +class SampleError(RuntimeError): + """A user-facing sample error with no transport or credential details.""" + + +def add_recognition_arguments(parser): + parser.add_argument( + "--model", choices=("qwen-audio-3.1-asr-flash-streaming", + "paraformer-realtime-v2", "fun-asr-realtime"), + default="qwen-audio-3.1-asr-flash-streaming", + help="ASR model (default: qwen-audio-3.1-asr-flash-streaming); " + "this example uses 16 kHz for Qwen-Audio and Fun-ASR", + ) + parser.add_argument( + "--transcript", type=Path, default=Path("transcript.txt"), + help="new UTF-8 file for final sentences only (default: transcript.txt)", + ) + parser.add_argument( + "--partials", action="store_true", + help="also display provisional text on stderr; never save it", + ) + parser.add_argument( + "--timeout", type=positive_seconds, default=30.0, + help="socket timeout and final-result wait budget in seconds (default: 30)", + ) + parser.add_argument("--region", help="DashScope region; default: SDK configuration") + parser.add_argument("--workspace", help="DashScope workspace ID") + parser.add_argument("--vocabulary-id", help="existing ASR vocabulary ID") + + +def check_receiver(receiver): + if receiver.done(): + receiver.result() + raise SampleError("ASR finished before all audio was sent.") + + +def consume_results(session, transcript, partials): + for result in session.results(): + sentence = result.get_sentence() + if not sentence or not sentence.get("text"): + continue + text = sentence["text"] + if result.is_sentence_end(): + transcript.write(text + "\n") + transcript.flush() + print(text, flush=True) + elif partials: + print(f"[partial] {text}", file=sys.stderr, flush=True) + + +def run_recognition(args, sample_rate, transcript, send_audio): + try: + from dashscope import Client + except ImportError: + raise SampleError( + "The V2 SDK is required; run python -m pip install -e . " + "from the repository root." + ) from None + + try: + client = Client( + timeout=args.timeout, region=args.region, workspace=args.workspace, + ) + try: + executor = None + session = client.audio.asr.recognize( + model=args.model, + format="pcm", + sample_rate=sample_rate, + vocabulary_id=args.vocabulary_id, + timeout=args.timeout, + ) + try: + executor = ThreadPoolExecutor(max_workers=1) + receiver = None + try: + session.start() + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_audio(session, receiver) + check_receiver(receiver) + session.stop() + try: + receiver.result(timeout=args.timeout) + except FutureTimeoutError: + if receiver.done(): + receiver.result() + raise SampleError( + f"No task-finished within {args.timeout:g}s after stop." + ) from None + except BaseException: + if receiver is not None and receiver.done(): + receiver.result() + raise + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + print(json.dumps({ + "request_id": session.request_id, + "first_package_delay_ms": session.first_package_delay, + "last_package_delay_ms": session.last_package_delay, + "usage": session.usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + finally: + client.close() + except SampleError: + raise + except Exception as error: + # Transport exceptions can include authorization headers or response bodies. + raise SampleError( + f"ASR failed ({type(error).__name__}). Check network, SDK credentials, " + "model/region access, audio device/output availability and --timeout. " + "Output files may be incomplete." + ) from None + + +def wav_frames(audio): + remaining = audio.getnframes() + block_frames = audio.getframerate() // 10 + while remaining: + count = min(block_frames, remaining) + pcm = audio.readframes(count) + if len(pcm) != count * 2: + raise SampleError("Truncated WAV: fewer PCM bytes than declared frames.") + yield pcm + remaining -= count + if audio.readframes(1): + raise SampleError("Invalid WAV: PCM data is not aligned to 16-bit frames.") + + +def validate_wav_data(audio): + for _ in wav_frames(audio): + pass + audio.rewind() + + +def wait_until(deadline, receiver): + check_receiver(receiver) + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + check_receiver(receiver) + + +def send_wav(audio, session, receiver): + deadline = time.monotonic() + for pcm in wav_frames(audio): + wait_until(deadline, receiver) + deadline = time.monotonic() + len(pcm) / (2 * audio.getframerate()) + session.send_audio_frame(pcm) + wait_until(deadline, receiver) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "WebSocket duplex ASR with Qwen-Audio 3.1 streaming by default. " + "Send a mono, 16-bit, 16 kHz PCM WAV while receiving text; not HTTP. " + "WAV headers are removed and audio is paced in 100 ms frames. " + "Uses existing SDK credentials. Final sentences go to stdout and " + "the transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e .\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket speech.wav " + "--transcript speech.txt\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket speech.wav " + "--model fun-asr-realtime --transcript fun.txt --partials\n\n" + "Existing output files are never replaced. Failed runs may leave " + "partial files.\n" + "This example uses 16 kHz for Qwen-Audio and Fun-ASR; " + "Paraformer also accepts 8 kHz. No resampling is performed." + ), + ) + parser.add_argument("file", type=Path, help="local uncompressed PCM WAV file") + add_recognition_arguments(parser) + args = parser.parse_args(argv) + try: + rates = (8000, 16000) if args.model == "paraformer-realtime-v2" else (16000,) + audio = open_wav(args.file, sample_rates=rates) + try: + validate_wav_data(audio) + transcript = args.transcript.open("x", encoding="utf-8") + try: + def send_audio(session, receiver): + send_wav(audio, session, receiver) + + run_recognition(args, audio.getframerate(), transcript, send_audio) + finally: + transcript.close() + finally: + audio.close() + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except EOFError: + print("error: invalid or truncated WAV header.", file=sys.stderr) + return 1 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py b/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py new file mode 100644 index 0000000..b8980a2 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +"""Run five sequential ASR or TTS tasks on one real WebSocket connection.""" + +import argparse +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event, Timer + +from dashscope import Client +from ._common import open_wav, positive_seconds, wav_output +from .asr_websocket import ( + check_receiver, + consume_results, + send_wav, + validate_wav_data, +) +from .tts_websocket import receive_audio + + +RUNS = 5 + + +def completed_task(session): + task_id = session.session.task_id + header = (session.last_response or {}).get("header", {}) + if header.get("event") != "task-finished" or header.get("task_id") != task_id: + raise RuntimeError("任务未收到匹配的 task-finished,停止复用连接") + return { + "task_id": task_id, + "request_id": session.session.request_id, + "final_event": header["event"], + "first_package_delay_ms": session.first_package_delay, + "usage": session.usage, + } + + +def recognize_one(client, connection, args, source, index): + source.rewind() + path = args.output_dir / f"asr-{index:02d}.txt" + transcript = path.open("x+", encoding="utf-8") + try: + session = client.audio.asr.recognize( + model=args.model, format="pcm", sample_rate=16000, + connection=connection, timeout=args.timeout, + ) + executor = None + try: + session.start() + print(f"ASR {index}/{RUNS}: task_id={session.session.task_id}", flush=True) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_wav(source, session, receiver) + check_receiver(receiver) + session.stop() + receiver.result(timeout=args.timeout) + result = completed_task(session) + result["last_package_delay_ms"] = session.last_package_delay + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + transcript.seek(0) + text = transcript.read().strip() + if not text: + raise RuntimeError("没有最终转写,请使用包含清晰语音的 WAV") + finally: + transcript.close() + result.update(transcript=path.name, text=text) + return result + + +def synthesize_one(client, connection, args, index): + path = args.output_dir / f"tts-{index:02d}.wav" + events_path = args.output_dir / f"tts-{index:02d}-events.jsonl" + text = f"第{index}次。{args.text}" + raw, output = wav_output(path, 24000) + try: + events = events_path.open("x", encoding="utf-8") + try: + session = client.audio.tts.connect( + model=args.model, voice=args.voice, format="pcm", sample_rate=24000, + connection=connection, timeout=args.timeout, + ) + executor = None + try: + session.start() + print( + f"TTS {index}/{RUNS}: task_id={session.session.task_id}", + flush=True, + ) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit(receive_audio, session, output, events) + session.send_text(text) + session.stop() + size = receiver.result(timeout=args.timeout) + result = completed_task(session) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + finally: + events.close() + finally: + try: + output.close() + finally: + raw.close() + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + result.update(audio=path.name, events=events_path.name, input_text=text, + audio_bytes=size, audio_seconds=size / 48000) + return result + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="在一条真实 WebSocket 上顺序执行 5 次 ASR 或 TTS,不自动重连。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.connection_reuse asr " + "tests/data/asr_example.wav --output-dir reuse-asr\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.connection_reuse tts " + "--output-dir reuse-tts\n" + "ASR 与 TTS 分别运行,各创建一条连接;不是混合并发测试。" + "使用已配置的 SDK 凭据,真实调用可能计费。不会录音或播放。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--output-dir", type=Path, required=True, + help="新的输出目录,父目录必须存在,不覆盖已有目录") + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="握手、任务接收及结束输入后等待超时秒数,默认 30") + common.add_argument("--task-timeout", type=positive_seconds, default=120.0, + help="每轮任务总预算秒数,超时关闭整条连接,默认 120") + common.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + common.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="kind", required=True) + asr = commands.add_parser("asr", parents=[common], help="同一 WAV 识别 5 次") + asr.add_argument("file", type=Path, help="16 kHz、16-bit、单声道 PCM WAV") + asr.add_argument("--model", default="qwen-audio-3.1-asr-flash-streaming", + choices=("qwen-audio-3.1-asr-flash-streaming",)) + asr.add_argument("--partials", action="store_true", help="显示临时识别文本") + tts = commands.add_parser("tts", parents=[common], help="带轮次编号的文本合成 5 次") + tts.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash",)) + tts.add_argument("--voice", default="longanhuan_v3.1", help="与 TTS 模型匹配的音色") + tts.add_argument("--text", default="你好,这是连接复用测试。", + help="每轮自动在文本前加“第N次。”") + return parser + + +def run_tasks(args, source=None): + args.output_dir.mkdir() + output = (args.output_dir / "summary.json").open("x", encoding="utf-8") + try: + data = {"kind": args.kind, "model": args.model, "runs_per_connection": RUNS, + "status": "running", "connection_count": 0, "runs": []} + checkpoint(output, data) + try: + client = Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) + try: + data.update( + region=client.config.region, workspace=client.config.workspace, + ) + connection = client.ws_connection(timeout=args.timeout, module="audio") + try: + data["connection_count"] = 1 + checkpoint(output, data) + for index in range(1, RUNS + 1): + expired = Event() + + def abort_task(): + expired.set() + connection.close() + + watchdog = Timer(args.task_timeout, abort_task) + watchdog.daemon = True + watchdog.start() + try: + if args.kind == "asr": + result = recognize_one( + client, connection, args, source, index, + ) + else: + result = synthesize_one( + client, connection, args, index, + ) + finally: + watchdog.cancel() + watchdog.join() + if expired.is_set(): + raise TimeoutError("单轮任务超过 --task-timeout") + if connection.closed: + raise RuntimeError("共享连接已断开,停止后续任务") + if any(item["task_id"] == result["task_id"] + for item in data["runs"]): + raise RuntimeError("不同轮次使用了相同 task_id") + result.update(index=index, connection_open_after_task=True) + data["runs"].append(result) + checkpoint(output, data) + print(json.dumps(result, ensure_ascii=False), flush=True) + finally: + connection.close() + data["connection_closed"] = connection.closed + finally: + client.close() + data["status"] = "completed" + except BaseException as error: + data.update(status="failed", error_type=type(error).__name__) + raise + finally: + checkpoint(output, data) + finally: + output.close() + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.kind == "asr": + source = open_wav(args.file, (16000,)) + try: + validate_wav_data(source) + run_tasks(args, source) + finally: + source.close() + else: + if not args.text.strip(): + parser.error("--text 不能为空") + run_tasks(args) + print(f"完成:1 条连接,{RUNS} 个独立任务;{args.output_dir.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消并关闭连接;保留已完成结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"复用测试失败({type(error).__name__});保留已有结果,不自动重试。", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py new file mode 100644 index 0000000..9651e40 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +"""One Omni 3.8 spoken turn from a WAV file or microphone, saved as a WAV reply.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Omni 3.8 单轮语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni --microphone " + "--seconds 10 --output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "使用手动结束单轮输入模式,不启用独立 Qwen-ASR/Qwen-TTS。" + "Omni 3.8 协议已适配,但本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanlingxin", help="音色,默认 longanlingxin") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], + voice=args.voice, + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + enable_input_audio_transcription=False, + turn_detection=None, + instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py new file mode 100644 index 0000000..55bb909 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_qwen_audio --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_qwen_audio --microphone " + "--seconds 10 --output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "固定使用 qwen-audio-3.0-realtime-plus,手动结束单轮输入。" + "本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanqian", help="音色,默认 longanqian") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。", + help="Qwen-Audio 回答指令") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region", help="服务地域") + parser.add_argument("--workspace", help="工作空间 ID") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen-audio-3.0-realtime-plus", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], voice=args.voice, + input_audio_format="pcm", output_audio_format="pcm", + enable_turn_detection=False, instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py new file mode 100644 index 0000000..4db13ed --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""Translate speech with Qwen3.8-LiveTranslate, saving the spoken translation.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + responses = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n译文:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n原文:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") not in (None, "completed"): + raise RuntimeError(f"翻译未正常完成:{response.get('status')}") + responses += 1 + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + if target == "session.finished" and not responses: + raise RuntimeError("会话结束,但未收到翻译响应结束事件") + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本次总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音翻译,{args.seconds:g} 秒后结束;Ctrl+C 停止输入。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本次总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + pcm = bytes(audio) + if len(pcm) != count * 2: + raise RuntimeError("麦克风返回的 PCM 帧不完整") + session.append_audio(pcm) + sent += len(pcm) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待剩余译文。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本次至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen3.8-LiveTranslate:边输入边翻译,保存译文音频后播放。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_translation --wav input.wav " + "--language en --output translation.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_translation --microphone " + "--seconds 5 --language en --output translation.wav\n" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。" + "使用服务端默认说话人检测;示例未进行云端验证,实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--language", default="en", help="目标语种代码,默认 en(英语)") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="本次翻译总预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen3.8-livetranslate-flash-realtime", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + output_modalities=["text", "audio"], + translation_params={"language": args.language}, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "session.finished", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + # Drain all translations, not just the first response.done. + session.end_session() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"翻译音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if not args.language.strip(): + parser.error("--language 需要非空目标语种代码") + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;翻译文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"翻译音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"翻译失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_http.py b/examples/01_atomic_capabilities/audio/direct_call/tts_http.py new file mode 100644 index 0000000..4f76ad6 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_http.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +"""HTTP TTS: synthesize once, download raw PCM, then save and play one WAV.""" + +import argparse +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def https_audio_url(url): + """Validate the signed URL; change only an HTTP scheme to HTTPS.""" + if not isinstance(url, str) or not url: + raise SampleError("服务未返回音频下载地址") + if any(ord(char) <= 32 or ord(char) == 127 for char in url): + raise SampleError("音频下载地址包含非法空白或控制字符") + try: + parts = urlsplit(url) + valid = ( + parts.scheme in ("http", "https") + and bool(parts.hostname) + and parts.username is None + and parts.password is None + and "#" not in url + and "\\" not in url + ) + # Validate a supplied port without changing the signed URL. + _ = parts.port + except ValueError: + raise SampleError("音频下载地址无效") from None + if not valid: + raise SampleError("音频下载地址必须为 HTTP(S),有主机且无 userinfo/fragment") + if parts.scheme == "http": + return "https" + url[len(parts.scheme):] + return url + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP 单次语音合成:下载原始 PCM,保存 WAV 后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_http " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成及下载的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + # Reserve the output exclusively before making any billable request. + raw, output = wav_output(args.output, args.sample_rate) + try: + client = Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) + try: + result = client.audio.tts.http.synthesize( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + if result.is_error: + raise SampleError("服务返回合成错误") + print("request_id:", result.request_id, "usage:", result.usage) + url = https_audio_url(result.audio_url) + # No SDK credentials on downloads; redirects cannot downgrade HTTPS. + downloader = httpx.Client(timeout=args.timeout, follow_redirects=False) + try: + request = downloader.build_request("GET", url) + response = downloader.send(request, stream=True) + try: + if not 200 <= response.status_code < 300: + raise SampleError( + f"音频下载失败(HTTP {response.status_code};不跟随重定向)" + ) + for data in response.iter_bytes(): + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + finally: + response.close() + finally: + downloader.close() + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py b/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py new file mode 100644 index 0000000..4cb8582 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""HTTP SSE TTS: stream decoded PCM into one WAV, then play after EOF.""" + +import argparse +import sys +from pathlib import Path + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP SSE 语音合成:逐块保存 PCM WAV,完整结束后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_http_stream " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成流的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + finished = False + # Reserve the output exclusively before making any billable request. + raw, output = wav_output(args.output, args.sample_rate) + try: + client = Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) + try: + chunks = client.audio.tts.http.stream( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + try: + for chunk in chunks: + if chunk.is_error: + raise SampleError("服务返回流式合成错误") + # Drain after stop so later stream errors remain failures. + finished = finished or chunk.is_final + if chunk.is_final: + print("request_id:", chunk.request_id, "usage:", chunk.usage) + data = chunk.audio + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + finally: + chunks.close() + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not finished: + raise SampleError("SSE 流缺少 finish_reason=stop 结束标记") + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py new file mode 100644 index 0000000..aa89a8e --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex TTS: send text while receiving audio into one WAV.""" + +import argparse +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_audio(session, output, events): + size = 0 + for frame in session.events(): + if isinstance(frame, bytes): + output.writeframesraw(frame) + size += len(frame) + elif events is not None: + events.write(json.dumps(frame, ensure_ascii=False) + "\n") + events.flush() + return size + + +def synthesize(client, args, parts, output, events): + options = { + "model": args.model, + "voice": args.voice, + "format": "pcm", + "sample_rate": args.sample_rate, + "timeout": args.timeout, + "enable_ssml": args.ssml, + } + if args.instruction: + options["parameters"] = {"instruction": args.instruction} + + executor = None + session = client.audio.tts.connect(**options) + try: + session.start() + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit(receive_audio, session, output, events) + for part in parts: + if receiver.done(): + receiver.result() + raise RuntimeError("文本尚未发送完,合成会话已结束") + if part: + session.send_text(part) + session.stop() + size = receiver.result(timeout=args.timeout) + print("request_id:", session.session.request_id) + print("首包延迟(ms):", session.first_package_delay) + print("usage:", session.usage) + return size + finally: + try: + # Close before executor shutdown to unblock its socket reader. + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description="WebSocket 双工流式合成:边发送文本边接收音频,保存 WAV 后播放。", + epilog=( + "默认 qwen-audio-3.1-tts-flash,推荐音色 longanhuan_v3.1。\n" + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_websocket " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "按行发送:python -m examples.01_atomic_capabilities.audio.direct_call.tts_websocket " + "--text-file article.txt --output article.wav --events events.jsonl\n" + "先安装 python -m pip install -e .,配置 DASHSCOPE_API_KEY、" + "DASHSCOPE_WORKSPACE 和模型对应地域。真实服务可能计费。" + "不调用独立 qwen-asr、旧 qwen-tts 或 Sambert。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", action="append", help="文本;可重复传入多个片段") + source.add_argument("--text-file", type=Path, help="UTF-8 文本,按行发送") + source.add_argument("--prompt", help="实时朗读大语言模型生成的回答,额外计费") + parser.add_argument("--llm-model", default="qwen-plus", help="--prompt 的文本模型") + parser.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", + "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus")) + parser.add_argument("--voice", + help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan;" + "复刻音色须同时指定创建时的 --model 和 --voice voice_id") + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--events", type=Path, help="原始事件 JSONL 文件") + parser.add_argument("--ssml", action="store_true", help="按所选模型的 SSML 规则合成") + parser.add_argument("--instruction", help="模型支持时使用的合成指令") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="socket 超时及输入结束后的等待秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.voice is None: + args.voice = { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if args.prompt is not None: + if not args.prompt.strip(): + parser.error("--prompt 需要非空提示词") + if args.ssml: + parser.error("--prompt 输出为普通文本,不能与 --ssml 一起使用") + if not args.no_play: + player_command(args.output) + source = args.text_file.open(encoding="utf-8") if args.text_file else None + try: + if source is not None: + if not any(line.strip() for line in source): + parser.error("文本文件不能为空") + source.seek(0) + parts = source + else: + parts = args.text or [] + if not args.prompt and not any(part.strip() for part in parts): + parser.error("文本不能为空") + raw, output = wav_output(args.output, args.sample_rate) + try: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + if args.prompt: + stream = client.chat.completions.create( + model=args.llm_model, + messages=[{"role": "user", "content": args.prompt}], + stream=True, + parameters={"incremental_output": True}, + ) + try: + size = synthesize( + client, args, stream.text_stream, output, events, + ) + finally: + stream.close() + else: + size = synthesize(client, args, parts, output, events) + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + if events is not None: + events.close() + finally: + try: + output.close() + finally: + raw.close() + finally: + if source is not None: + source.close() + print(f"已保存 {args.output.resolve()}({size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py new file mode 100644 index 0000000..a979bd9 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +"""Create and manage Qwen-Audio and CosyVoice clones through the V2 public API.""" + +import argparse +import json +import re +import shlex +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +MODELS = ( + "qwen-audio-3.0-tts-flash", "cosyvoice-v3-flash", "cosyvoice-v3-plus", +) + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use a service-reachable HTTPS audio URL without userinfo or fragments, " + "not a local path. Upload owned/authorized speaker audio yourself; " + "this sample never uploads it." + ) + return value + + +def voice_prefix(value): + if not re.fullmatch(r"[a-z0-9]{1,10}", value): + raise argparse.ArgumentTypeError("prefix must be 1-10 lowercase letters/digits") + return value + + +def voice_id(value): + if not value or any(char.isspace() for char in value): + raise argparse.ArgumentTypeError( + "voice ID must be nonempty and contain no spaces" + ) + return value + + +def checkpoint(output, data, response): + data["result"] = response.model_dump(mode="json", exclude={"headers"}, + exclude_none=True) + if output is not None: + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def connection_options(client): + options = [] + if client.config.region: + options.extend(["--region", client.config.region]) + if client.config.workspace: + options.extend(["--workspace", client.config.workspace]) + return options + + +def wait_ready(client, args, identifier, output, data): + deadline = time.monotonic() + args.wait_timeout + previous_status = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + "Voice is not ready before --wait-timeout; use get to recover" + ) + voice = client.audio.voices.get( + identifier, timeout=min(args.timeout, remaining) + ) + checkpoint(output, data, voice) + if voice.status != previous_status: + print(f"Voice status: {voice.status}", flush=True) + previous_status = voice.status + if voice.status == "OK": + return + if voice.status != "DEPLOYING": + raise RuntimeError( + f"Voice is unavailable or has unsupported status: {voice.status}" + ) + # Only DEPLOYING is retryable; never poll UNDEPLOYED or unknown states. + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2.0, remaining)) + + +def create_voice(client, args, output, data): + print("Use only your own or explicitly authorized speaker audio. " + "Creating a clone may incur charges; it will NOT be automatically deleted.", + file=sys.stderr, flush=True) + voice = client.audio.voices.create( + target_model=args.model, prefix=args.prefix, url=args.url, + ) + identifier = voice.voice_id + if not identifier: + checkpoint(output, data, voice) + raise RuntimeError( + "No voice ID returned; use list before attempting another create" + ) + print(f"voice_id: {identifier}", flush=True) + data.update(voice_id=identifier, target_model=args.model) + checkpoint(output, data, voice) + print("Recover without creating again: " + shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.direct_call.voice_clone", "get", + "--voice-id", identifier, *connection_options(client), + ]), flush=True) + wait_ready(client, args, identifier, output, data) + print("Ready. Synthesize separately (same model; may incur charges):") + print(shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.direct_call.tts_websocket", + "--model", args.model, + "--voice", identifier, "--text", "Hello, this is my cloned voice.", + "--output", "cloned.wav", "--no-play", *connection_options(client), + ]), flush=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description=( + "Qwen-Audio / CosyVoice voice cloning; no synthesis, playback, " + "or auto-deletion." + ), + epilog=( + "Default target: qwen-audio-3.0-tts-flash (voice-enrollment API).\n" + "Create: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone create " + "--url \"$AUDIO_URL\" --output new-voice.json\n" + "Recover: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone get " + "--voice-id VOICE_ID\n" + "List: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone list --prefix demo\n" + "Delete only when intended: " + "python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone " + "delete --voice-id VOICE_ID --yes\n" + "Use your configured DASHSCOPE_API_KEY; keep the same region/workspace. " + "Use subcommand --help for options." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--output", type=Path, help="optional NEW .json file; never overwrite" + ) + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="request timeout seconds (default: 30)") + common.add_argument("--region", help="override DASHSCOPE_REGION") + common.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser( + "create", parents=[common], help="create a clone and wait until OK", + description=( + "Use only owned/authorized speaker audio at a service-reachable HTTPS URL. " + "Upload it yourself; this sample never uploads local files. Recommended: " + "10-20 seconds, WAV (16-bit)/MP3/M4A, >=16 kHz, <=10 MB, " + "at most 60 seconds. " + "Include at least 5 seconds of continuous clear speech, without music " + "or other speakers. Only the first channel is used for stereo audio. " + "The voice persists, including after timeout or interruption." + ), + ) + create.add_argument("--url", type=https_url, required=True) + create.add_argument("--prefix", type=voice_prefix, default="demo", + help="1-10 lowercase letters/digits (default: demo)") + create.add_argument("--model", choices=MODELS, default="qwen-audio-3.0-tts-flash", + help="target TTS model (default: qwen-audio-3.0-tts-flash); " + "synthesis must use this same version") + create.add_argument("--wait-timeout", type=positive_seconds, default=300.0, + help="maximum readiness polling seconds (default: 300)") + get = commands.add_parser("get", parents=[common], + help="query once; exit nonzero unless the voice is OK") + get.add_argument("--voice-id", type=voice_id, required=True) + listing = commands.add_parser( + "list", parents=[common], help="list one page of clones" + ) + listing.add_argument("--prefix", type=voice_prefix) + listing.add_argument( + "--page-index", type=int, default=0, help="zero-based page index" + ) + listing.add_argument("--page-size", type=int, default=10) + delete = commands.add_parser( + "delete", parents=[common], help="explicitly delete one clone" + ) + delete.add_argument("--voice-id", type=voice_id, required=True) + delete.add_argument("--yes", action="store_true", help="confirm permanent deletion") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.command == "delete" and not args.yes: + parser.error("delete requires --yes; nothing has been deleted") + if args.command == "list" and (args.page_index < 0 or args.page_size <= 0): + parser.error("--page-index must be >= 0 and --page-size must be > 0") + if args.output and args.output.suffix.lower() != ".json": + parser.error("--output must be a new .json file") + # Open exclusively before any request can create a billable resource. + output = args.output.open("x", encoding="utf-8") if args.output else None + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, + ) + try: + data = {"action": args.command, "region": client.config.region, + "workspace": client.config.workspace} + if args.command == "create": + create_voice(client, args, output, data) + elif args.command == "get": + voice = client.audio.voices.get(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, voice) + print(f"voice_id: {args.voice_id}; status: {voice.status}", flush=True) + if voice.status != "OK": + print( + "Voice is not ready; do not synthesize with it yet.", + file=sys.stderr, + ) + return 1 + elif args.command == "list": + voices = client.audio.voices.list( + prefix=args.prefix, page_index=args.page_index, + page_size=args.page_size, + ) + data.update(page_index=args.page_index, page_size=args.page_size) + checkpoint(output, data, voices) + print(f"Page {args.page_index}: {len(voices.voices)} voice(s)") + for voice in voices.voices: + print( + f"voice_id: {voice.get('voice_id')}; " + f"status: {voice.get('status')}" + ) + else: + result = client.audio.voices.delete(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, result) + print(f"Deleted {args.voice_id}; request_id: {result.request_id}") + finally: + client.close() + finally: + if output is not None: + output.close() + if args.output: + print(f"Saved {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print( + "\nInterrupted; no automatic deletion. Recover with get/list, not create.", + file=sys.stderr, + ) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + # Do not expose signed source/preview URLs through exception messages. + print(f"Request failed ({type(error).__name__}). Use get/list to recover; " + "existing clones and saved results are retained.", file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print( + f"Voice operation failed: {error}. No automatic deletion.", file=sys.stderr + ) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_omni.py b/examples/01_atomic_capabilities/audio/realtime_omni.py new file mode 100644 index 0000000..9d7b72e --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_omni.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +"""One Omni 3.8 spoken turn from a WAV file or microphone, saved as a WAV reply.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Omni 3.8 单轮语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_omni --wav input.wav --output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_omni --microphone --seconds 10 " + "--output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "使用手动结束单轮输入模式,不启用独立 Qwen-ASR/Qwen-TTS。" + "Omni 3.8 协议已适配,但本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanlingxin", help="音色,默认 longanlingxin") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], + voice=args.voice, + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + enable_input_audio_transcription=False, + turn_detection=None, + instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + try: + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py new file mode 100644 index 0000000..6e4406d --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio --microphone --seconds 10 " + "--output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "固定使用 qwen-audio-3.0-realtime-plus,手动结束单轮输入。" + "本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanqian", help="音色,默认 longanqian") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。", + help="Qwen-Audio 回答指令") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region", help="服务地域") + parser.add_argument("--workspace", help="工作空间 ID") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen-audio-3.0-realtime-plus", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], voice=args.voice, + input_audio_format="pcm", output_audio_format="pcm", + enable_turn_detection=False, instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_translation.py b/examples/01_atomic_capabilities/audio/realtime_translation.py new file mode 100644 index 0000000..186f9df --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_translation.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""Translate speech with Qwen3.8-LiveTranslate, saving the spoken translation.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + responses = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n译文:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n原文:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") not in (None, "completed"): + raise RuntimeError(f"翻译未正常完成:{response.get('status')}") + responses += 1 + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + if target == "session.finished" and not responses: + raise RuntimeError("会话结束,但未收到翻译响应结束事件") + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本次总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音翻译,{args.seconds:g} 秒后结束;Ctrl+C 停止输入。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本次总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + pcm = bytes(audio) + if len(pcm) != count * 2: + raise RuntimeError("麦克风返回的 PCM 帧不完整") + session.append_audio(pcm) + sent += len(pcm) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待剩余译文。") + if sent < 3200: + raise ValueError("本次至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen3.8-LiveTranslate:边输入边翻译,保存译文音频后播放。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_translation --wav input.wav " + "--language en --output translation.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_translation --microphone --seconds 5 " + "--language en --output translation.wav\n" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。" + "使用服务端默认说话人检测;示例未进行云端验证,实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--language", default="en", help="目标语种代码,默认 en(英语)") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="本次翻译总预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen3.8-livetranslate-flash-realtime", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + output_modalities=["text", "audio"], + translation_params={"language": args.language}, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit( + receive_until, session, "session.finished", deadline, output, + ) + try: + send_input(session, receiver, args, source, deadline) + # Drain all translations, not just the first response.done. + session.end_session() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"翻译音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if not args.language.strip(): + parser.error("--language 需要非空目标语种代码") + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;翻译文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"翻译音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"翻译失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio_speech/tingwu.py b/examples/01_atomic_capabilities/audio/tingwu.py similarity index 100% rename from examples/01_atomic_capabilities/audio_speech/tingwu.py rename to examples/01_atomic_capabilities/audio/tingwu.py diff --git a/examples/01_atomic_capabilities/audio/tts_http.py b/examples/01_atomic_capabilities/audio/tts_http.py new file mode 100644 index 0000000..8c0a1bd --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_http.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""HTTP TTS: synthesize once, download raw PCM, then save and play one WAV.""" + +import argparse +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def https_audio_url(url): + """Validate the signed URL; change only an HTTP scheme to HTTPS.""" + if not isinstance(url, str) or not url: + raise SampleError("服务未返回音频下载地址") + if any(ord(char) <= 32 or ord(char) == 127 for char in url): + raise SampleError("音频下载地址包含非法空白或控制字符") + try: + parts = urlsplit(url) + valid = ( + parts.scheme in ("http", "https") + and bool(parts.hostname) + and parts.username is None + and parts.password is None + and "#" not in url + and "\\" not in url + ) + # Validate a supplied port without changing the signed URL. + _ = parts.port + except ValueError: + raise SampleError("音频下载地址无效") from None + if not valid: + raise SampleError("音频下载地址必须为 HTTP(S),有主机且无 userinfo/fragment") + if parts.scheme == "http": + return "https" + url[len(parts.scheme):] + return url + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP 单次语音合成:下载原始 PCM,保存 WAV 后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_http " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成及下载的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + # Reserve the output exclusively before making any billable request. + with wav_output(args.output, args.sample_rate) as output: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + result = client.audio.tts.http.synthesize( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + if result.is_error: + raise SampleError("服务返回合成错误") + print("request_id:", result.request_id, "usage:", result.usage) + url = https_audio_url(result.audio_url) + # No SDK credentials on downloads; redirects cannot downgrade HTTPS. + with httpx.Client(timeout=args.timeout, + follow_redirects=False) as downloader: + with downloader.stream("GET", url) as response: + if not 200 <= response.status_code < 300: + raise SampleError( + f"音频下载失败(HTTP {response.status_code};不跟随重定向)" + ) + for data in response.iter_bytes(): + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/tts_http_stream.py b/examples/01_atomic_capabilities/audio/tts_http_stream.py new file mode 100644 index 0000000..3e39cb5 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_http_stream.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""HTTP SSE TTS: stream decoded PCM into one WAV, then play after EOF.""" + +import argparse +import sys +from contextlib import closing +from pathlib import Path + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP SSE 语音合成:逐块保存 PCM WAV,完整结束后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_http_stream " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成流的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + finished = False + # Reserve the output exclusively before making any billable request. + with wav_output(args.output, args.sample_rate) as output: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + with closing(client.audio.tts.http.stream( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + )) as chunks: + for chunk in chunks: + if chunk.is_error: + raise SampleError("服务返回流式合成错误") + # Drain after stop so later stream errors remain failures. + finished = finished or chunk.is_final + if chunk.is_final: + print("request_id:", chunk.request_id, "usage:", chunk.usage) + data = chunk.audio + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not finished: + raise SampleError("SSE 流缺少 finish_reason=stop 结束标记") + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/tts_websocket.py b/examples/01_atomic_capabilities/audio/tts_websocket.py new file mode 100644 index 0000000..b2a8259 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_websocket.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex TTS: send text while receiving audio into one WAV.""" + +import argparse +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_audio(session, output, events): + size = 0 + for frame in session.events(): + if isinstance(frame, bytes): + output.writeframesraw(frame) + size += len(frame) + elif events is not None: + events.write(json.dumps(frame, ensure_ascii=False) + "\n") + events.flush() + return size + + +def synthesize(client, args, parts, output, events): + options = { + "model": args.model, + "voice": args.voice, + "format": "pcm", + "sample_rate": args.sample_rate, + "timeout": args.timeout, + "enable_ssml": args.ssml, + } + if args.instruction: + options["parameters"] = {"instruction": args.instruction} + + with client.audio.tts.connect(**options) as session: + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit(receive_audio, session, output, events) + try: + for part in parts: + if receiver.done(): + receiver.result() + raise RuntimeError("文本尚未发送完,合成会话已结束") + if part: + session.send_text(part) + session.stop() + size = receiver.result(timeout=args.timeout) + print("request_id:", session.session.request_id) + print("首包延迟(ms):", session.first_package_delay) + print("usage:", session.usage) + return size + finally: + # Close before executor shutdown to unblock its socket reader. + session.close() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="WebSocket 双工流式合成:边发送文本边接收音频,保存 WAV 后播放。", + epilog=( + "默认 qwen-audio-3.1-tts-flash,推荐音色 longanhuan_v3.1。\n" + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_websocket " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "按行发送:python -m examples.01_atomic_capabilities.audio.tts_websocket " + "--text-file article.txt --output article.wav --events events.jsonl\n" + "先安装 python -m pip install -e .,配置 DASHSCOPE_API_KEY、" + "DASHSCOPE_WORKSPACE 和模型对应地域。真实服务可能计费。" + "不调用独立 qwen-asr、旧 qwen-tts 或 Sambert。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", action="append", help="文本;可重复传入多个片段") + source.add_argument("--text-file", type=Path, help="UTF-8 文本,按行发送") + source.add_argument("--prompt", help="实时朗读大语言模型生成的回答,额外计费") + parser.add_argument("--llm-model", default="qwen-plus", help="--prompt 的文本模型") + parser.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", + "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus")) + parser.add_argument("--voice", + help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan;" + "复刻音色须同时指定创建时的 --model 和 --voice voice_id") + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--events", type=Path, help="原始事件 JSONL 文件") + parser.add_argument("--ssml", action="store_true", help="按所选模型的 SSML 规则合成") + parser.add_argument("--instruction", help="模型支持时使用的合成指令") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="socket 超时及输入结束后的等待秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.voice is None: + args.voice = { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if args.prompt is not None: + if not args.prompt.strip(): + parser.error("--prompt 需要非空提示词") + if args.ssml: + parser.error("--prompt 输出为普通文本,不能与 --ssml 一起使用") + if not args.no_play: + player_command(args.output) + source = args.text_file.open(encoding="utf-8") if args.text_file else None + try: + if source is not None: + if not any(line.strip() for line in source): + parser.error("文本文件不能为空") + source.seek(0) + parts = source + else: + parts = args.text or [] + if not args.prompt and not any(part.strip() for part in parts): + parser.error("文本不能为空") + with wav_output(args.output, args.sample_rate) as output: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + with Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + if args.prompt: + with closing(client.chat.completions.create( + model=args.llm_model, + messages=[{"role": "user", "content": args.prompt}], + stream=True, + parameters={"incremental_output": True}, + )) as stream: + size = synthesize( + client, args, stream.text_stream, output, events, + ) + else: + size = synthesize(client, args, parts, output, events) + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + finally: + if events is not None: + events.close() + finally: + if source is not None: + source.close() + print(f"已保存 {args.output.resolve()}({size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/voice_clone.py b/examples/01_atomic_capabilities/audio/voice_clone.py new file mode 100644 index 0000000..d6ab2b5 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/voice_clone.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +"""Create and manage Qwen-Audio and CosyVoice clones through the V2 public API.""" + +import argparse +import json +import re +import shlex +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +MODELS = ( + "qwen-audio-3.0-tts-flash", "cosyvoice-v3-flash", "cosyvoice-v3-plus", +) + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use a service-reachable HTTPS audio URL without userinfo or fragments, " + "not a local path. Upload owned/authorized speaker audio yourself; " + "this sample never uploads it." + ) + return value + + +def voice_prefix(value): + if not re.fullmatch(r"[a-z0-9]{1,10}", value): + raise argparse.ArgumentTypeError("prefix must be 1-10 lowercase letters/digits") + return value + + +def voice_id(value): + if not value or any(char.isspace() for char in value): + raise argparse.ArgumentTypeError( + "voice ID must be nonempty and contain no spaces" + ) + return value + + +def checkpoint(output, data, response): + data["result"] = response.model_dump(mode="json", exclude={"headers"}, + exclude_none=True) + if output is not None: + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def connection_options(client): + options = [] + if client.config.region: + options.extend(["--region", client.config.region]) + if client.config.workspace: + options.extend(["--workspace", client.config.workspace]) + return options + + +def wait_ready(client, args, identifier, output, data): + deadline = time.monotonic() + args.wait_timeout + previous_status = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + "Voice is not ready before --wait-timeout; use get to recover" + ) + voice = client.audio.voices.get( + identifier, timeout=min(args.timeout, remaining) + ) + checkpoint(output, data, voice) + if voice.status != previous_status: + print(f"Voice status: {voice.status}", flush=True) + previous_status = voice.status + if voice.status == "OK": + return + if voice.status != "DEPLOYING": + raise RuntimeError( + f"Voice is unavailable or has unsupported status: {voice.status}" + ) + # Only DEPLOYING is retryable; never poll UNDEPLOYED or unknown states. + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2.0, remaining)) + + +def create_voice(client, args, output, data): + print("Use only your own or explicitly authorized speaker audio. " + "Creating a clone may incur charges; it will NOT be automatically deleted.", + file=sys.stderr, flush=True) + voice = client.audio.voices.create( + target_model=args.model, prefix=args.prefix, url=args.url, + ) + identifier = voice.voice_id + if not identifier: + checkpoint(output, data, voice) + raise RuntimeError( + "No voice ID returned; use list before attempting another create" + ) + print(f"voice_id: {identifier}", flush=True) + data.update(voice_id=identifier, target_model=args.model) + checkpoint(output, data, voice) + print("Recover without creating again: " + shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.voice_clone", "get", "--voice-id", identifier, + *connection_options(client), + ]), flush=True) + wait_ready(client, args, identifier, output, data) + print("Ready. Synthesize separately (same model; may incur charges):") + print(shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.tts_websocket", "--model", args.model, + "--voice", identifier, "--text", "Hello, this is my cloned voice.", + "--output", "cloned.wav", "--no-play", *connection_options(client), + ]), flush=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description=( + "Qwen-Audio / CosyVoice voice cloning; no synthesis, playback, " + "or auto-deletion." + ), + epilog=( + "Default target: qwen-audio-3.0-tts-flash (voice-enrollment API).\n" + "Create: python -m examples.01_atomic_capabilities.audio.voice_clone create --url \"$AUDIO_URL\" " + "--output new-voice.json\n" + "Recover: python -m examples.01_atomic_capabilities.audio.voice_clone get --voice-id VOICE_ID\n" + "List: python -m examples.01_atomic_capabilities.audio.voice_clone list --prefix demo\n" + "Delete only when intended: python -m examples.01_atomic_capabilities.audio.voice_clone delete " + "--voice-id VOICE_ID --yes\n" + "Use your configured DASHSCOPE_API_KEY; keep the same region/workspace. " + "Use subcommand --help for options." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--output", type=Path, help="optional NEW .json file; never overwrite" + ) + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="request timeout seconds (default: 30)") + common.add_argument("--region", help="override DASHSCOPE_REGION") + common.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser( + "create", parents=[common], help="create a clone and wait until OK", + description=( + "Use only owned/authorized speaker audio at a service-reachable HTTPS URL. " + "Upload it yourself; this sample never uploads local files. Recommended: " + "10-20 seconds, WAV (16-bit)/MP3/M4A, >=16 kHz, <=10 MB, " + "at most 60 seconds. " + "Include at least 5 seconds of continuous clear speech, without music " + "or other speakers. Only the first channel is used for stereo audio. " + "The voice persists, including after timeout or interruption." + ), + ) + create.add_argument("--url", type=https_url, required=True) + create.add_argument("--prefix", type=voice_prefix, default="demo", + help="1-10 lowercase letters/digits (default: demo)") + create.add_argument("--model", choices=MODELS, default="qwen-audio-3.0-tts-flash", + help="target TTS model (default: qwen-audio-3.0-tts-flash); " + "synthesis must use this same version") + create.add_argument("--wait-timeout", type=positive_seconds, default=300.0, + help="maximum readiness polling seconds (default: 300)") + get = commands.add_parser("get", parents=[common], + help="query once; exit nonzero unless the voice is OK") + get.add_argument("--voice-id", type=voice_id, required=True) + listing = commands.add_parser( + "list", parents=[common], help="list one page of clones" + ) + listing.add_argument("--prefix", type=voice_prefix) + listing.add_argument( + "--page-index", type=int, default=0, help="zero-based page index" + ) + listing.add_argument("--page-size", type=int, default=10) + delete = commands.add_parser( + "delete", parents=[common], help="explicitly delete one clone" + ) + delete.add_argument("--voice-id", type=voice_id, required=True) + delete.add_argument("--yes", action="store_true", help="confirm permanent deletion") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.command == "delete" and not args.yes: + parser.error("delete requires --yes; nothing has been deleted") + if args.command == "list" and (args.page_index < 0 or args.page_size <= 0): + parser.error("--page-index must be >= 0 and --page-size must be > 0") + if args.output and args.output.suffix.lower() != ".json": + parser.error("--output must be a new .json file") + # Open exclusively before any request can create a billable resource. + output = args.output.open("x", encoding="utf-8") if args.output else None + try: + with Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, + ) as client: + data = {"action": args.command, "region": client.config.region, + "workspace": client.config.workspace} + if args.command == "create": + create_voice(client, args, output, data) + elif args.command == "get": + voice = client.audio.voices.get(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, voice) + print(f"voice_id: {args.voice_id}; status: {voice.status}", flush=True) + if voice.status != "OK": + print( + "Voice is not ready; do not synthesize with it yet.", + file=sys.stderr, + ) + return 1 + elif args.command == "list": + voices = client.audio.voices.list( + prefix=args.prefix, page_index=args.page_index, + page_size=args.page_size, + ) + data.update(page_index=args.page_index, page_size=args.page_size) + checkpoint(output, data, voices) + print(f"Page {args.page_index}: {len(voices.voices)} voice(s)") + for voice in voices.voices: + print( + f"voice_id: {voice.get('voice_id')}; " + f"status: {voice.get('status')}" + ) + else: + result = client.audio.voices.delete(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, result) + print(f"Deleted {args.voice_id}; request_id: {result.request_id}") + finally: + if output is not None: + output.close() + if args.output: + print(f"Saved {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print( + "\nInterrupted; no automatic deletion. Recover with get/list, not create.", + file=sys.stderr, + ) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + # Do not expose signed source/preview URLs through exception messages. + print(f"Request failed ({type(error).__name__}). Use get/list to recover; " + "existing clones and saved results are retained.", file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print( + f"Voice operation failed: {error}. No automatic deletion.", file=sys.stderr + ) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio_speech/audio_asr.py b/examples/01_atomic_capabilities/audio_speech/audio_asr.py deleted file mode 100644 index a810d19..0000000 --- a/examples/01_atomic_capabilities/audio_speech/audio_asr.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""dashscope 2.0 speech-recognition (ASR) example: batch -transcription over audio file URLs.""" - -import os - -import dashscope - -# Authentication: the client reads the API key from the -# DASHSCOPE_API_KEY environment variable by default; you can also -# pass it explicitly via dashscope.Client(api_key="sk-xxx"). -MODEL = os.environ.get("DASHSCOPE_MODEL", "paraformer-v2") - -# Replace with your own audio file URLs (must be publicly accessible) -FILE_URLS = [ - "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", -] - - -if __name__ == "__main__": - client = dashscope.Client() - - # Batch transcription: submits the task and waits for the result - print("----- transcribe request -----") - job = client.audio.asr.transcribe( - FILE_URLS, - model=MODEL, - parameters={"language_hints": ["zh"]}, - ) - if job.succeeded: - assert job.output is not None - for item in job.output["results"]: - print(f"file: {item['file_url']}") - print(f"transcription url: {item['transcription_url']}") - else: - print(f"job failed: {job.output}") - - # Realtime recognition of a local file (websocket) is available - # via client.audio.asr.recognize_file(path, model=...), which - # yields recognition events as an iterator. - - -# --- Production springboard (Best Practices & Patterns) ------- -# Minimal runnable atomic call above; to productionize it, see: -# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py -# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py -# Realtime hosting: 04_real_world_scenarios/realtime_audio_meeting_harness.py -# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/audio_speech/audio_tts.py b/examples/01_atomic_capabilities/audio_speech/audio_tts.py deleted file mode 100644 index b31a6dd..0000000 --- a/examples/01_atomic_capabilities/audio_speech/audio_tts.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""dashscope 2.0 speech-synthesis (TTS) example: one-shot URL -result and streaming audio chunks.""" - -import os - -import dashscope - -# Authentication: the client reads the API key from the -# DASHSCOPE_API_KEY environment variable by default; you can also -# pass it explicitly via dashscope.Client(api_key="sk-xxx"). -MODEL = os.environ.get("DASHSCOPE_MODEL", "sambert-zhichu-v1") -VOICE = os.environ.get("DASHSCOPE_TTS_VOICE", "zhichu") - -TEXT = "你好,这是 dashscope 2.0 的语音合成示例。" - - -if __name__ == "__main__": - client = dashscope.Client() - - # One-shot synthesis: the rendered audio comes back as a - # temporary URL - print("----- synthesize request -----") - result = client.audio.tts.http.synthesize( - TEXT, - model=MODEL, - voice=VOICE, - format="wav", - sample_rate=16000, - ) - print(f"audio url: {result.audio_url}") - - # Streaming synthesis: audio arrives as binary chunks that can be - # written straight to a file - print("----- streaming synthesis request -----") - stream = client.audio.tts.http.stream( - TEXT, - model=MODEL, - voice=VOICE, - ) - with open("tts_output.wav", "wb") as f: - for chunk in stream: - if chunk.audio: - f.write(chunk.audio) - print("audio saved to tts_output.wav") - - -# --- Production springboard (Best Practices & Patterns) ------- -# Minimal runnable atomic call above; to productionize it, see: -# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py -# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py -# No dedicated harness for this capability yet; saving the -# stream to disk plus the generic patterns above is enough. -# ------------------------------------------------------------- diff --git a/examples/04_real_world_scenarios/README.md b/examples/04_real_world_scenarios/README.md index 5b00078..2636abe 100644 --- a/examples/04_real_world_scenarios/README.md +++ b/examples/04_real_world_scenarios/README.md @@ -145,7 +145,7 @@ The offline demo runs 4 rooms concurrently, reports `completed` / `degraded` sep ### Adapt it into your own meeting platform 1. **Swap the audio source**: `_read_pcm` currently reads local 16 kHz PCM/WAV; in production feed `_pump_audio` frames from a microphone / RTC downlink / message-queue byte stream, keeping `pace=True` for realtime pacing. -2. **True speak-while-transcribing**: the demo is "send all audio → stop → read events" (consistent with `01_atomic_capabilities/audio_speech/tingwu.py`, for offline determinism); for live-caption style, drain `realtime.events()` on a reader thread concurrently while sending — the socket itself is full-duplex. +2. **True speak-while-transcribing**: the demo is "send all audio → stop → read events" (consistent with `01_atomic_capabilities/audio/tingwu.py`, for offline determinism); for live-caption style, drain `realtime.events()` on a reader thread concurrently while sending — the socket itself is full-duplex. 3. **Tune concurrency and admission**: `MAX_CONCURRENT` is the active-socket cap — set it by your machine's file-descriptor / thread budget; `RECONNECT_ATTEMPTS` and `BASE_DELAY` (production suggests ≈0.5s) control reconnect aggressiveness. 4. **Plug in persistence and observability**: `MeetingSession.transition` is the single exit for state; in production wire it to logging / metrics / meeting-record storage, persisting transcripts and states by `meeting_id`. diff --git a/examples/04_real_world_scenarios/README_zh.md b/examples/04_real_world_scenarios/README_zh.md index bbe9196..59c5ded 100644 --- a/examples/04_real_world_scenarios/README_zh.md +++ b/examples/04_real_world_scenarios/README_zh.md @@ -145,7 +145,7 @@ MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_m ### 改造成你自己的会议平台 1. **换音频源**:`_read_pcm` 目前读本地 16 kHz PCM/WAV;生产环境从麦克风 / RTC 下行 / 消息队列字节流向 `_pump_audio` 喂帧,保持 `pace=True` 的实时节奏。 -2. **真正的边说边转写**:演示是"发完全部音频 → stop → 读事件"(与 `01_atomic_capabilities/audio_speech/tingwu.py` 一致,为了离线确定性);实时字幕风格需要在发送的同时用读线程排空 `realtime.events()`——socket 本身是全双工的。 +2. **真正的边说边转写**:演示是"发完全部音频 → stop → 读事件"(与 `01_atomic_capabilities/audio/tingwu.py` 一致,为了离线确定性);实时字幕风格需要在发送的同时用读线程排空 `realtime.events()`——socket 本身是全双工的。 3. **调并发与准入**:`MAX_CONCURRENT` 是活跃 socket 上限——按你机器的 fd / 线程预算设定;`RECONNECT_ATTEMPTS` 和 `BASE_DELAY`(生产建议 ≈0.5s)控制重连激进程度。 4. **接持久化与可观测性**:`MeetingSession.transition` 是状态的唯一出口;生产环境接到日志 / 指标 / 会议记录存储上,按 `meeting_id` 持久化转写稿与状态。 diff --git a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py index 1906a53..13ce5c3 100644 --- a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +++ b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py @@ -174,7 +174,7 @@ def _pump_audio(session, realtime): Production variant: to transcribe *live* rather than after stop, drain ``realtime.events()`` on a reader thread while frames are still being sent -- the socket is duplex. The sequential shape here matches - 01_atomic_capabilities/audio_speech/tingwu.py and keeps the offline + 01_atomic_capabilities/audio/tingwu.py and keeps the offline mock deterministic. """ for frame in session.frames: diff --git a/examples/README.md b/examples/README.md index 7608950..fbb4584 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,9 +93,8 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `text_generation/chat.py` | Text generation: one-line `dashscope.chat()`, non-streaming, streaming (text_stream) | | `text_generation/tokenization.py` | Token counting | | `multimodal_vision/multimodal_chat.py` | Multimodal chat (qwen-vl image understanding) | -| `audio_speech/audio_tts.py` | Speech synthesis (one-shot + streaming); 2.0 audio API is a preview and may change | -| `audio_speech/audio_asr.py` | Batch speech recognition; 2.0 audio API is a preview and may change | -| `audio_speech/tingwu.py` | TingWu offline tasks and realtime sessions | +| `audio/` | Speech samples: ASR/TTS/realtime/voice CLIs (see `audio/README.md`; `direct_call/` holds the no-`with` variants) | +| `audio/tingwu.py` | TingWu offline tasks and realtime sessions | | `image_video_synthesis/images.py` | Image generation: one-call generate-and-wait + the manual jobs flow | | `image_video_synthesis/videos.py` | Video generation: one-call generate-and-wait + the manual jobs flow | | `embeddings/embeddings.py` | Text embedding (with batch-task entry notes) | diff --git a/examples/README_zh.md b/examples/README_zh.md index 6759b7e..73db121 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -93,9 +93,8 @@ examples/ | `text_generation/chat.py` | 文本生成:一行 `dashscope.chat()`、非流式、流式(text_stream) | | `text_generation/tokenization.py` | token 计数 | | `multimodal_vision/multimodal_chat.py` | 多模态对话(qwen-vl 图像理解) | -| `audio_speech/audio_tts.py` | 语音合成(一次性 + 流式);2.0 audio API 为预览,后续可能变更 | -| `audio_speech/audio_asr.py` | 批量语音识别;2.0 audio API 为预览,后续可能变更 | -| `audio_speech/tingwu.py` | 听悟离线任务与实时会话 | +| `audio/` | 语音示例:ASR/TTS/realtime/音色 CLI(见 `audio/README.md`;`direct_call/` 为不使用 `with` 的副本) | +| `audio/tingwu.py` | 听悟离线任务与实时会话 | | `image_video_synthesis/images.py` | 图像生成:一键生成并等待 + 手动 jobs 流程 | | `image_video_synthesis/videos.py` | 视频生成:一键生成并等待 + 手动 jobs 流程 | | `embeddings/embeddings.py` | 文本向量(含批量任务入口说明) | diff --git a/tests/unit/test_acli_usage_report.py b/tests/unit/test_acli_usage_report.py index 634e168..dd5a7e8 100644 --- a/tests/unit/test_acli_usage_report.py +++ b/tests/unit/test_acli_usage_report.py @@ -126,6 +126,30 @@ def _read(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) +def _await_usage(path: Path, predicate: Callable[[dict], bool]) -> dict: + """Return the first flushed usage satisfying ``predicate``. + + The flusher ticks on a daemon thread a shared CI runner can starve for + longer than any fixed sleep's slack (a 0.3s sleep leaves the duration + assertion only 0.1s), so poll instead of sleep-then-read. + """ + deadline = time.monotonic() + 5.0 + usage: dict | None = None + while time.monotonic() < deadline: + try: + usage = _read(path) + except FileNotFoundError: + # The first tick is what creates the file; it may not have landed. + pass + else: + if predicate(usage): + return usage + time.sleep(0.05) + raise AssertionError( + f"no flush within 5s satisfied the predicate; last read: {usage}", + ) + + def test_the_flusher_writes_while_nothing_is_streaming( tmp_path, ticking_flusher, @@ -138,9 +162,7 @@ def test_the_flusher_writes_while_nothing_is_streaming( executor = Executor() ticking_flusher(str(path), executor) executor.record_api_call({"input_tokens": 111, "output_tokens": 222}) - time.sleep(0.3) - usage = _read(path) - assert usage["api_calls"] == 1 + usage = _await_usage(path, lambda u: u["api_calls"] == 1) assert usage["input_tokens"] == 111 assert usage["output_tokens"] == 222 @@ -156,8 +178,8 @@ def test_duration_keeps_advancing_after_the_run_goes_quiet( _write_usage_file(str(path), executor) first = _read(path)["duration_sec"] ticking_flusher(str(path), executor) - time.sleep(0.3) - assert _read(path)["duration_sec"] > first + 0.2 + usage = _await_usage(path, lambda u: u["duration_sec"] > first + 0.2) + assert usage["duration_sec"] > first + 0.2 def test_stop_waits_for_a_tick_that_is_already_in_flight( @@ -250,6 +272,6 @@ def test_a_tick_that_raises_does_not_stop_the_flusher( path = tmp_path / "usage.json" executor = RacyExecutor(2) ticking_flusher(str(path), executor) - time.sleep(0.4) + usage = _await_usage(path, lambda u: u["duration_sec"] > 0.2) assert executor.failures_left == 0 - assert _read(path)["duration_sec"] > 0.2 + assert usage["duration_sec"] > 0.2 diff --git a/tests/v2/protocols/test_anthropic_conformance.py b/tests/v2/protocols/test_anthropic_conformance.py index 09a4b3b..4d86b07 100644 --- a/tests/v2/protocols/test_anthropic_conformance.py +++ b/tests/v2/protocols/test_anthropic_conformance.py @@ -22,8 +22,13 @@ # pylint: disable=protected-access import json +from copy import deepcopy + +import httpx +import pytest from dashscope.acli.providers.anthropic import AnthropicProvider +from dashscope.v2 import AsyncClient, Client from dashscope.v2.protocols import AnthropicProtocol MODEL = "qwen3-max" @@ -212,3 +217,110 @@ def test_text_content_parts(): ) request = _v2_request([{"role": "user", "content": list(parts)}]) assert request.body["messages"] == acli_messages + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "entry_point", + ["named", "parameters", "parameters_override"], +) +@pytest.mark.parametrize( + "tool_choice, expected", + [ + (None, None), + ("auto", {"type": "auto"}), + ("none", {"type": "none"}), + ("required", {"type": "any"}), + ( + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "tool", "name": "get_weather"}, + ), + ( + {"type": "tool", "name": "get_weather"}, + {"type": "tool", "name": "get_weather"}, + ), + ( + {"type": "any", "disable_parallel_tool_use": True}, + {"type": "any", "disable_parallel_tool_use": True}, + ), + ], +) +async def test_public_tool_choice_translation_and_null_compatibility( + monkeypatch, + asynchronous, + entry_point, + tool_choice, + expected, +): + monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr( + "dashscope.v2._config._api_key_from_file", + lambda: None, + ) + via_parameters = entry_point != "named" + kwargs = {"tool_choice": deepcopy(tool_choice)} + if via_parameters: + kwargs = { + "parameters": { + "tool_choice": deepcopy(tool_choice), + "metadata": {"trace": "test-only"}, + }, + } + if entry_point == "parameters_override": + kwargs["tool_choice"] = "required" + original = deepcopy(kwargs) + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response( + 200, + json={ + "id": "test-message", + "model": MODEL, + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + }, + ) + + base_url = "https://anthropic.test.local" + http_client = (httpx.AsyncClient if asynchronous else httpx.Client)( + transport=httpx.MockTransport(handler), + base_url=base_url, + ) + client = (AsyncClient if asynchronous else Client)( + api_key="sk-test-only", + base_url=base_url, + protocol="anthropic", + max_retries=0, + http_client=http_client, + ) + try: + result = client.chat.completions.create( + model=MODEL, + messages="hi", + **kwargs, + ) + if asynchronous: + result = await result + assert result.output_text == "ok" + assert len(requests) == 1 + assert str(requests[0].url) == f"{base_url}/v1/messages" + body = json.loads(requests[0].content) + if via_parameters or tool_choice is not None: + assert "tool_choice" in body + assert body["tool_choice"] == expected + else: + assert "tool_choice" not in body + if via_parameters: + assert body["metadata"] == original["parameters"]["metadata"] + assert kwargs == original + finally: + if asynchronous: + await client.aclose() + await http_client.aclose() + else: + client.close() + http_client.close() diff --git a/tests/v2/smoke/test_full_surface.py b/tests/v2/smoke/test_full_surface.py index a91c1d0..7b69cbb 100644 --- a/tests/v2/smoke/test_full_surface.py +++ b/tests/v2/smoke/test_full_surface.py @@ -255,31 +255,6 @@ def handler(request): assert done.succeeded -def test_smoke_decision(): - def handler(request): - assert request.url.path == "/api/v1/systemone" - body = json.loads(request.content) - assert body["questions"]["refund"]["type"] == "noul" - return _json( - { - "request_id": "req-dec", - "model": "decision-model-preview", - "answers": {"refund": {"type": "noul", "noul": 0.99}}, - "usage": {"input_tokens": 9}, - "latency_ms": 12.3, - }, - ) - - client = _make_client(handler) - resp = client.decision.create( - model="decision-model-preview", - state="这段评论是否在索要退款?", - questions={"refund": {"type": "noul"}}, - ) - assert resp.answers["refund"].noul == 0.99 - assert resp.usage.input_tokens == 9 - - def test_smoke_files_models(tmp_path): data_file = tmp_path / "train.jsonl" data_file.write_text('{"prompt": "hi"}\n', encoding="utf-8") @@ -397,7 +372,16 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True @@ -635,14 +619,6 @@ def handler(request): "output": {"text": "hi"}, }, ) - if request.url.path.endswith("/systemone"): - return _json( - { - "request_id": "req-dec", - "model": "decision-model-preview", - "answers": {"q": {"type": "noul", "noul": 0.5}}, - }, - ) if request.method == "POST": return _json( { @@ -674,12 +650,6 @@ def handler(request): messages=[{"role": "user", "content": "hi"}], ) assert resp.output_text == "Hello" - decision = await client.decision.create( - model="decision-model-preview", - state="s", - questions={"q": {"type": "noul"}}, - ) - assert decision.answers["q"].noul == 0.5 img = await client.images.generate( model="wanx2.1-t2i", prompt="a cat", diff --git a/tests/v2/test_agentstudio_errors.py b/tests/v2/test_agentstudio_errors.py index 2c45e7c..01840c0 100644 --- a/tests/v2/test_agentstudio_errors.py +++ b/tests/v2/test_agentstudio_errors.py @@ -2,7 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the AgentStudio -> v2 error-alignment proxy (M3 3.12). -Three things are pinned here: +Two things are pinned here: * :func:`translate_agentstudio_error` maps the 1.x AgentStudio family onto the v2 family by *two* routes -- HTTP-status errors through @@ -10,9 +10,6 @@ through ``error_from_sdk_exception``. * The transparent proxy keeps ``isinstance`` / ``.transport`` working while making every failure catchable as ``DashScopeError``. -* Lazily-failing return values -- event streams and cursor pages -- - translate errors raised during iteration and pagination, not just - errors raised by the call itself. """ # Mock handlers intentionally ignore the request argument. @@ -20,6 +17,9 @@ from __future__ import annotations +import asyncio +import json + import httpx import pytest @@ -27,6 +27,7 @@ from dashscope.agentstudio import Client as AgentStudioClient from dashscope.agentstudio import exceptions as as_exc from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage +from dashscope.agentstudio.types import ServerEvent, Session from dashscope.v2 import errors as v2_errors from dashscope.v2.resources._agentstudio import ( _wrap_result, @@ -37,6 +38,17 @@ BASE_URL = "https://as.test.local" +@pytest.fixture(autouse=True) +def _clear_credentials(monkeypatch): + for name in ( + "DASHSCOPE_API_KEY", + "GATEWAY_API_KEY", + "GATEWAY_MODEL", + "GATEWAY_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + + # --------------------------------------------------------------------------- # Two-branch translation, exercised directly (no network) # --------------------------------------------------------------------------- @@ -242,127 +254,504 @@ def test_sync_stream_proxy_translates_iteration_error(): assert ei.value.code == "sdk.agentstudio.StreamClosedError" -# --------------------------------------------------------------------------- -# Cursor pages: the fetch_next closure captures the raw 1.x resource -# method, so pagination fetches bypass the method guards and the page -# proxy must translate errors raised while iterating / calling get_next() -# --------------------------------------------------------------------------- +@pytest.mark.parametrize("compat", [False, True]) +@pytest.mark.parametrize("header_id", [None, "header-id"]) +def test_translation_preserves_v1_metadata(compat, header_id): + body = { + "type": "error", + "message": "top-level message must not replace the parsed message", + "request_id": "body-id", + "error": { + "error_code" if compat else "code": "rate_limit_error", + "error_message" if compat else "message": "canonical message", + }, + } + headers = {"x-request-id": header_id} if header_id else {} + original = as_exc.from_response( + status_code=429, + body=body, + headers=headers, + ) + translated = translate_agentstudio_error(original) + assert isinstance(translated, v2_errors.RateLimitError) + assert ( + translated.request_id + == original.request_id + == (header_id or "body-id") + ) + assert translated.code == original.code == "rate_limit_error" + assert translated.message == original.message == "canonical message" + assert translated.body is body + assert body["request_id"] == "body-id" -def _sync_page(items, fetch_next): - return CursorPage( - data=items, - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, +def test_agentstudio_code_does_not_override_http_status(): + original = as_exc.from_response( + status_code=429, + body={"error": {"code": "InvalidApiKey", "message": "quota"}}, ) + translated = translate_agentstudio_error(original) + assert isinstance(translated, v2_errors.RateLimitError) + assert translated.code == "InvalidApiKey" + assert translated.body is original.raw + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_proxy_observes_attribute_and_method_replacements(asynchronous): + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(lambda request: httpx.Response(200, json={"id": "s-1"})) + target = object.__getattribute__(proxy, "_target") + http_client = target.transport._client + try: + target.labels = {"before": True} + assert proxy.labels is target.labels + proxy.labels = {"after": True} + assert proxy.labels == {"after": True} + target.labels = {"direct": True} + assert proxy.labels is target.labels + assert proxy.labels == {"direct": True} + assert "labels" not in object.__getattribute__(proxy, "_cache") + + resource = proxy.sessions + assert proxy.sessions is resource + result = resource.retrieve("s-1") + if asynchronous: + result = await result + assert type(result) is Session + assert result.id == "s-1" + assert "retrieve" not in object.__getattribute__(resource, "_cache") + + first = Session(id="replacement") + + async def async_retrieve(session_id): + return first + + replacement = async_retrieve if asynchronous else lambda _: first + resource.retrieve = replacement + result = resource.retrieve("s-1") + assert (await result if asynchronous else result) is first + + async def fail(session_id): + raise as_exc.RateLimitError("new method") + + def sync_fail(session_id): + raise as_exc.RateLimitError("new method") + + target.sessions.retrieve = fail if asynchronous else sync_fail + with pytest.raises(v2_errors.RateLimitError): + result = resource.retrieve("s-1") + if asynchronous: + await result + + target.sessions = type(target.sessions)(target) + assert proxy.sessions is not resource + assert proxy.sessions.events is proxy.sessions.events + resource = proxy.sessions + proxy.sessions = type(target.sessions)(target) + assert proxy.sessions is not resource + + transport = proxy.transport + proxy.transport = object() + assert proxy.transport is target.transport + assert proxy.transport is not transport + target.transport = transport + assert proxy.transport is transport + finally: + if asynchronous: + await proxy.aclose() + assert not http_client.is_closed + await http_client.aclose() + else: + proxy.close() + assert not http_client.is_closed + http_client.close() + + +def _page_handler(requests, fail_at=None): + def handler(request): + cursor = request.url.params.get("page") + number = int(cursor) if cursor else 1 + requests.append(number) + assert request.url.params["limit"] == "1" + if number == fail_at: + return httpx.Response( + 429, + headers={"x-request-id": "page-header"}, + json={ + "type": "error", + "error": {"error_code": "quota", "error_message": "slow"}, + "request_id": "page-body", + }, + ) + return httpx.Response( + 200, + json={ + "data": [{"id": f"s-{number}", "title": "session"}], + "next_page": str(number + 1) if number < 3 else None, + "request_id": f"page-{number}", + }, + ) + return handler -def test_sync_page_proxy_translates_auto_pagination_error(): - def fetch_next(cursor): - raise as_exc.APIConnectionError("dropped mid-pagination") - page = _wrap_result(_sync_page([{"id": "a-1"}], fetch_next)) - iterator = iter(page) - assert next(iterator) == {"id": "a-1"} - with pytest.raises(v2_errors.SdkError) as ei: - next(iterator) - assert ei.value.code == "sdk.agentstudio.APIConnectionError" +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("auto", [False, True]) +@pytest.mark.parametrize("fail_at", [2, 3]) +@pytest.mark.asyncio +async def test_pagination_lazily_translates_later_pages( + asynchronous, + auto, + fail_at, +): + requests = [] + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(_page_handler(requests, fail_at)) + try: + page = proxy.sessions.list(limit=1) + if asynchronous: + page = await page + assert isinstance( + page, + AsyncCursorPage if asynchronous else CursorPage, + ) + assert len(page) == 1 + assert page.has_next() + assert page.request_id == "page-1" + assert type(page.data[0]) is Session + assert requests == [1] + with pytest.raises(v2_errors.RateLimitError) as caught: + if auto: + if asynchronous: + iterator = page.__aiter__() + assert await iterator.__anext__() is page.data[0] + assert requests == [1] + async for _ in iterator: + pass + else: + iterator = iter(page) + assert next(iterator) is page.data[0] + assert requests == [1] + list(iterator) + else: + while page.has_next(): + page = page.get_next() + if asynchronous: + page = await page + assert requests == list(range(1, fail_at + 1)) + assert caught.value.code == "quota" + assert caught.value.request_id == "page-header" + assert caught.value.message == "slow" + assert caught.value.status_code == 429 + assert isinstance(caught.value.__cause__, as_exc.RateLimitError) + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_pagination_success_preserves_models_and_mutability( + asynchronous, +): + requests = [] + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(_page_handler(requests)) + try: + page = proxy.sessions.list(limit=1) + if asynchronous: + page = await page + items = [item async for item in page] if asynchronous else list(page) + assert [item.id for item in items] == ["s-1", "s-2", "s-3"] + assert all(type(item) is Session for item in items) + assert items[0] is page.data[0] + assert len(page) == 1 + page.data = [Session(id="local")] + page.next_page = None + assert not page.has_next() + result = page.get_next() + assert (await result if asynchronous else result) is None + assert page.data[0].to_dict()["id"] == "local" + assert requests == [1, 2, 3] + page.data = [] + assert len(page) == 0 + assert not page + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +_SSE_EVENTS = [ + { + "id": "m-1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + }, + { + "type": "event_delta", + "event_id": "m-1", + "delta": { + "type": "content_delta", + "index": 0, + "content": {"type": "text", "text": "delta"}, + }, + }, +] + + +def _sse_frames(): + for event in _SSE_EVENTS: + yield f"event: message\ndata: {json.dumps(event)}\n\n".encode() + + +class _SyncSSEBody(httpx.SyncByteStream): + def __init__(self, error=None): + self.error = error + self.closed = 0 + + def __iter__(self): + yield from _sse_frames() + if self.error is not None: + raise self.error + def close(self): + self.closed += 1 -def test_sync_page_proxy_translates_get_next_error(): - def fetch_next(cursor): - raise as_exc.NotFoundError("gone", status_code=404, request_id="r-2") - page = _wrap_result(_sync_page([], fetch_next)) - with pytest.raises(v2_errors.NotFoundError): - page.get_next() +class _AsyncSSEBody(httpx.AsyncByteStream): + def __init__(self, error=None): + self.error = error + self.closed = 0 + async def __aiter__(self): + for frame in _sse_frames(): + yield frame + if self.error is not None: + raise self.error -def test_sync_page_proxy_stays_transparent_and_wraps_next_page(): - second = CursorPage( - data=[{"id": "a-2"}], - next_page=None, - request_id="req-p2", - ) - page = _wrap_result(_sync_page([{"id": "a-1"}], lambda cursor: second)) - assert isinstance(page, CursorPage) - assert len(page) == 1 - assert page.data == [{"id": "a-1"}] - assert page.has_next() - assert list(page) == [{"id": "a-1"}, {"id": "a-2"}] - page2 = page.get_next() - assert isinstance(page2, CursorPage) - assert list(page2) == [{"id": "a-2"}] - assert page2.get_next() is None + async def aclose(self): + self.closed += 1 -@pytest.mark.asyncio -async def test_async_page_proxy_translates_auto_pagination_error(): - async def fetch_next(cursor): - raise as_exc.APITimeoutError("timed out mid-pagination") - - page = _wrap_result( - AsyncCursorPage( - data=[{"id": "a-1"}], - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, - ), +def _sse_client(asynchronous, error=None): + body = _AsyncSSEBody(error) if asynchronous else _SyncSSEBody(error) + response = httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=body, ) - seen = [] - with pytest.raises(v2_errors.SdkError) as ei: - async for item in page: - seen.append(item) - assert seen == [{"id": "a-1"}] - assert ei.value.code == "sdk.agentstudio.APITimeoutError" + factory = _make_async_proxy if asynchronous else _make_proxy + return factory(lambda request: response), response, body + +async def _consume_stream(stream, view, asynchronous): + source = stream if view == "events" else getattr(stream, view) + return [item async for item in source] if asynchronous else list(source) + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.parametrize( + "error_cls,code", + [ + (httpx.ReadTimeout, "sdk.agentstudio.APITimeoutError"), + (httpx.ReadError, "sdk.agentstudio.APIConnectionError"), + (httpx.RemoteProtocolError, "sdk.agentstudio.APIConnectionError"), + ], +) @pytest.mark.asyncio -async def test_async_page_proxy_translates_get_next_error(): - async def fetch_next(cursor): - raise as_exc.RateLimitError("slow", status_code=429, request_id="r-2") - - page = _wrap_result( - AsyncCursorPage( - data=[], - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, - ), - ) - with pytest.raises(v2_errors.RateLimitError): - await page.get_next() +async def test_real_sse_transport_errors(asynchronous, view, error_cls, code): + original = error_cls("SSE transport failed") + proxy, response, body = _sse_client(asynchronous, original) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + assert not response.is_closed + with pytest.raises(v2_errors.SdkError) as caught: + await _consume_stream(stream, view, asynchronous) + assert caught.value.code == code + assert caught.value.__cause__ is original + assert caught.value.message == "SSE transport failed" + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.asyncio +async def test_real_sse_closed_views(asynchronous, view): + proxy, response, body = _sse_client(asynchronous) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + await stream.aclose() + await stream.aclose() + else: + stream.close() + stream.close() + assert response.is_closed + assert body.closed == 1 + with pytest.raises(v2_errors.SdkError) as caught: + await _consume_stream(stream, view, asynchronous) + assert caught.value.code == "sdk.agentstudio.StreamClosedError" + assert isinstance(caught.value.__cause__, as_exc.StreamClosedError) + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.parametrize("early_exit", [False, True]) +@pytest.mark.asyncio +async def test_real_sse_normal_results_and_context_close( + asynchronous, + view, + early_exit, +): + proxy, response, body = _sse_client(asynchronous) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + async with stream as entered: + assert entered is stream + if early_exit: + source = ( + stream if view == "events" else getattr(stream, view) + ) + iterator = source.__aiter__() + await iterator.__anext__() + else: + items = await _consume_stream(stream, view, asynchronous) + if early_exit: + await iterator.aclose() + else: + with stream as entered: + assert entered is stream + if early_exit: + source = ( + stream if view == "events" else getattr(stream, view) + ) + iterator = iter(source) + next(iterator) + else: + items = await _consume_stream(stream, view, asynchronous) + if early_exit: + iterator.close() + if not early_exit: + if view == "events": + assert all(type(item) is ServerEvent for item in items) + assert [item.type for item in items] == [ + "message", + "event_delta", + ] + else: + assert items == ["hello" if view == "text_stream" else "delta"] + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "error_cls", + [ValueError, TypeError, httpx.LocalProtocolError], +) +@pytest.mark.asyncio +async def test_real_sse_programming_errors_are_not_translated( + asynchronous, + error_cls, +): + original = error_cls("programming error") + proxy, response, body = _sse_client(asynchronous, original) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + with pytest.raises(error_cls) as caught: + await _consume_stream(stream, "text_stream", asynchronous) + assert caught.value is original + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() -def test_proxy_translates_pagination_fetch_error_end_to_end(): - # Page 2's fetch goes through the raw 1.x resource method captured in - # fetch_next -- without the page proxy this raises the 1.x error. - def handler(request): - if "page=cursor-2" in str(request.url): - return httpx.Response( - 404, - json={ - "type": "error", - "error": { - "code": "not_found_error", - "message": "no such page", - }, - "request_id": "req-p2", - }, +@pytest.mark.asyncio +async def test_real_sse_consumer_cancellation_is_not_translated(): + reading = asyncio.Event() + + class BlockingBody(_AsyncSSEBody): + async def __aiter__(self): + reading.set() + await asyncio.Event().wait() + yield b"" + + body = BlockingBody() + response = httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=body, + ) + proxy = _make_async_proxy(lambda request: response) + try: + stream = await proxy.sessions.events.stream("s-1") + async with stream: + task = asyncio.create_task( + _consume_stream(stream, "text_stream", True), ) - return httpx.Response( - 200, - json={ - "data": [{"id": "a-1", "name": "demo", "model": "qwen-plus"}], - "next_page": "cursor-2", - "request_id": "req-p1", - }, - ) + await asyncio.wait_for(reading.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert response.is_closed + assert body.closed == 1 + finally: + await proxy.transport._client.aclose() - proxy = _make_proxy(handler) - page = proxy.agents.list() - assert isinstance(page, CursorPage) - with pytest.raises(v2_errors.NotFoundError): - list(page) - proxy.close() + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_standalone_v1_keeps_native_errors(asynchronous): + original = httpx.ReadTimeout("native read timeout") + proxy, response, body = _sse_client(asynchronous, original) + client = object.__getattribute__(proxy, "_target") + try: + stream = client.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + with pytest.raises(httpx.ReadTimeout) as caught: + await _consume_stream(stream, "text_stream", asynchronous) + assert caught.value is original + with pytest.raises(as_exc.StreamClosedError): + await _consume_stream(stream, "text_stream", asynchronous) + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await client.transport._client.aclose() + else: + client.transport._client.close() diff --git a/tests/v2/test_audio.py b/tests/v2/test_audio.py index 9702786..e918f51 100644 --- a/tests/v2/test_audio.py +++ b/tests/v2/test_audio.py @@ -13,9 +13,11 @@ import pytest from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import DashScopeError +from dashscope.v2.errors import APIConnectionError, DashScopeError +from dashscope.v2.resources import audio as audio_resources from dashscope.v2.resources.audio._callbacks import run_with_callback from dashscope.v2.resources.audio.asr import RecognitionResult +from dashscope.v2.transport.ws import WsEventError BASE_URL = "https://api.test.local/api/v1" TRANSCRIPTION_PATH = "/api/v1/services/audio/asr/transcription" @@ -65,7 +67,19 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + try: + event = json.loads(raw) + except ValueError: + return raw + header = event.get("header") if isinstance(event, dict) else None + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True @@ -87,6 +101,21 @@ def _sent_frames(ws): return [json.loads(item) for item in ws.sent if isinstance(item, str)] +def test_audio_public_resources_exclude_translation(): + with _make_bare_client() as client: + assert isinstance(client.audio, audio_resources.Audio) + assert not hasattr(client.audio, "translation") + assert isinstance(client.audio.asr, audio_resources.Asr) + assert isinstance(client.audio.tts, audio_resources.Tts) + assert isinstance(client.audio.realtime, audio_resources.Realtime) + assert callable(client.audio.asr.recognize) + assert callable(client.audio.tts.synthesize) + assert callable(client.audio.realtime.connect) + for name in ("translation", "Translation", "TranslationSession"): + assert not hasattr(audio_resources, name) + assert name not in audio_resources.__all__ + + def test_transcription_transcribe_submit_and_wait(): captured = {} @@ -128,6 +157,59 @@ def handler(request): assert job.output["results"][0]["transcription_url"] +@pytest.mark.parametrize("entry_point", ["transcribe", "create"]) +@pytest.mark.parametrize( + "parameter_kwargs", + [ + pytest.param({}, id="omitted"), + pytest.param({"parameters": None}, id="none"), + pytest.param({"parameters": {}}, id="empty"), + pytest.param( + { + "parameters": { + "keep_dialect": False, + "vocabulary": {"通义千问": 5}, + "language_hints": ["zh", "en"], + "channel_id": [0], + "diarization_enabled": False, + }, + }, + id="custom", + ), + ], +) +def test_transcription_serializes_parameters(entry_point, parameter_kwargs): + bodies = [] + + def handler(request): + assert request.method == "POST" + assert request.url.path == TRANSCRIPTION_PATH + assert request.headers["X-DashScope-Async"] == "enable" + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + with _make_client(handler) as client: + if entry_point == "transcribe": + job = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + **parameter_kwargs, + ) + else: + job = client.audio.asr.jobs.create( + model="qwen-audio-3.1-asr-flash-filetrans", + file_urls=["https://example.com/a.wav"], + **parameter_kwargs, + ) + assert job.succeeded + assert bodies == [ + { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": parameter_kwargs.get("parameters") or {}, + }, + ] + + def test_transcription_requires_file_urls(): client = _make_bare_client() with pytest.raises(ValueError, match="file_urls"): @@ -164,17 +246,91 @@ def handler(request): @pytest.mark.asyncio -async def test_async_transcription_transcribe(): +@pytest.mark.parametrize( + "model_kwargs, expected_model", + [ + ({}, "qwen-audio-3.1-asr-flash-filetrans"), + ({"model": "paraformer-v2"}, "paraformer-v2"), + ], +) +async def test_async_transcription_transcribe(model_kwargs, expected_model): + captured = {} + def handler(request): if request.method == "POST": + captured["body"] = json.loads(request.content) return httpx.Response(200, json=_task_body("PENDING")) return httpx.Response(200, json=_task_body("SUCCEEDED")) - client = _make_async_client(handler) - job = await client.audio.asr.transcribe( - ["https://example.com/a.wav"], - ) + async with _make_async_client(handler) as client: + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + poll_interval=0, + **model_kwargs, + ) assert job.succeeded is True + assert captured["body"] == { + "model": expected_model, + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": {}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entry_point", ["transcribe", "create"]) +@pytest.mark.parametrize( + "parameter_kwargs", + [ + pytest.param({}, id="omitted"), + pytest.param({"parameters": None}, id="none"), + pytest.param({"parameters": {}}, id="empty"), + pytest.param( + { + "parameters": { + "keep_dialect": False, + "vocabulary": {"通义千问": 5}, + "language_hints": ["zh", "en"], + "channel_id": [0], + "diarization_enabled": False, + }, + }, + id="custom", + ), + ], +) +async def test_async_transcription_serializes_parameters( + entry_point, + parameter_kwargs, +): + bodies = [] + + def handler(request): + assert request.method == "POST" + assert request.url.path == TRANSCRIPTION_PATH + assert request.headers["X-DashScope-Async"] == "enable" + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + async with _make_async_client(handler) as client: + if entry_point == "transcribe": + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + **parameter_kwargs, + ) + else: + job = await client.audio.asr.jobs.create( + model="qwen-audio-3.1-asr-flash-filetrans", + file_urls=["https://example.com/a.wav"], + **parameter_kwargs, + ) + assert job.succeeded + assert bodies == [ + { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": parameter_kwargs.get("parameters") or {}, + }, + ] def test_recognition_session_lifecycle(): @@ -689,3 +845,323 @@ def test_run_with_callback_delivers_errors_via_on_error(): kinds = [call[0] for call in callback.calls] assert kinds == ["on_open", "on_error", "on_close"] + + +def test_recognition_audio_parameter_merge_precedence(): + parameters = { + "format": "wav", + "sample_rate": 8000, + "vocabulary_id": "old", + "diarization_enabled": True, + "disfluency_removal_enabled": True, + "language_hints": ["zh"], + } + original = dict(parameters) + ws = FakeWebSocket([_event("task-started")]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + format="pcm", + sample_rate=16000, + vocabulary_id="new", + diarization_enabled=False, + disfluency_removal_enabled=False, + parameters=parameters, + connector=lambda url, headers: ws, + ): + actual = _sent_frames(ws)[0]["payload"]["parameters"] + assert parameters == original + assert actual == { + **original, + "vocabulary_id": "new", + "diarization_enabled": False, + "disfluency_removal_enabled": False, + } + + +def test_recognition_results_preserve_optional_request_id_and_extra_fields(): + generated = { + "header": {"event": "result-generated", "task_id": "t"}, + "payload": { + "output": {"sentence": {"text": "final", "end_time": 0}}, + "usage": {"duration": 0}, + "future_field": {"ok": True}, + }, + } + ws = FakeWebSocket( + [ + _event("task-started"), + json.dumps(generated), + _event("task-finished"), + ], + ) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + result = next(rec.results()) + assert result.get_sentence()["end_time"] == 0 + assert result.is_sentence_end() + assert result.get_usage() == {"duration": 0} + assert result.future_field == {"ok": True} + assert result.request_id is None + assert rec.request_id == rec.session.task_id + + +def test_recognition_same_object_restarts_with_fresh_state_after_close(): + sockets = [] + + def connector(url, headers): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "sentence": {"text": "hello", "end_time": 0}, + }, + "usage": {"duration": 1}, + }, + ), + _event("task-finished"), + ], + ) + sockets.append(ws) + return ws + + task_ids = [] + with _make_bare_client() as client: + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connector=connector, + ) + for _ in range(2): + with rec: + task_ids.append(rec.session.task_id) + assert rec.usage is None + assert rec.first_package_delay is None + assert rec.last_package_delay is None + assert rec.last_response["header"]["event"] == "task-started" + rec.send_audio_frame(b"audio") + rec.stop() + assert len(list(rec.results())) == 1 + assert rec.usage == {"duration": 1} + assert rec.first_package_delay >= 0 + assert rec.last_package_delay >= 0 + rec.close() + assert task_ids[0] != task_ids[1] + assert len(sockets) == 2 and all(ws.closed for ws in sockets) + + +@pytest.mark.parametrize("raw", [None, "", "peer-close"]) +def test_recognition_eof_before_terminal_is_not_success(raw): + from websocket import WebSocketConnectionClosedException + + class PeerCloseSocket(FakeWebSocket): + def recv(self): + value = super().recv() + if value == "peer-close": + raise WebSocketConnectionClosedException("peer closed") + return value + + ws = PeerCloseSocket([_event("task-started"), raw]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + with pytest.raises( + APIConnectionError, + match="before task-finished", + ): + list(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize( + "raw, error_type", + [ + ("{", json.JSONDecodeError), + ("[]", WsEventError), + ('{"header": []}', WsEventError), + ], +) +def test_recognition_invalid_frame_raises_and_closes(raw, error_type): + ws = FakeWebSocket([_event("task-started"), raw]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + with pytest.raises(error_type): + next(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize("phase", ["start", "result"]) +def test_recognition_rejects_foreign_task_id(phase): + class ForeignTaskSocket(FakeWebSocket): + def send(self, payload, opcode=1): + super().send(payload, opcode=opcode) + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == "run-task" + ): + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.append( + json.dumps( + { + "header": { + "event": "task-started", + "task_id": "foreign-task" + if phase == "start" + else task_id, + }, + "payload": {}, + }, + ), + ) + self.inbox.append( + json.dumps( + { + "header": { + "event": "task-finished", + "task_id": "foreign-task", + }, + "payload": {}, + }, + ), + ) + + ws = ForeignTaskSocket() + with _make_bare_client() as client: + with pytest.raises(WsEventError): + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + if phase == "result": + next(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize( + "action", + ["run-task", "continue-task", "finish-task"], +) +def test_recognition_control_send_failure_closes_and_can_restart(action): + class BrokenControlSocket(FakeWebSocket): + def send(self, payload, opcode=1): + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == action + ): + raise OSError("control send failed") + super().send(payload, opcode=opcode) + + broken = BrokenControlSocket([_event("task-started")]) + healthy = FakeWebSocket([_event("task-started"), _event("task-finished")]) + sockets = iter([broken, healthy]) + with _make_bare_client() as client: + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: next(sockets), + ) + with pytest.raises(OSError, match="control send failed"): + with rec: + if action == "continue-task": + rec.update_context({"context": "next"}) + else: + rec.stop() + assert broken.closed + with rec: + rec.stop() + assert len(list(rec.events())) == 1 + assert healthy.closed + + +def test_recognize_file_close_after_partial_iteration_releases_socket( + tmp_path, +): + path = tmp_path / "local.pcm" + path.write_bytes(b"audio") + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": {"sentence": {"text": "partial"}}}, + ), + _event("task-finished"), + ], + ) + with _make_bare_client() as client: + events = client.audio.asr.recognize_file( + str(path), + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + try: + assert next(events)["header"]["event"] == "result-generated" + finally: + events.close() + assert ws.closed + + +def test_recognize_file_removed_before_iteration_closes_opened_socket( + tmp_path, +): + path = tmp_path / "local.pcm" + path.write_bytes(b"audio") + ws = FakeWebSocket([_event("task-started")]) + with _make_bare_client() as client: + events = client.audio.asr.recognize_file( + str(path), + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + path.unlink() + with pytest.raises(FileNotFoundError): + next(events) + assert ws.closed + + +@pytest.mark.parametrize("family", ["asr", "tts"]) +def test_audio_ws_call_auth_is_case_insensitive_and_isolated(family): + captured = [] + headers = { + "authorization": "Bearer old-key", + "x-dashscope-workspace": "old-workspace", + "User-Agent": "custom-agent", + } + original = dict(headers) + + def connector(url, actual): + captured.append(actual) + return FakeWebSocket([_event("task-started")]) + + with _make_bare_client() as client: + connect = ( + client.audio.asr.recognize + if family == "asr" + else client.audio.tts.connect + ) + with connect( + model="test-model", + connector=connector, + headers=headers, + api_key="call-key", + workspace="call-workspace", + ): + pass + with connect(model="test-model", connector=connector): + pass + first, second = [httpx.Headers(value) for value in captured] + assert first.get_list("authorization") == ["Bearer call-key"] + assert first.get_list("x-dashscope-workspace") == ["call-workspace"] + assert first.get_list("user-agent") == ["custom-agent"] + assert second.get_list("authorization") == ["Bearer sk-test"] + assert second.get_list("x-dashscope-workspace") == ["ws-test"] + assert headers == original diff --git a/tests/v2/test_audio_connection.py b/tests/v2/test_audio_connection.py index d93a593..d8d962d 100644 --- a/tests/v2/test_audio_connection.py +++ b/tests/v2/test_audio_connection.py @@ -10,6 +10,7 @@ import pytest from dashscope.v2.client import Client +from dashscope.v2.errors import DashScopeError BASE_URL = "https://api.test.local/api/v1" WS_URL = "wss://ws.test.local/api-ws/v1/inference" @@ -98,29 +99,128 @@ def test_ws_connection_factory_uses_client_config(): def test_recognition_sequential_reuse_on_shared_connection(): client, conn, connects = _make_client_and_connection() ws = connects[0]["ws"] - - for _ in range(2): - rec = client.audio.asr.recognize( - model="paraformer-realtime-v2", - connection=conn, + task_ids = [] + audio_frames = [bytes([index, index + 1]) for index in range(5)] + try: + for index, audio in enumerate(audio_frames): + rec = client.audio.asr.recognize( + model="qwen-audio-3.1-asr-flash-streaming", + connection=conn, + timeout=5, + ) + try: + rec.start() + task_id = rec.session.task_id + task_ids.append(task_id) + rec.send_audio_frame(audio) + assert conn._binary_uplink_owner is rec.session + result = AutoWebSocket._event( + "result-generated", + task_id, + { + "output": { + "sentence": {"text": f"recognized task {index}"}, + }, + }, + ) + ws.inbox.put(result) + rec.stop() + assert list(rec.events()) == [ + json.loads(result), + json.loads(AutoWebSocket._event("task-finished", task_id)), + ] + assert rec.session.finished + assert conn._binary_uplink_owner is None + finally: + rec.close() + assert not conn._tasks + assert not conn.closed and not ws.closed + assert conn._reader.is_alive() + + assert len(connects) == 1 + run_tasks = _run_task_frames(ws) + assert [frame["header"]["task_id"] for frame in run_tasks] == task_ids + assert len(set(task_ids)) == 5 + assert all( + frame["payload"]["model"] == "qwen-audio-3.1-asr-flash-streaming" + for frame in run_tasks ) - rec.start() - rec.send_audio_frame(b"\x00\x01") - rec.stop() - list(rec.events()) - rec.close() - assert not conn.closed - - assert len(connects) == 1 - run_tasks = _run_task_frames(ws) - assert len(run_tasks) == 2 - assert ( - run_tasks[0]["header"]["task_id"] != run_tasks[1]["header"]["task_id"] - ) - # Exactly one binary frame per recognition, both on the shared socket. - assert len([item for item in ws.sent if isinstance(item, bytes)]) == 2 - conn.close() - client.close() + assert [ + item for item in ws.sent if isinstance(item, bytes) + ] == audio_frames + finally: + try: + conn.close() + finally: + client.close() + assert conn.closed and ws.closed + assert not conn._reader.is_alive() + + +def test_synthesis_sequential_reuse_on_shared_connection(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + task_ids = [] + texts = [f"Synthesize task {index}." for index in range(5)] + try: + for index, text in enumerate(texts): + tts = client.audio.tts.connect( + model="qwen-audio-3.1-tts-flash", + voice="longanhuan_v3.1", + connection=conn, + timeout=5, + ) + try: + tts.start() + task_id = tts.session.task_id + task_ids.append(task_id) + assert conn._binary_downlink_owner is tts.session + tts.send_text(text) + audio = f"synthesized-audio-{index}".encode("utf-8") + ws.inbox.put(audio) + tts.stop() + assert list(tts.audio_chunks()) == [audio] + assert tts.last_response == json.loads( + AutoWebSocket._event("task-finished", task_id), + ) + assert tts.session.finished + assert conn._binary_downlink_owner is None + finally: + tts.close() + assert not conn._tasks + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + assert not conn.closed and not ws.closed + assert conn._reader.is_alive() + + assert len(connects) == 1 + run_tasks = _run_task_frames(ws) + assert [frame["header"]["task_id"] for frame in run_tasks] == task_ids + assert len(set(task_ids)) == 5 + assert all( + frame["payload"]["model"] == "qwen-audio-3.1-tts-flash" + and frame["payload"]["parameters"]["voice"] == "longanhuan_v3.1" + for frame in run_tasks + ) + text_frames = [ + json.loads(item) + for item in ws.sent + if isinstance(item, str) + and json.loads(item)["header"].get("action") == "continue-task" + ] + assert [ + frame["header"]["task_id"] for frame in text_frames + ] == task_ids + assert [frame["payload"]["input"] for frame in text_frames] == [ + {"text": text} for text in texts + ] + finally: + try: + conn.close() + finally: + client.close() + assert conn.closed and ws.closed + assert not conn._reader.is_alive() def test_recognition_and_synthesis_concurrently_on_one_connection(): @@ -269,3 +369,183 @@ def connector(url, headers): assert sockets[0]["ws"].closed assert sockets[1]["ws"].closed client.close() + + +@pytest.fixture +def bounded_audio_connection(): + resources = [] + + def make(ws=None): + ws = AutoWebSocket() if ws is None else ws + client = Client(api_key="sk-test", base_url=BASE_URL, timeout=0.5) + try: + conn = client.ws_connection( + url=WS_URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + except BaseException: + client.close() + raise + resources.append((client, conn)) + return client, conn, ws + + yield make + for client, conn in resources: + try: + conn.close() + conn._reader.join(timeout=1) + assert not conn._reader.is_alive(), "websocket reader leaked" + finally: + client.close() + + +@pytest.mark.parametrize( + "reply", + ["task-failed", "result-generated", "timeout"], +) +def test_shared_recognition_start_failure_detaches_and_allows_retry( + bounded_audio_connection, + reply, +): + class FailOnceSocket(AutoWebSocket): + failed_once = False + + def send(self, payload, opcode=1): + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == "run-task" + ): + if not self.failed_once: + self.failed_once = True + self.sent.append(payload) + if reply != "timeout": + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.put(self._event(reply, task_id)) + return + super().send(payload, opcode=opcode) + + client, conn, ws = bounded_audio_connection(FailOnceSocket()) + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + timeout=0.05, + ) + try: + expected = TimeoutError if reply == "timeout" else DashScopeError + with pytest.raises(expected): + rec.start() + assert not conn._tasks + assert not conn.closed and not ws.closed + with pytest.raises(RuntimeError, match="not open"): + _ = rec.session + with rec: + rec.send_audio_frame(b"retry") + rec.stop() + assert list(rec.events())[-1]["header"]["event"] == "task-finished" + assert not conn._tasks + assert len(_run_task_frames(ws)) == 2 + finally: + rec.close() + + +def test_shared_recognition_same_object_restarts_and_releases_owners( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + rec = client.audio.asr.recognize(model="fun-asr-realtime", connection=conn) + ids = [] + try: + for _ in range(2): + with rec: + ids.append(rec.session.task_id) + assert rec.usage is None and rec.first_package_delay is None + assert rec.last_package_delay is None + rec.send_audio_frame(b"audio") + ws.inbox.put( + ws._event( + "result-generated", + rec.session.task_id, + { + "output": { + "sentence": {"text": "hello", "end_time": 0}, + }, + "usage": {"duration": 1}, + }, + ), + ) + rec.stop() + assert len(list(rec.results())) == 1 + assert rec.usage == {"duration": 1} + assert not conn._tasks + assert conn._binary_uplink_owner is None + assert not conn.closed + assert ids[0] != ids[1] + assert len(_run_task_frames(ws)) == 2 + finally: + rec.close() + + +def test_shared_recognition_close_after_uplink_conflict_does_not_break_owner( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + first = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + second = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + try: + first.start() + second.start() + first.send_audio_frame(b"first") + with pytest.raises(RuntimeError, match="binary uplink"): + second.send_audio_frame(b"blocked") + second.close() + first.send_audio_frame(b"still first") + first.stop() + assert list(first.events())[-1]["header"]["event"] == "task-finished" + with second: + second.send_audio_frame(b"now second") + second.stop() + list(second.events()) + assert [frame for frame in ws.sent if isinstance(frame, bytes)] == [ + b"first", + b"still first", + b"now second", + ] + assert not conn.closed + finally: + first.close() + second.close() + + +def test_shared_recognition_task_failure_keeps_other_task_usable( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + first = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + second = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + try: + first.start() + second.start() + first.send_audio_frame(b"first") + ws.inbox.put(ws._event("task-failed", first.session.task_id)) + with pytest.raises(DashScopeError): + next(first.events()) + second.send_audio_frame(b"second") + second.stop() + assert list(second.events())[-1]["header"]["event"] == "task-finished" + assert not conn.closed + finally: + first.close() + second.close() diff --git a/tests/v2/test_audio_management.py b/tests/v2/test_audio_management.py index f988075..f51386b 100644 --- a/tests/v2/test_audio_management.py +++ b/tests/v2/test_audio_management.py @@ -18,7 +18,14 @@ import pytest from dashscope.v2 import Client -from dashscope.v2.errors import BadRequestError +from dashscope.v2.errors import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + InternalServerError, +) BASE_URL = "https://api.test.local/api/v1" @@ -279,8 +286,8 @@ def handler(request): parameters={"sample_rate": 24000, "response_format": "wav"}, ) assert created.voice == "announcer" - assert created.preview_audio is not None - # pylint: disable-next=unsubscriptable-object # Optional narrowed above + # The mock always returns a dict; pylint only sees Optional[Dict]. + # pylint: disable-next=unsubscriptable-object assert created.preview_audio["response_format"] == "wav" assert calls[0] == { "model": "qwen-voice-design", @@ -474,3 +481,599 @@ def handler(request): prefix="BAD PREFIX", vocabulary=[{"text": "x"}], ) + + +_MANAGEMENT_RESOURCES = ("vocabularies", "voices", "voice_design", "phrases") + + +@pytest.mark.parametrize( + "resource, method", + [ + (resource, method) + for resource in _MANAGEMENT_RESOURCES + for method in ("create", "get", "list", "update", "delete") + if (resource, method) != ("voice_design", "update") + ], +) +def test_management_all_actions_keep_real_response_headers(resource, method): + response = httpx.Response( + 200, + headers={"x-request-id": "real-id", "x-gateway": "test"}, + json={"output": {}, "headers": {"x-request-id": "body-id"}}, + ) + client = _make_client(lambda request: response) + create_args = { + "vocabularies": { + "target_model": "paraformer-v2", + "prefix": "test", + "vocabulary": [{"text": "test", "weight": 1}], + }, + "voices": { + "target_model": "cosyvoice-v3-flash", + "prefix": "test", + "url": "https://cdn.test/audio.wav", + }, + "voice_design": { + "target_model": "qwen-tts-vd", + "voice_prompt": "warm", + "preview_text": "hello", + }, + "phrases": {"model": "paraformer-v2", "phrases": {"test": 1}}, + } + args = () if method in ("create", "list") else ("resource-1",) + kwargs = create_args[resource] if method == "create" else {} + if method == "update": + kwargs = { + "vocabularies": {"vocabulary": [{"text": "test", "weight": 1}]}, + "voices": {"url": "https://cdn.test/audio.wav"}, + "phrases": create_args["phrases"], + }[resource] + try: + result = getattr(getattr(client.audio, resource), method)( + *args, + **kwargs, + ) + assert result.headers == dict(response.headers) + assert result.status_code == 200 + assert not result.is_error + result.headers["x-gateway"] = "changed" + assert response.headers["x-gateway"] == "test" + finally: + client.close() + client.transport.http_client.close() + + +@pytest.mark.parametrize( + "resource, wire_key, attribute, item", + [ + ("vocabularies", key, "vocabularies", {"vocabulary_id": "v-1"}) + for key in ("vocabularies", "vocabulary_list") + ] + + [ + (resource, key, "voices", {"voice": "voice-1", "enabled": False}) + for resource in ("voices", "voice_design") + for key in ("voices", "voice_list") + ] + + [ + ("phrases", key, "phrases", {"finetuned_output": "phrase-1"}) + for key in ("phrases", "jobs", "outputs") + ], +) +def test_management_list_aliases_and_envelope( + resource, + wire_key, + attribute, + item, +): + body = { + "request_id": "outer-id", + "status_code": 200, + "usage": {"count": 0}, + "output": { + wire_key: [item], + "request_id": "inner-id", + "total": 1, + "page_no": 2, + "page_size": 1, + "enabled": False, + }, + } + client = _make_client(lambda request: httpx.Response(200, json=body)) + result = getattr(client.audio, resource).list() + values = getattr(result, attribute) + if resource == "phrases": + assert values[0].phrase_id == "phrase-1" + assert result.total_count == 1 + assert (result.page_no, result.page_size) == (2, 1) + else: + assert values == [item] + assert result.request_id == "outer-id" + assert result.status_code == 200 + assert result.usage == {"count": 0} + assert result.enabled is False + assert not result.is_error + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [200, 400, 401, 500]) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_management_error_envelope(resource, status, raise_on_error): + body = { + "code": "InvalidApiKey" if status == 401 else "InvalidParameter", + "message": "management rejected", + "request_id": "body-error-id", + "status_code": status, + } + client = _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-error-id"}, + ), + raise_on_error=raise_on_error, + max_retries=0, + ) + call = getattr(client.audio, resource).list + if raise_on_error: + error_type = AuthenticationError if status == 401 else BadRequestError + with pytest.raises(error_type, match="management rejected") as caught: + call() + assert caught.value.code == body["code"] + assert caught.value.status_code == status + assert caught.value.request_id == "body-error-id" + assert caught.value.body == body + assert caught.value.headers["x-request-id"] == "header-error-id" + else: + result = call() + assert result.is_error + assert result.code == body["code"] + assert result.message == body["message"] + assert result.request_id == "body-error-id" + assert result.status_code == status + assert result.headers["x-request-id"] == "header-error-id" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_nonraising_http_failure_without_code_is_error(resource): + # A non-2xx response must not look like a successful empty resource list. + client = _make_client( + lambda request: httpx.Response(503, json={"message": "unavailable"}), + raise_on_error=False, + max_retries=0, + ) + result = getattr(client.audio, resource).list() + assert result.is_error + assert result.status_code == 503 + assert result.message == "unavailable" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_redirect_is_not_success(resource): + client = _make_client( + lambda request: httpx.Response( + 302, + json={}, + headers={"location": "https://other.test/"}, + ), + max_retries=0, + ) + with pytest.raises(APIStatusError) as caught: + getattr(client.audio, resource).list() + assert caught.value.status_code == 302 + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_non_json_http_error_keeps_header_request_id(resource): + client = _make_client( + lambda request: httpx.Response( + 500, + text="gateway failed", + headers={"x-request-id": "gateway-id"}, + ), + max_retries=0, + ) + with pytest.raises(InternalServerError, match="gateway failed") as caught: + getattr(client.audio, resource).list() + assert caught.value.status_code == 500 + assert caught.value.request_id == "gateway-id" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_management_malformed_json_is_not_suppressed(resource, raise_on_error): + client = _make_client( + lambda request: httpx.Response(200, content=b'{"output":'), + raise_on_error=raise_on_error, + ) + # Compatibility mode suppresses service errors, not parsing failures. + with pytest.raises(ValueError): + getattr(client.audio, resource).list() + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize( + "transport_error, sdk_error", + [ + (httpx.ConnectError, APIConnectionError), + (httpx.ReadTimeout, APITimeoutError), + ], +) +def test_management_transport_error_not_suppressed( + resource, + transport_error, + sdk_error, +): + def handler(request): + raise transport_error("offline", request=request) + + client = _make_client(handler, raise_on_error=False, max_retries=0) + with pytest.raises(sdk_error) as caught: + getattr(client.audio, resource).list() + assert isinstance(caught.value.__cause__, transport_error) + + +@pytest.mark.parametrize("method", ["create", "update"]) +def test_phrase_id_prefers_compiled_resource_over_job_id(method): + # The management guide §3 records this official dual-ID response shape. + output = {"job_id": "ft-job-1", "finetuned_output": "paraformer-v1-ft-1"} + client = _make_client( + lambda request: httpx.Response(200, json={"output": output}), + ) + args = ("paraformer-v1-ft-1",) if method == "update" else () + result = getattr(client.audio.phrases, method)( + *args, + model="paraformer-realtime-v1", + phrases={"下一首": 4}, + ) + assert result.phrase_id == output["finetuned_output"] + assert result.job_id == output["job_id"] + + +def test_phrases_list_maps_finetuned_outputs(): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + "finetuned_outputs": [ + {"finetuned_output": "paraformer-v1-ft-1"}, + ], + "total": 1, + }, + }, + ), + ) + result = client.audio.phrases.list() + assert [phrase.phrase_id for phrase in result.phrases] == [ + "paraformer-v1-ft-1", + ] + assert result.total_count == 1 + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("value", [None, "", " ", False]) +def test_management_empty_id_rejected_without_network(resource, value): + def handler(request): + pytest.fail("invalid IDs must be rejected before HTTP") + + client = _make_client(handler) + for method in ("get", "delete"): + with pytest.raises(ValueError): + getattr(getattr(client.audio, resource), method)(value) + + +@pytest.mark.parametrize("phrases", [None, {}, False]) +def test_empty_phrases_rejected_without_network(phrases): + def handler(request): + pytest.fail("empty phrases must be rejected before HTTP") + + client = _make_client(handler) + for method, args in (("create", ()), ("update", ("phrase-1",))): + with pytest.raises(ValueError, match="phrases"): + getattr(client.audio.phrases, method)( + *args, + model="paraformer-realtime-v1", + phrases=phrases, + ) + + +@pytest.mark.parametrize("resource", ["vocabularies", "voices"]) +@pytest.mark.parametrize("prefix", [None, "", False]) +def test_management_empty_prefix_omitted_and_zero_page_preserved( + resource, + prefix, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + getattr(client.audio, resource).list( + prefix=prefix, + page_index=0, + page_size=0, + ) + assert "prefix" not in bodies[0]["input"] + assert bodies[0]["input"]["page_index"] == 0 + assert bodies[0]["input"]["page_size"] == 0 + + +@pytest.mark.parametrize("explicit", [False, True]) +def test_customization_optional_fields_and_caller_dicts(explicit): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + voice_extra = {"language_hints": ["en"], "max_prompt_audio_length": 10} + design_extra = {"language": "en", "preferred_name": "original"} + original = json.loads(json.dumps([voice_extra, design_extra])) + parameters = {} if explicit else None + client.audio.voices.create( + target_model="cosyvoice-v3-plus", + prefix="demo", + url="https://cdn.test/a.wav", + language_hints=[] if explicit else None, + max_prompt_audio_length=0 if explicit else None, + extra_input=voice_extra, + parameters=parameters, + ) + client.audio.voice_design.create( + target_model="qwen3-tts-vd-realtime-2026-01-15", + voice_prompt="低沉", + preview_text="你好", + language="" if explicit else None, + preferred_name="" if explicit else None, + extra_input=design_extra, + parameters=parameters, + ) + assert bodies[0]["input"]["language_hints"] == ([] if explicit else ["en"]) + assert bodies[0]["input"]["max_prompt_audio_length"] == ( + 0 if explicit else 10 + ) + assert bodies[1]["input"]["language"] == ("" if explicit else "en") + assert bodies[1]["input"]["preferred_name"] == ( + "" if explicit else "original" + ) + assert [voice_extra, design_extra] == original + for body in bodies: + if explicit: + assert body["parameters"] == {} + else: + assert "parameters" not in body + + +@pytest.mark.parametrize("resource", ["voices", "voice_design"]) +def test_customization_extra_input_cannot_change_create_action(resource): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + if resource == "voices": + client.audio.voices.create( + target_model="cosyvoice-v3-plus", + prefix="demo", + url="https://cdn.test/a.wav", + extra_input={"action": "delete_voice"}, + ) + expected_action = "create_voice" + else: + client.audio.voice_design.create( + target_model="qwen3-tts-vd-realtime-2026-01-15", + voice_prompt="低沉", + preview_text="你好", + extra_input={"action": "delete"}, + ) + expected_action = "create" + assert bodies[0]["input"]["action"] == expected_action + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("auth_header", ["Authorization", "authorization"]) +def test_management_call_auth_is_single_valued_and_does_not_leak( + resource, + auth_header, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + http_client = client.transport.http_client + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + workspace_header = ( + "X-DashScope-WorkSpace" + if auth_header == "Authorization" + else "x-dashscope-workspace" + ) + headers = {auth_header: "Bearer header-key", workspace_header: "header-ws"} + original = dict(headers) + call = getattr(client.audio, resource).list + call(api_key="call-key", workspace="call-ws", headers=headers, timeout=0) + call(api_key=None, workspace=None, timeout=None) + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert requests[0].extensions["timeout"]["read"] == 0 + assert requests[1].headers["authorization"] == "Bearer pool-key" + assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" + assert ( + requests[1].extensions["timeout"]["read"] == http_client.timeout.read + ) + assert all(request.url.host == "api.test.local" for request in requests) + assert headers == original + + +@pytest.mark.parametrize("request_id", [None, "outer-id"]) +def test_designed_voice_keeps_raw_preview_and_unknown_fields(request_id): + # Preview audio is explicitly a raw dict, not the decoded SSE bytes API. + preview = {"data": "%%%", "sample_rate": 24000, "response_format": "wav"} + body = { + "request_id": request_id, + "output": { + "request_id": "inner-id", + "voice": "full-voice-id", + "preview_audio": preview, + "enabled": False, + "future_field": [], + }, + } + client = _make_client(lambda request: httpx.Response(200, json=body)) + result = client.audio.voice_design.get("full-voice-id") + assert result.voice == "full-voice-id" + assert result.preview_audio == preview + assert result.request_id == (request_id or "inner-id") + assert result.enabled is False + assert result.model_dump()["future_field"] == [] + + +@pytest.mark.parametrize("id_key", ["phrase_id", "job_id", "finetuned_output"]) +def test_phrase_single_id_alias_preserves_status_and_details(id_key): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + id_key: "returned-id", + "status": "PENDING", + "hyper_parameters": {"phrase_list": {"下一首": 4}}, + }, + }, + ), + ) + result = client.audio.phrases.get("queried-id") + assert result.phrase_id == "returned-id" + assert result.status == "PENDING" + assert result.hyper_parameters == {"phrase_list": {"下一首": 4}} + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [302, 503]) +@pytest.mark.parametrize("body_kind", ["json", "text"]) +def test_management_nonraising_failure_keeps_http_metadata( + resource, + status, + body_kind, +): + body = {"message": "unavailable", "status_code": 200} + kwargs = {"json": body} if body_kind == "json" else {"text": "unavailable"} + client = _make_client( + lambda request: httpx.Response( + status, + headers={"x-request-id": "header-id"}, + **kwargs, + ), + raise_on_error=False, + max_retries=0, + ) + result = getattr(client.audio, resource).list() + assert result.status_code == status + assert result.request_id == "header-id" + assert result.message == "unavailable" + assert result.code is None + assert result.is_error + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + ("", "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_management_request_id_precedence( + resource, + status, + raise_on_error, + outer_id, + inner_id, + expected_id, +): + body = { + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + with _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-id"}, + ), + raise_on_error=raise_on_error, + max_retries=0, + ) as client: + if raise_on_error and status == 400: + with pytest.raises(APIStatusError) as caught: + getattr(client.audio, resource).list() + assert caught.value.request_id == expected_id + assert caught.value.body == body + assert caught.value.status_code == status + else: + result = getattr(client.audio, resource).list() + assert result.request_id == expected_id + assert result.status_code == status + assert result.is_error == (status != 200) + + +@pytest.mark.parametrize( + "method", + ["create", "update", "get", "list", "delete"], +) +def test_phrase_methods_share_nonraising_http_metadata(method): + client = _make_client( + lambda request: httpx.Response( + 503, + text="unavailable", + headers={"x-request-id": "phrase-error"}, + ), + raise_on_error=False, + max_retries=0, + ) + args = ("phrase-id",) if method in {"update", "get", "delete"} else () + kwargs = ( + {"model": "paraformer-v1", "phrases": {"下一首": 4}} + if method in {"create", "update"} + else {} + ) + result = getattr(client.audio.phrases, method)(*args, **kwargs) + assert result.status_code == 503 + assert result.request_id == "phrase-error" + assert result.message == "unavailable" + assert result.is_error + + +def test_phrase_explicit_resource_id_wins_over_both_aliases(): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + "phrase_id": "explicit-id", + "finetuned_output": "compiled-id", + "job_id": "job-id", + }, + }, + ), + ) + result = client.audio.phrases.get("queried-id") + assert result.phrase_id == "explicit-id" + assert result.finetuned_output == "compiled-id" + assert result.job_id == "job-id" diff --git a/tests/v2/test_audio_omni_session.py b/tests/v2/test_audio_omni_session.py index 4eca4fe..380c46b 100644 --- a/tests/v2/test_audio_omni_session.py +++ b/tests/v2/test_audio_omni_session.py @@ -511,26 +511,6 @@ def connector(url, headers): session.close() -def test_every_session_opens_its_own_socket(client): - # Contract relied on by examples/04_real_world_scenarios/ - # realtime_audio_meeting_harness.py: realtime sessions never share a - # socket, so concurrent sessions need one dedicated connection each. - sockets = [FakeWebSocket(), FakeWebSocket()] - calls = [] - - def connector(url, headers): - calls.append((url, headers)) - return sockets[len(calls) - 1] - - first = client.audio.realtime.connect(model="omni", connector=connector) - second = client.audio.realtime.connect(model="omni", connector=connector) - with first, second: - assert len(calls) == 2 - first.send_event({"type": "ping"}) - assert len(sockets[0].sent) == 1 - assert sockets[1].sent == [] - - @pytest.mark.parametrize("timeout, expected", [(None, 12.5), (7, 7)]) def test_default_connector_applies_timeout_during_handshake( client, @@ -1067,7 +1047,12 @@ def test_disconnect_after_response_done_is_normal(opened): "qwen3.8-livetranslate-flash-realtime" + suffix for suffix in ("", "-2026-09-01") ] -_PATCH_MODELS = _QWEN_AUDIO_MODELS + _LIVE_MODELS + _LIVE38_MODELS +_OMNI38_MODELS = [ + "qwen3.8-omni-flash-realtime" + suffix for suffix in ("", "-2026-09-01") +] +_PATCH_MODELS = ( + _QWEN_AUDIO_MODELS + _LIVE_MODELS + _LIVE38_MODELS + _OMNI38_MODELS +) @pytest.fixture @@ -1099,6 +1084,12 @@ def test_new_protocol_updates_are_incremental(model_opened, model): "output": {"voice": "Cherry"}, }, } + elif model in _OMNI38_MODELS: + configured = { + "modalities": ["text", "audio"], + "turn_detection": {"type": "server_vad"}, + "audio": {"output": {"voice": "Cherry"}}, + } else: configured = { "modalities": ["text", "audio"], @@ -1146,6 +1137,12 @@ def test_new_protocol_updates_preserve_falsey_values(model_opened, model): "output": {"voice": None}, }, ) + elif model in _OMNI38_MODELS: + expected.update( + modalities=[], + turn_detection={}, + audio={"output": {"voice": None}}, + ) else: expected.update(modalities=[], voice=None, turn_detection={}) assert _payloads(ws) == [{"type": "session.update", "session": expected}] @@ -1837,6 +1834,16 @@ def receive(kind, at, **fields): "qwen-omni-turbo-realtime", _default_session(modalities=["text"], voice="Cherry"), ), + ( + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime-2026-09-01", + {"modalities": ["text"], "audio": {"output": {"voice": "Cherry"}}}, + ), + ( + "qwen3.8-omni-flash-realtime", + "qwen3.5-omni-flash-realtime", + _default_session(modalities=["text"], voice="Cherry"), + ), ], ) def test_url_model_selects_wire_protocol_without_changing_public_model( @@ -1879,3 +1886,1228 @@ def connector(url, headers): ws.inbox.append({"type": "session.finished"}) assert list(session.events()) == [{"type": "session.finished"}] assert session.model == model + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +def test_omni38_manual_audio_and_response(model_opened): + session, ws = model_opened + input_format = { + "type": "pcm", + "sample_rate": 16000, + "sample_format": "s16le", + "channels": 1, + "packing": "interleaved", + "channel_layout": "mono", + } + output_format = {"type": "pcm", "sample_rate": 24000} + session.update_session( + ["text", "audio"], + voice="longanlingxin", + input_audio_config=input_format, + output_audio_config=output_format, + enable_turn_detection=False, + ) + session.append_audio(b"audio") + session.commit() + session.create_response() + session.create_response( + modalities=["text"], + instructions="", + temperature=0, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "modalities": ["text", "audio"], + "turn_detection": None, + "audio": { + "input": {"format": input_format}, + "output": { + "format": output_format, + "voice": "longanlingxin", + }, + }, + }, + }, + {"type": "input_audio_buffer.append", "audio": "YXVkaW8="}, + {"type": "input_audio_buffer.commit"}, + {"type": "response.create"}, + { + "type": "response.create", + "response": { + "modalities": ["text"], + "instructions": "", + "temperature": 0, + }, + }, + ] + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "raw_audio, expected_audio", + [ + ( + {"input": {"format": {"type": "wav"}}, "output": {"voice": ""}}, + { + "input": {"format": {"type": "wav", "sample_rate": 16000}}, + "output": {"format": {"type": "pcm"}, "voice": ""}, + }, + ), + ({"input": None, "output": None}, {"input": None, "output": None}), + (None, None), + ], +) +def test_omni38_nested_overrides_leave_vad_at_top_level( + model_opened, + raw_audio, + expected_audio, +): + session, ws = model_opened + options = { + "input_audio_format": "pcm", + "sample_rate": 16000, + "output_audio_format": "pcm", + "voice": "longanlingxin", + "turn_detection_type": "semantic_vad", + "turn_detection_threshold": 0, + "audio": raw_audio, + } + original = copy.deepcopy(options) + session.update_session(**options) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "turn_detection": {"type": "semantic_vad", "threshold": 0}, + "audio": expected_audio, + }, + }, + ] + assert options == original + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +def test_omni38_multichannel_and_extensions_are_not_mutated(model_opened): + session, ws = model_opened + audio_format = { + "type": "pcm", + "sample_rate": 16000, + "sample_format": "s16le", + "channels": 2, + "packing": "interleaved", + "channel_layout": "raw_mic_array", + } + tools = [{"type": "function", "name": "get_weather", "parameters": {}}] + original = copy.deepcopy([audio_format, tools]) + session.update_session( + input_audio_config=audio_format, + input_audio_format="wav", + sample_rate=8000, + input_video_representation_compact="normal", + tools=tools, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "audio": {"input": {"format": audio_format}}, + "video": {"input": {"representation_compact": "normal"}}, + "tools": tools, + }, + }, + ] + assert [audio_format, tools] == original + approval = { + "type": "mcp_approval_request", + "id": "approval-1", + "server_label": "example", + "name": "get_weather", + "arguments": "{}", + } + ws.inbox.append({"type": "response.output_item.done", "item": approval}) + assert session.recv_event()["item"] == approval + response = { + "type": "mcp_approval_response", + "approval_request_id": "approval-1", + "approve": False, + } + session.create_item(response) + assert _payloads(ws)[-1] == { + "type": "conversation.item.create", + "item": response, + } + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "options, expected", + [ + ( + {"enable_input_audio_transcription": True}, + {"model": "qwen3-asr-flash-realtime"}, + ), + ({"enable_input_audio_transcription": False}, None), + ({"transcription_params": {}}, {}), + ({"input_audio_transcription_model": None}, {"model": None}), + ( + { + "enable_input_audio_transcription": True, + "input_audio_transcription": None, + }, + None, + ), + ], +) +def test_omni38_transcription_is_explicit(model_opened, options, expected): + session, ws = model_opened + session.update_session(**options) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": {"input_audio_transcription": expected}, + }, + ] + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "eof", + [None, "", b"", WebSocketConnectionClosedException("closed")], +) +@pytest.mark.parametrize("completed", [False, True]) +def test_omni38_finish_needs_response_done_not_session_finished( + model_opened, + eof, + completed, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.end_session() + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + assert ws.close_calls == 0 + with pytest.raises(RuntimeError, match="finishing"): + session.append_audio(b"late") + if completed: + ws.inbox.append({"type": "response.done", "response": {"id": "r1"}}) + ws.inbox.append(eof) + if completed: + assert len(list(session.events())) == 1 + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + assert ws.close_calls == 1 + + +# Audit cases assert the protocol and lifecycle contracts. +# All sockets are local stubs; no credentials or cloud are used. +_AUDIT_MODELS = [ + "qwen3.5-omni-flash-realtime", + _OMNI38_MODELS[0], + _QWEN_AUDIO_MODELS[0], + _LIVE_MODELS[0], + _LIVE38_MODELS[0], +] + + +@pytest.mark.parametrize("model", _AUDIT_MODELS[1:]) +def test_audit_patch_snapshot_survives_caller_mutation(model_opened, model): + session, ws = model_opened + options = { + "input_audio_config": {"type": "pcm", "sample_rate": 0}, + "output_audio_config": {"type": "opus", "channels": 1}, + "translation_params": {"language": "", "corpus": {"text": []}}, + "transcription_params": {"language": None, "corpus_text": ""}, + "turn_detection_param": {"threshold": 0, "create_response": False}, + "tools": [{"name": "tool", "parameters": {"required": []}}], + } + original = copy.deepcopy(options) + session.update_session(**options) + assert options == original + expected = { + "audio": { + "input": {"format": original["input_audio_config"]}, + "output": {"format": original["output_audio_config"]}, + }, + "translation": original["translation_params"], + "input_audio_transcription": { + "language": None, + "corpus": {"text": ""}, + }, + "tools": original["tools"], + } + if model in _LIVE38_MODELS: + expected["audio"]["input"]["turn_detection"] = original[ + "turn_detection_param" + ] + else: + expected["turn_detection"] = original["turn_detection_param"] + options["input_audio_config"]["sample_rate"] = 48000 + options["translation_params"]["corpus"]["text"].append("later") + options["tools"][0]["parameters"]["required"].append("later") + session.update_session(instructions="next") + assert _payloads(ws) == [ + {"type": "session.update", "session": expected}, + {"type": "session.update", "session": {"instructions": "next"}}, + ] + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "kind", + ["session.update", "response.create", "session.finish"], +) +def test_audit_serialization_failure_does_not_poison_session( + model_opened, + kind, +): + session, ws = model_opened + event = {"type": kind, "extension": {"not-json": {"a", "b"}}} + original = copy.deepcopy(event) + with pytest.raises(TypeError): + session.send_event(event) + assert event == original + assert ws.sent == [] + assert ws.close_calls == 0 + session.commit() + assert _payloads(ws) == [{"type": "input_audio_buffer.commit"}] + assert session.recv_event() is None + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "operation", + ["append_audio", "update_session", "create_response", "cancel_response"], +) +def test_audit_helper_send_failure_preserves_error_and_inputs( + model_opened, + operation, +): + session, ws = model_opened + failure = ConnectionResetError("audit send failed") + ws.send_error = failure + ws.close_error = OSError("cleanup must not mask send") + options = {"metadata": {"values": [0, False, None]}} + original = copy.deepcopy(options) + with pytest.raises(ConnectionResetError) as caught: + if operation == "append_audio": + session.append_audio(b"audio") + elif operation == "update_session": + session.update_session(["text"], **options) + elif operation == "create_response": + session.create_response(**options) + else: + session.cancel_response() + assert caught.value is failure + assert options == original + assert ws.sent == [] + assert ws.close_calls == 1 + session.close() + assert ws.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.commit() + + +@pytest.mark.parametrize("stage", ["send", "recv", "settimeout"]) +@pytest.mark.parametrize("failure_type", [KeyboardInterrupt, SystemExit]) +def test_audit_base_exception_cleanup_and_reconnect( + client, + stage, + failure_type, +): + failed, fresh = FakeWebSocket(), FakeWebSocket() + failure = failure_type("interrupted") + if stage == "settimeout": + failed.timeout_error = failure + elif stage == "send": + failed.send_error = failure + else: + failed.inbox.append(failure) + sockets = iter([failed, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + try: + with pytest.raises(failure_type) as caught: + session.connect() + if stage == "send": + session.commit() + elif stage == "recv": + session.recv_event() + assert caught.value is failure + assert failed.close_calls == 1 + session.connect() + session.commit() + assert _payloads(fresh) == [{"type": "input_audio_buffer.commit"}] + assert session.last_message is None + assert session.last_response_id is None + finally: + session.close() + assert failed.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("use_default", [False, True]) +def test_audit_settimeout_failure_after_previous_response_resets_state( + client, + monkeypatch, + use_default, +): + first, failed, fresh = (FakeWebSocket() for _ in range(3)) + failed.timeout_error = OSError("cannot set timeout") + failed.close_error = OSError("cleanup failed") + sockets = iter([first, failed, fresh]) + options = {} + if use_default: + monkeypatch.setattr( + "websocket.create_connection", + lambda *a, **kw: next(sockets), + ) + else: + options["connector"] = lambda url, headers: next(sockets) + session = client.audio.realtime.connect(model=_OMNI38_MODELS[0], **options) + try: + session.connect() + first.inbox.append( + {"type": "response.created", "response": {"id": "old"}}, + ) + session.recv_event() + session.end_session() + session.close() + with pytest.raises(OSError, match="cannot set timeout"): + session.connect() + assert failed.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.recv_event() + session.connect() + assert session.last_message is None + assert session.last_response_id is None + assert session.last_response_create_time is None + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + session.commit() + assert session.recv_event() is None + finally: + session.close() + assert first.close_calls == failed.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize( + "raw, error_type", + [ + ("null", ValueError), + ("false", ValueError), + ("0", ValueError), + ('"text"', ValueError), + ('[{"type": "response.done"}]', ValueError), + ('{"type": "response.done"} trailing', json.JSONDecodeError), + (" ", json.JSONDecodeError), + (bytearray(b"\xff"), UnicodeDecodeError), + ], +) +def test_audit_invalid_frames_close_without_masking_parse_error( + opened, + active, + raw, + error_type, +): + session, ws = opened + previous = {"type": "response.created", "response": {"id": "r1"}} + if active: + ws.inbox.append(previous) + session.recv_event() + ws.inbox.append(raw) + ws.close_error = OSError("cleanup failed") + with pytest.raises(error_type): + session.recv_event() + assert ws.close_calls == 1 + assert session.last_message == (previous if active else None) + session.close() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("error", ["bad", ["bad"], 1, True, "", [], 0, False]) +def test_audit_malformed_error_object_is_rejected_and_closes(opened, error): + session, ws = opened + ws.inbox.append({"type": "error", "error": error}) + ws.close_error = OSError("cleanup failed") + # Falsey non-objects are invalid too, not an absent error object. + with pytest.raises(ValueError, match="JSON error object"): + session.recv_event() + assert ws.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.commit() + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("completed", [False, True]) +def test_audit_cancel_still_requires_matching_response_done( + model_opened, + completed, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.cancel_response() + assert _payloads(ws) == [{"type": "response.cancel"}] + assert ws.close_calls == 0 + ws.inbox.append({"type": "response.done", "response": {"id": "stale"}}) + session.recv_event() + if completed: + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "cancelled", + "status_details": { + "type": "cancelled", + "reason": "client_cancelled", + }, + }, + } + ws.inbox.append(done) + assert session.recv_event() == done + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_failed_response_done_remains_raw_terminal_event(model_opened): + session, ws = model_opened + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "failed", + "status_details": { + "type": "failed", + "error": { + "code": "InternalError", + "message": "generation failed", + }, + }, + }, + } + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + done, + ], + ) + assert list(session.events())[-1] == done + assert session.last_message == done + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_recoverable_error_keeps_active_response_tracking(model_opened): + session, ws = model_opened + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "cannot cancel this request", + }, + }, + ], + ) + session.recv_event() + started = session.last_response_create_time + session.cancel_response() + with pytest.raises(BadRequestError, match="cannot cancel"): + session.recv_event() + assert ws.close_calls == 0 + assert session.last_response_create_time == started + assert session.last_response_id == "r1" + session.commit() + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_fatal_error_during_response_preserves_server_failure( + model_opened, +): + session, ws = model_opened + failure = { + "type": "error", + "error": { + "type": "server_error", + "code": "Throttling", + "message": "busy", + }, + } + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + failure, + ], + ) + session.recv_event() + ws.close_error = OSError("cleanup failed") + with pytest.raises(RateLimitError, match="busy") as caught: + session.recv_event() + assert caught.value.body == failure + assert session.last_message == failure + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS[:3]) +@pytest.mark.parametrize("raw_send", [False, True]) +def test_audit_requested_response_cannot_disappear_before_created( + model_opened, + raw_send, +): + session, ws = model_opened + if raw_send: + session.send_event({"type": "response.create"}) + else: + session.create_response() + # A successful send followed by EOF is not a successful empty response. + with pytest.raises(APIConnectionError, match="response"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize("request_finish", [False, True]) +def test_audit_omni38_optional_session_ack_cannot_replace_response_done( + model_opened, + request_finish, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + if request_finish: + session.end_session() + ws.inbox.append({"type": "session.finished"}) + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + assert ws.close_calls == 1 + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + + +@pytest.mark.parametrize( + "wire_model, needs_ack", + [ + ("qwen3.5-omni-flash-realtime", True), + (_OMNI38_MODELS[0], False), + (_LIVE_MODELS[0], True), + (_LIVE38_MODELS[0], True), + ], +) +def test_audit_finish_ack_uses_wire_protocol_even_without_response( + client, + wire_model, + needs_ack, +): + ws = FakeWebSocket() + with client.audio.realtime.connect( + model=_QWEN_AUDIO_MODELS[0], + ws_url=f"wss://example.test/realtime?model={wire_model}", + connector=lambda url, headers: ws, + ) as session: + session.end_session() + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + if needs_ack: + with pytest.raises( + APIConnectionError, + match="before session.finished", + ): + session.recv_event() + else: + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "model", + [_AUDIT_MODELS[0], _OMNI38_MODELS[0], _LIVE38_MODELS[0]], +) +@pytest.mark.parametrize( + "operation", + ["commit", "cancel_response", "create_response", "clear_appended_audio"], +) +def test_audit_finish_rejects_other_sends_but_keeps_reader( + model_opened, + operation, +): + session, ws = model_opened + session.end_session() + with pytest.raises(RuntimeError, match="finishing"): + getattr(session, operation)() + assert _payloads(ws) == [{"type": "session.finish"}] + assert ws.close_calls == 0 + ws.inbox.append({"type": "session.finished"}) + assert session.recv_event() == {"type": "session.finished"} + session.close() + session.close() + assert ws.close_calls == 1 + + +def _audit_start_call(call): + import threading + from concurrent.futures import Future + + result = Future() + + def run(): + try: + result.set_result(call()) + except BaseException as error: + result.set_exception(error) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread, result + + +def test_audit_concurrent_connect_and_close_are_single_shot(client): + import threading + + barrier = threading.Barrier(2, timeout=2) + ws = FakeWebSocket() + calls = [] + + def connector(url, headers): + calls.append(url) + return ws + + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=connector, + ) + + def connect(): + barrier.wait() + return session.connect() + + workers = [_audit_start_call(connect) for _ in range(2)] + try: + results = [] + for thread, future in workers: + thread.join(2) + assert not thread.is_alive() + try: + results.append(future.result(timeout=0)) + except RuntimeError as error: + assert "already open" in str(error) + assert results == [session] + assert len(calls) == 1 + assert ws.close_calls == 0 + + def close(): + barrier.wait() + session.close() + + workers = [_audit_start_call(close) for _ in range(2)] + for thread, future in workers: + thread.join(2) + assert not thread.is_alive() + future.result(timeout=0) + finally: + session.close() + assert ws.close_calls == 1 + + +def test_audit_concurrent_receivers_cannot_reorder_response_tracking( + opened, + monkeypatch, +): + import threading + + session, ws = opened + entered, release = threading.Event(), threading.Event() + created = {"type": "response.created", "response": {"id": "r1"}} + done = {"type": "response.done", "response": {"id": "r1"}} + ws.inbox.extend([created, done]) + track = session._track_event + + def delayed_track(event): + if event["type"] == "response.created": + entered.set() + assert release.wait(2), "test did not release first reader" + track(event) + + monkeypatch.setattr(session, "_track_event", delayed_track) + workers = [_audit_start_call(session.recv_event)] + second_started, second_finished = threading.Event(), threading.Event() + + def second_read(): + second_started.set() + try: + return session.recv_event() + finally: + second_finished.set() + + try: + assert entered.wait(2) + workers.append(_audit_start_call(second_read)) + assert second_started.wait(2) + assert not second_finished.wait(0.1) + assert ws.recv_calls == 1, "second reader read before first tracked" + finally: + release.set() + for thread, _ in workers: + thread.join(2) + assert not thread.is_alive() + assert workers[0][1].result(timeout=0) == created + assert workers[1][1].result(timeout=0) == done + assert session.last_message == done + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "old_result", + [ + None, + ConnectionResetError("old socket failed"), + "{invalid", + {"type": "session.created", "session": {"id": "stale"}}, + {"type": "response.created", "response": {"id": "stale"}}, + {"type": "session.finished"}, + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": "pending", + }, + }, + ], +) +@pytest.mark.parametrize("reuse_socket", [False, True]) +def test_audit_old_reader_cannot_close_or_contaminate_reconnected_session( + client, + old_result, + reuse_socket, +): + import threading + + entered, release = threading.Event(), threading.Event() + + class DelayedSocket(FakeWebSocket): + def recv(self): + entered.set() + assert release.wait(2), "test did not release old socket" + return super().recv() + + old = DelayedSocket([old_result]) + # A custom connector may return the same wrapper on a new connection. + fresh = old if reuse_socket else FakeWebSocket() + sockets = iter([old, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + session.connect() + thread, future = _audit_start_call(session.recv_event) + try: + assert entered.wait(2) + session.close() + session.connect() + session.send_event({"type": "response.create", "event_id": "pending"}) + release.set() + thread.join(2) + assert not thread.is_alive() + # The retired read may report cancellation/transport/parse failure; + # none of these may act on the newly installed connection. + try: + future.result(timeout=0) + except (RuntimeError, APIConnectionError, OSError, ValueError): + pass + assert fresh.close_calls == int(reuse_socket) + assert session.session_id is None + assert session.last_message is None + assert session.last_response_id is None + session.commit() + assert ( + json.loads(fresh.sent[-1])["type"] == "input_audio_buffer.commit" + ) + # Neither a stale created nor a stale rejection may consume this. + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + finally: + release.set() + thread.join(2) + session.close() + assert not thread.is_alive() + if reuse_socket: + assert old.close_calls == 2 + else: + assert old.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize( + "code", + [["InvalidParameter"], {"bad": "code"}, [], {}, 0, False, 1, True], +) +def test_audit_malformed_recoverable_error_code_cannot_leak_socket( + opened, + code, +): + session, ws = opened + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": code, + "message": "invalid wire code", + }, + }, + ) + ws.close_error = OSError("cleanup failed") + with pytest.raises(ValueError, match="string error code"): + session.recv_event() + assert ws.close_calls == 1 + + +def test_audit_blocked_reader_does_not_block_sender_or_close(client): + import threading + + entered, release = threading.Event(), threading.Event() + + class DelayedSocket(FakeWebSocket): + def recv(self): + entered.set() + assert release.wait(2), "test did not release reader" + return super().recv() + + ws = DelayedSocket() + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: ws, + ) + session.connect() + workers = [_audit_start_call(session.recv_event)] + + def send_and_close(): + session.commit() + session.close() + + try: + assert entered.wait(2) + workers.append(_audit_start_call(send_and_close)) + workers[1][1].result(timeout=1) + assert not release.is_set() + assert _payloads(ws) == [{"type": "input_audio_buffer.commit"}] + assert ws.close_calls == 1 + finally: + release.set() + for thread, _ in workers: + thread.join(2) + assert not thread.is_alive() + session.close() + assert workers[0][1].result(timeout=0) is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("raw_send", [False, True]) +@pytest.mark.parametrize("cancelled", [False, True]) +def test_rejected_create_allows_another_response( + model_opened, + raw_send, + cancelled, +): + session, ws = model_opened + if raw_send: + session.send_event( + { + "type": "response.create", + "event_id": "rejected-create", + "response": {"instructions": 123}, + }, + ) + else: + session.create_response(instructions=123) + request_id = json.loads(ws.sent[-1])["event_id"] + rejected = { + "type": "error", + "event_id": "server-error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "instructions must be a string", + "event_id": request_id, + }, + } + ws.inbox.append(rejected) + with pytest.raises(BadRequestError) as caught: + session.recv_event() + assert caught.value.body == rejected + assert ws.close_calls == 0 + assert session.last_response_id is None + + session.commit() + session.create_response() + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + assert session.recv_event()["type"] == "response.created" + if cancelled: + session.cancel_response() + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "cancelled" if cancelled else "completed", + }, + } + ws.inbox.append(done) + assert session.recv_event() == done + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "error_request", + [None, "update", "cancel", "consumed-create"], +) +def test_unrelated_error_cannot_consume_pending_create( + model_opened, + error_request, +): + session, ws = model_opened + session.send_event( + {"type": "response.create", "event_id": "consumed-create"}, + ) + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.done", "response": {"id": "r1"}}, + ], + ) + session.recv_event() + session.recv_event() + session.send_event({"type": "session.update", "event_id": "update"}) + session.send_event({"type": "response.create", "event_id": "pending"}) + session.send_event({"type": "response.cancel", "event_id": "cancel"}) + error = { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "unrelated rejection", + } + if error_request is not None: + error["event_id"] = error_request + # Even a matching outer ID identifies a server event, not a client request. + ws.inbox.append({"type": "error", "event_id": "pending", "error": error}) + with pytest.raises(BadRequestError, match="unrelated rejection"): + session.recv_event() + assert ws.close_calls == 0 + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("order", [(0, 1, 2), (2, 0, 1)]) +def test_multiple_rejected_creates_are_correlated_individually( + model_opened, + order, +): + session, ws = model_opened + for index in range(3): + session.send_event( + { + "type": "response.create", + "event_id": f"create-{index}", + "response": {"instructions": 123}, + }, + ) + for index in order: + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": f"create-{index}", + }, + }, + ) + with pytest.raises(BadRequestError): + session.recv_event() + assert ws.close_calls == 0 + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "second_outcome", + ["missing", "rejected", "completed"], +) +def test_created_consumes_only_one_pending_request( + model_opened, + second_outcome, +): + session, ws = model_opened + session.send_event({"type": "response.create", "event_id": "first"}) + session.send_event({"type": "response.create", "event_id": "second"}) + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.done", "response": {"id": "r1"}}, + ], + ) + session.recv_event() + session.recv_event() + if second_outcome == "rejected": + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": "second", + }, + }, + ) + with pytest.raises(BadRequestError): + session.recv_event() + elif second_outcome == "completed": + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r2"}}, + {"type": "response.done", "response": {"id": "r2"}}, + ], + ) + session.recv_event() + session.recv_event() + if second_outcome == "missing": + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + else: + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("stage", ["before-created", "before-done", "done"]) +def test_cancel_before_created_does_not_discard_pending_request( + model_opened, + stage, +): + session, ws = model_opened + session.create_response() + session.cancel_response() + if stage != "before-created": + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + ws.inbox.append({"type": "response.done", "response": {"id": "stale"}}) + session.recv_event() + if stage == "done": + ws.inbox.append( + { + "type": "response.done", + "response": {"id": "r1", "status": "cancelled"}, + }, + ) + session.recv_event() + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("request_finish", [False, True]) +def test_omni38_optional_ack_cannot_hide_pending_create( + client, + request_finish, +): + ws = FakeWebSocket([{"type": "session.finished"}]) + with client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: ws, + ) as session: + session.create_response() + if request_finish: + session.end_session() + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("send_failure", [False, True]) +def test_reconnect_clears_pending_create_state(client, send_failure): + first, fresh = FakeWebSocket(), FakeWebSocket() + sockets = iter([first, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + with session: + if send_failure: + first.send_error = ConnectionResetError("create failed") + with pytest.raises(ConnectionResetError, match="create failed"): + session.create_response() + assert session.recv_event() is None + else: + session.create_response() + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + session.recv_event() + with session: + assert session.last_response_id is None + assert session.last_message is None + assert session.recv_event() is None + assert first.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("completed", [False, True]) +def test_rejected_create_does_not_complete_an_active_response( + model_opened, + completed, +): + session, ws = model_opened + session.create_response() + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.send_event({"type": "response.create", "event_id": "second"}) + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "response already in progress", + "event_id": "second", + }, + }, + ) + with pytest.raises(BadRequestError, match="already in progress"): + session.recv_event() + assert ws.close_calls == 0 + if completed: + ws.inbox.append({"type": "response.done", "response": {"id": "r1"}}) + session.recv_event() + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 diff --git a/tests/v2/test_audio_samples.py b/tests/v2/test_audio_samples.py new file mode 100644 index 0000000..d4d30f2 --- /dev/null +++ b/tests/v2/test_audio_samples.py @@ -0,0 +1,1579 @@ +# -*- coding: utf-8 -*- +"""Offline wire and output checks for the protocol-specific audio examples.""" + +import base64 +import importlib +import json +import wave +from contextlib import contextmanager, nullcontext +from pathlib import Path +from queue import Queue +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest + +from dashscope import Client +from dashscope.v2.errors import DashScopeError + + +def _sample(name): + # ``01_atomic_capabilities`` is not a valid identifier in source-level + # imports, so the samples are loaded through importlib instead. + return importlib.import_module( + f"examples.01_atomic_capabilities.audio.{name}", + ) + + +asr_file = _sample("asr_file") +asr_http_stream = _sample("asr_http_stream") +asr_microphone = _sample("asr_microphone") +connection_reuse = _sample("connection_reuse") +realtime_omni = _sample("realtime_omni") +realtime_qwen_audio = _sample("realtime_qwen_audio") +realtime_translation = _sample("realtime_translation") +tts_http = _sample("tts_http") +tts_http_stream = _sample("tts_http_stream") +tts_websocket = _sample("tts_websocket") +voice_clone = _sample("voice_clone") +asr_file_direct_call = _sample("direct_call.asr_file") +asr_http_stream_direct_call = _sample("direct_call.asr_http_stream") +connection_reuse_direct_call = _sample("direct_call.connection_reuse") +realtime_omni_direct_call = _sample("direct_call.realtime_omni") +realtime_qwen_audio_direct_call = _sample("direct_call.realtime_qwen_audio") +realtime_translation_direct_call = _sample("direct_call.realtime_translation") +tts_http_direct_call = _sample("direct_call.tts_http") +tts_http_stream_direct_call = _sample("direct_call.tts_http_stream") +tts_websocket_direct_call = _sample("direct_call.tts_websocket") +voice_clone_direct_call = _sample("direct_call.voice_clone") + + +BASE_URL = "https://api.test.local/api/v1" +HTTP_CLIENT = httpx.Client +PCM = b"\x01\x00\xff\x7f" * 64 + + +def attach_client(monkeypatch, module, handler): + responses = [] + + def respond(request): + response = handler(request) + responses.append(response) + return response + + transport = httpx.MockTransport(respond) + http_client = HTTP_CLIENT(transport=transport, base_url=BASE_URL) + client = Client( + api_key="sk-test", + workspace="ws-test", + base_url=BASE_URL, + http_client=http_client, + max_retries=0, + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: client) + return responses + + +def assert_wav(path, pcm=PCM): + with wave.open(str(path), "rb") as audio: + assert audio.getnchannels() == 1 + assert audio.getsampwidth() == 2 + assert audio.getframerate() == 24000 + assert audio.readframes(audio.getnframes()) == pcm + + +def sse(parts): + return "".join("data: " + json.dumps(part) + "\n\n" for part in parts) + + +def audio_part(pcm): + return {"output": {"audio": {"data": base64.b64encode(pcm).decode()}}} + + +def assert_tts_request(request, streaming): + assert request.method == "POST" + assert request.url.path == "/api/v1/services/audio/tts/SpeechSynthesizer" + assert request.headers.get("x-dashscope-sse") == ( + "enable" if streaming else None + ) + body = json.loads(request.content) + assert body == { + "model": "qwen-audio-3.1-tts-flash", + "input": { + "text": "你好", + "voice": "longanhuan_v3.1", + "format": "pcm", + "sample_rate": 24000, + }, + } + + +@pytest.mark.parametrize("module", [tts_http, tts_http_direct_call]) +def test_http_tts_download_has_no_sdk_credentials( + monkeypatch, + tmp_path, + module, +): + def handler(request): + assert_tts_request(request, False) + return httpx.Response( + 200, + json={ + "request_id": "tts-http-test", + "output": { + "audio": {"url": "http://cdn.test/a.pcm?signature=test"}, + }, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + downloads = [] + + def download(request): + assert str(request.url) == "https://cdn.test/a.pcm?signature=test" + assert "authorization" not in request.headers + assert "x-dashscope-workspace" not in request.headers + downloads.append(request) + return httpx.Response(200, content=PCM) + + downloader = HTTP_CLIENT(transport=httpx.MockTransport(download)) + monkeypatch.setattr(module.httpx, "Client", lambda **kwargs: downloader) + path = tmp_path / "http.wav" + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert_wav(path) + assert len(downloads) == 1 + assert downloader.is_closed + assert all(response.is_closed for response in responses) + + +@pytest.mark.parametrize( + "module", + [tts_http_stream, tts_http_stream_direct_call], +) +def test_http_tts_sse_appends_pcm_and_closes(monkeypatch, tmp_path, module): + def handler(request): + assert_tts_request(request, True) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse( + [ + audio_part(PCM[:1]), + audio_part(PCM[1:]), + {"output": {"finish_reason": "stop"}}, + ], + ), + ) + + responses = attach_client(monkeypatch, module, handler) + path = tmp_path / "sse.wav" + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert_wav(path) + assert len(responses) == 1 + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [tts_http_stream, tts_http_stream_direct_call], +) +@pytest.mark.parametrize( + "parts", + [ + [audio_part(PCM)], + [{"output": {"finish_reason": "stop"}}], + [audio_part(b"\x01"), {"output": {"finish_reason": "stop"}}], + [ + audio_part(b"RIFF\x00\x00\x00\x00WAVEdata"), + {"output": {"finish_reason": "stop"}}, + ], + ], +) +def test_http_tts_sse_rejects_incomplete_audio( + monkeypatch, + tmp_path, + parts, + module, +): + responses = attach_client( + monkeypatch, + module, + lambda request: httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse(parts), + ), + ) + with pytest.raises(module.SampleError): + module.main( + [ + "--text", + "你好", + "--output", + str(tmp_path / "bad.wav"), + "--no-play", + ], + ) + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [ + tts_http, + tts_http_direct_call, + tts_http_stream, + tts_http_stream_direct_call, + ], +) +def test_http_tts_output_exists_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + path = tmp_path / "existing.wav" + path.write_bytes(b"keep") + with pytest.raises(FileExistsError): + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert path.read_bytes() == b"keep" + + +@pytest.mark.parametrize( + "module", + [ + tts_http, + tts_http_direct_call, + tts_http_stream, + tts_http_stream_direct_call, + ], +) +def test_http_tts_empty_input_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created with empty text") + + monkeypatch.setattr(module, "Client", forbidden) + path = tmp_path / "unused.wav" + with pytest.raises(SystemExit) as caught: + module.main(["--text", " \n", "--output", str(path), "--no-play"]) + assert caught.value.code == 2 + assert not path.exists() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +@pytest.mark.parametrize("resume", [False, True]) +def test_asr_file_http_submit_or_resume(monkeypatch, tmp_path, resume, module): + calls = [] + + def handler(request): + calls.append(request) + if request.method == "POST": + assert ( + request.url.path == "/api/v1/services/audio/asr/transcription" + ) + assert request.headers["x-dashscope-async"] == "enable" + assert json.loads(request.content) == { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://cdn.test/input.wav"]}, + "parameters": {}, + } + return httpx.Response( + 200, + json={ + "output": { + "task_id": "file-task", + "task_status": "PENDING", + }, + }, + ) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/file-task" + return httpx.Response( + 200, + json={ + "request_id": "file-request", + "output": { + "task_id": "file-task", + "task_status": "SUCCEEDED", + "results": [ + { + "subtask_status": "SUCCEEDED", + "transcription_url": "https://cdn.test/transcript.json", + }, + ], + }, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + client = module.Client() + close = Mock(wraps=client.close) + monkeypatch.setattr(client, "close", close) + + def download(request): + assert "authorization" not in request.headers + assert "x-dashscope-workspace" not in request.headers + return httpx.Response(200, json={"transcripts": [{"text": "识别结果。"}]}) + + downloader = HTTP_CLIENT(transport=httpx.MockTransport(download)) + monkeypatch.setattr(module.httpx, "Client", lambda **kwargs: downloader) + folder = tmp_path / "result" + source = ( + ["--task-id", "file-task"] + if resume + else [ + "--url", + "https://cdn.test/input.wav", + ] + ) + assert module.main([*source, "--output-dir", str(folder)]) == 0 + metadata = json.loads((folder / "task.json").read_text()) + assert metadata["task_id"] == "file-task" + assert metadata["status"] == "SUCCEEDED" + assert (folder / "001.txt").read_text() == "识别结果。\n" + assert sum(request.method == "POST" for request in calls) == ( + 0 if resume else 1 + ) + assert all(response.is_closed for response in responses) + assert downloader.is_closed + close.assert_called_once_with() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +@pytest.mark.parametrize("error_type", [DashScopeError, KeyboardInterrupt]) +def test_asr_file_wait_failure_preserves_id_and_closes( + monkeypatch, + tmp_path, + error_type, + module, +): + def handler(request): + assert request.method == "POST" + return httpx.Response( + 200, + json={ + "output": {"task_id": "saved-task", "task_status": "PENDING"}, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + client = module.Client() + close = Mock(wraps=client.close) + monkeypatch.setattr(client, "close", close) + outputs = [] + checkpoint = module.checkpoint + + def save(output, metadata): + outputs.append(output) + checkpoint(output, metadata) + + monkeypatch.setattr(module, "checkpoint", save) + folder = tmp_path / "interrupted" + + def wait(job, *, timeout): + assert job.task_id == "saved-task" + assert timeout == 600.0 + assert not outputs[0].closed + metadata = json.loads((folder / "task.json").read_text()) + assert metadata["task_id"] == "saved-task" + assert metadata["status"] == "PENDING" + raise error_type("wait interrupted") + + monkeypatch.setattr(client.audio.asr.jobs, "wait", wait) + with pytest.raises(error_type): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--output-dir", + str(folder), + ], + ) + assert len(responses) == 1 + assert responses[0].is_closed + assert len(outputs) == 1 + assert outputs[0].closed + assert ( + json.loads((folder / "task.json").read_text())["task_id"] + == "saved-task" + ) + close.assert_called_once_with() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +def test_asr_file_output_exists_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + folder = tmp_path / "existing" + folder.mkdir() + checkpoint = folder / "task.json" + checkpoint.write_text("keep", encoding="utf-8") + with pytest.raises(FileExistsError): + module.main( + [ + "--task-id", + "saved-task", + "--output-dir", + str(folder), + ], + ) + assert checkpoint.read_text() == "keep" + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +def test_asr_file_single_url_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before single-URL validation") + + monkeypatch.setattr(module, "Client", forbidden) + folder = tmp_path / "unused" + with pytest.raises(SystemExit) as caught: + module.main( + [ + "--url", + "https://cdn.test/one.wav", + "--url", + "https://cdn.test/two.wav", + "--output-dir", + str(folder), + ], + ) + assert caught.value.code == 2 + assert not folder.exists() + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +@pytest.mark.parametrize("local_file", [False, True]) +def test_rest_asr_wire_and_cumulative_revisions( + monkeypatch, + tmp_path, + local_file, + module, +): + fixture = tmp_path / "input.wav" + with wave.open(str(fixture), "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(PCM) + + def handler(request): + assert request.method == "POST" + assert request.url.path == ( + "/api/v1/services/aigc/multimodal-generation/generation" + ) + assert request.headers["x-dashscope-sse"] == "enable" + body = json.loads(request.content) + assert body["model"] == "qwen-audio-3.1-asr-flash" + assert body["parameters"] == {"format": "wav", "sample_rate": "16000"} + part = body["input"]["messages"][0]["content"][0] + assert part["type"] == "input_audio" + data = part["input_audio"]["data"] + if local_file: + assert data.startswith("data:audio/wav;base64,") + assert ( + base64.b64decode(data.split(",", 1)[1]) == fixture.read_bytes() + ) + else: + assert data == "https://cdn.test/input.wav" + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse( + [ + {"output": {"text": "可能被修订的长文本"}}, + { + "output": { + "text": "你好", + "sentence": { + "sentence_end": True, + }, + }, + }, + { + "output": { + "text": "你好。再见。", + "sentence": { + "sentence_end": True, + }, + }, + }, + ], + ), + ) + + responses = attach_client(monkeypatch, module, handler) + transcript = tmp_path / "rest.txt" + events = tmp_path / "rest.jsonl" + source = ( + ["--file", str(fixture)] + if local_file + else [ + "--url", + "https://cdn.test/input.wav", + ] + ) + assert ( + module.main( + [ + *source, + "--transcript", + str(transcript), + "--events", + str(events), + ], + ) + == 0 + ) + assert transcript.read_text() == "你好。再见。" + assert len(events.read_text().splitlines()) == 3 + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +@pytest.mark.parametrize( + "parts,error", + [ + ([{"code": "InvalidParameter", "message": "bad"}], DashScopeError), + ([{"output": {}}], RuntimeError), + ([{"output": {"text": 42}}], ValueError), + ([], RuntimeError), + ], +) +def test_rest_asr_errors_close_response( + monkeypatch, + tmp_path, + parts, + error, + module, +): + responses = attach_client( + monkeypatch, + module, + lambda request: httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse(parts), + ), + ) + with pytest.raises(error): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(tmp_path / "rest.txt"), + ], + ) + assert responses[0].is_closed + + +@pytest.mark.parametrize("save_events", [False, True]) +@pytest.mark.parametrize("source", ["text", "file", "prompt"]) +def test_websocket_tts_always_uses_duplex( + monkeypatch, + tmp_path, + source, + save_events, +): + parts = ["你好", " ", "世界"] + stopped = Event() + session = Mock( + session=SimpleNamespace(request_id="duplex-request"), + first_package_delay=1.0, + usage={"characters": 5}, + ) + final = {"header": {"event": "task-finished"}} + + def frames(): + yield PCM[:128] + assert stopped.wait(2) + yield PCM[128:] + yield final + + session.events.side_effect = frames + session.stop.side_effect = stopped.set + session.close.side_effect = stopped.set + connect = Mock(return_value=nullcontext(session)) + llm_stream = SimpleNamespace(text_stream=iter(parts), close=Mock()) + create = Mock(return_value=llm_stream) + client = SimpleNamespace( + audio=SimpleNamespace(tts=SimpleNamespace(connect=connect)), + chat=SimpleNamespace(completions=SimpleNamespace(create=create)), + ) + monkeypatch.setattr( + tts_websocket, + "Client", + lambda **kwargs: nullcontext(client), + ) + if source == "text": + source_args = [ + "--text", + parts[0], + "--text", + parts[1], + "--text", + parts[2], + ] + elif source == "file": + parts = ["你好\n", " \n", "世界\n"] + text_file = tmp_path / "source.txt" + text_file.write_text("".join(parts), encoding="utf-8") + source_args = ["--text-file", str(text_file)] + else: + source_args = ["--prompt", "请打个招呼"] + path = tmp_path / "duplex.wav" + events = tmp_path / "events.jsonl" + event_args = ["--events", str(events)] if save_events else [] + tts_websocket.main( + [ + *source_args, + "--output", + str(path), + *event_args, + "--no-play", + ], + ) + connect.assert_called_once_with( + model="qwen-audio-3.1-tts-flash", + voice="longanhuan_v3.1", + format="pcm", + sample_rate=24000, + timeout=30.0, + enable_ssml=False, + ) + assert [call.args[0] for call in session.send_text.call_args_list] == parts + session.stop.assert_called_once() + session.close.assert_called_once() + assert_wav(path) + if save_events: + assert json.loads(events.read_text()) == final + else: + assert not events.exists() + if source == "prompt": + create.assert_called_once() + llm_stream.close.assert_called_once() + else: + create.assert_not_called() + + +def test_websocket_tts_send_error_closes_before_join(): + closed = Event() + reader_saw_close = [] + session = Mock() + + def frames(): + reader_saw_close.append(closed.wait(2)) + yield from () + + session.events.side_effect = frames + session.send_text.side_effect = RuntimeError("send failed") + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + tts=SimpleNamespace( + connect=Mock(return_value=nullcontext(session)), + ), + ), + ) + args = tts_websocket.make_parser().parse_args( + [ + "--text", + "你好", + "--output", + "unused.wav", + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + tts_websocket.synthesize(client, args, ["你好"], Mock(), None) + assert reader_saw_close == [True] + session.close.assert_called_once() + + +@pytest.mark.parametrize("module", [tts_websocket, tts_websocket_direct_call]) +def test_websocket_tts_has_no_mode_option(module): + parser = module.make_parser() + with pytest.raises(SystemExit) as caught: + parser.parse_args( + [ + "--mode", + "duplex", + "--text", + "你好", + "--output", + "unused.wav", + ], + ) + assert caught.value.code == 2 + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +def test_rest_asr_existing_output_prevents_request( + monkeypatch, + tmp_path, + module, +): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + transcript = tmp_path / "existing.txt" + transcript.write_text("keep") + with pytest.raises(FileExistsError): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(transcript), + ], + ) + assert transcript.read_text() == "keep" + + +@pytest.fixture +def opened_files(monkeypatch): + files = [] + open_file = Path.open + + def tracked_open(path, *args, **kwargs): + file = open_file(path, *args, **kwargs) + files.append(file) + return file + + monkeypatch.setattr(Path, "open", tracked_open) + yield files + assert files + assert all(file.closed for file in files) + + +@pytest.mark.parametrize( + "module,arguments", + [ + (module, arguments) + for modules, arguments in [ + ( + (asr_http_stream, asr_http_stream_direct_call), + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + "rest.txt", + "--events", + "events.jsonl", + ], + ), + ( + (tts_websocket, tts_websocket_direct_call), + [ + "--text-file", + "input.txt", + "--output", + "tts.wav", + "--events", + "events.jsonl", + "--no-play", + ], + ), + ( + (realtime_omni, realtime_omni_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (realtime_qwen_audio, realtime_qwen_audio_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (realtime_translation, realtime_translation_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (connection_reuse, connection_reuse_direct_call), + [ + "asr", + "input.wav", + "--output-dir", + "reuse", + ], + ), + ( + (connection_reuse, connection_reuse_direct_call), + [ + "tts", + "--output-dir", + "reuse", + ], + ), + ( + (voice_clone, voice_clone_direct_call), + ["list", "--output", "voices.json"], + ), + ] + for module in modules + ], +) +def test_audio_client_failure_closes_files( + monkeypatch, + tmp_path, + opened_files, + module, + arguments, +): + with wave.open(str(tmp_path / "input.wav"), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + (tmp_path / "input.txt").write_text("你好\n", encoding="utf-8") + source_open = wave.open + readers = [] + + def track_source(path, mode=None): + audio = source_open(path, mode) + if mode == "rb": + readers.append(audio) + return audio + + monkeypatch.setattr(wave, "open", track_source) + client = Mock(side_effect=RuntimeError("client failed")) + monkeypatch.setattr(module, "Client", client) + filenames = { + "input.wav", + "input.txt", + "rest.txt", + "events.jsonl", + "tts.wav", + "reply.wav", + "reuse", + "voices.json", + } + argv = [ + str(tmp_path / arg) if arg in filenames else arg for arg in arguments + ] + with pytest.raises(RuntimeError, match="client failed"): + module.main(argv) + client.assert_called_once() + assert all(reader._file is None for reader in readers) + assert all(file.closed for file in opened_files) + + +@pytest.mark.parametrize( + "module", + [ + asr_http_stream, + asr_http_stream_direct_call, + tts_websocket, + tts_websocket_direct_call, + ], +) +def test_audio_existing_events_prevents_request( + monkeypatch, + tmp_path, + opened_files, + module, +): + events = tmp_path / "events.jsonl" + events.write_text("keep", encoding="utf-8") + client = Mock(side_effect=AssertionError("unexpected client")) + monkeypatch.setattr(module, "Client", client) + if module in (asr_http_stream, asr_http_stream_direct_call): + arguments = [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(tmp_path / "rest.txt"), + ] + else: + arguments = [ + "--text", + "你好", + "--output", + str(tmp_path / "tts.wav"), + "--no-play", + ] + with pytest.raises(FileExistsError): + module.main([*arguments, "--events", str(events)]) + client.assert_not_called() + assert events.read_text() == "keep" + + +def test_websocket_llm_failure_closes_stream( + monkeypatch, + tmp_path, + opened_files, +): + stream = SimpleNamespace(text_stream=iter(["你好"]), close=Mock()) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=Mock(return_value=stream), + ), + ), + ) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield client + finally: + closed.append(True) + + monkeypatch.setattr(tts_websocket, "Client", client_context) + monkeypatch.setattr( + tts_websocket, + "synthesize", + Mock( + side_effect=RuntimeError("synthesis failed"), + ), + ) + with pytest.raises(RuntimeError, match="synthesis failed"): + tts_websocket.main( + [ + "--prompt", + "你好", + "--output", + str(tmp_path / "tts.wav"), + "--events", + str(tmp_path / "events.jsonl"), + "--no-play", + ], + ) + stream.close.assert_called_once() + assert closed == [True] + + +@pytest.mark.parametrize("record", [False, True]) +@pytest.mark.parametrize("fail", [False, True]) +def test_microphone_optional_recording_cleanup( + monkeypatch, + tmp_path, + opened_files, + record, + fail, +): + pcm = b"\x01\x00" * 1600 + stream = Mock() + stream.read.return_value = (pcm, False) + closed = [] + + @contextmanager + def microphone(*args, **kwargs): + try: + yield stream + finally: + closed.append(True) + + monkeypatch.setattr(asr_microphone, "load_sounddevice", Mock()) + monkeypatch.setattr(asr_microphone, "microphone", microphone) + session = Mock() + receiver = Mock() + receiver.done.return_value = False + + def recognize(args, sample_rate, transcript, send_audio): + assert sample_rate == 16000 + if fail: + raise RuntimeError("recognition failed") + send_audio(session, receiver) + transcript.write("你好\n") + + monkeypatch.setattr(asr_microphone, "run_recognition", recognize) + recording = tmp_path / "recording.wav" + transcript = tmp_path / "microphone.txt" + record_args = ( + ["--recording", str(recording)] if record else ["--no-recording"] + ) + result = asr_microphone.main( + [ + "--seconds", + "0.1", + "--transcript", + str(transcript), + *record_args, + ], + ) + assert result == (1 if fail else 0) + assert closed == [True] + assert recording.exists() == record + if not fail: + session.send_audio_frame.assert_called_once_with(pcm) + assert transcript.read_text() == "你好\n" + if record: + with wave.open(str(recording), "rb") as audio: + assert audio.readframes(audio.getnframes()) == ( + b"" if fail else pcm + ) + + +@pytest.mark.parametrize("save", [False, True]) +@pytest.mark.parametrize("status", ["OK", "DEPLOYING"]) +def test_voice_get_optional_output_cleanup( + monkeypatch, + tmp_path, + save, + status, +): + voice = SimpleNamespace( + status=status, + model_dump=Mock( + return_value={"voice_id": "voice-test", "status": status}, + ), + ) + get = Mock(return_value=voice) + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace(voices=SimpleNamespace(get=get)), + ) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield client + finally: + closed.append(True) + + monkeypatch.setattr(voice_clone, "Client", client_context) + path = tmp_path / "voice.json" + opened = [] + checkpoint = voice_clone.checkpoint + + def save_result(output, data, response): + opened.append(output) + checkpoint(output, data, response) + + monkeypatch.setattr(voice_clone, "checkpoint", save_result) + output_args = ["--output", str(path)] if save else [] + result = voice_clone.main( + ["get", "--voice-id", "voice-test", *output_args], + ) + assert result == (0 if status == "OK" else 1) + get.assert_called_once_with("voice-test") + assert closed == [True] + assert path.exists() == save + if save: + assert opened[0].closed + assert json.loads(path.read_text())["result"]["status"] == status + else: + assert opened == [None] + + +@pytest.mark.parametrize( + "module,model", + [ + (realtime_omni, "qwen3.8-omni-flash-realtime"), + (realtime_qwen_audio, "qwen-audio-3.0-realtime-plus"), + (realtime_translation, "qwen3.8-livetranslate-flash-realtime"), + ], +) +@pytest.mark.parametrize("use_wav", [False, True]) +def test_realtime_source_and_context_cleanup( + monkeypatch, + tmp_path, + opened_files, + use_wav, + module, + model, +): + session = Mock() + session.recv_event.side_effect = [ + {"type": "session.updated"}, + { + "type": "response.audio.delta", + "delta": base64.b64encode(PCM).decode(), + }, + {"type": "response.done", "response": {"status": "completed"}}, + {"type": "session.finished"}, + ] + closed = [] + + @contextmanager + def connect(**kwargs): + assert kwargs["model"] == model + try: + yield session + finally: + closed.append("session") + + @contextmanager + def client_context(**kwargs): + try: + yield SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=connect, + ), + ), + ) + finally: + closed.append("client") + + monkeypatch.setattr(module, "Client", client_context) + sounddevice = Mock() + monkeypatch.setattr(module, "load_sounddevice", lambda: sounddevice) + send = Mock() + monkeypatch.setattr(module, "send_input", send) + path = tmp_path / "reply.wav" + if use_wav: + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + source_args = ["--wav", str(source)] + else: + source_args = ["--microphone"] + module.main([*source_args, "--output", str(path), "--no-play"]) + assert_wav(path) + if module is realtime_translation: + session.commit.assert_not_called() + session.create_response.assert_not_called() + session.end_session.assert_called_once() + session.update_session.assert_called_once_with( + output_modalities=["text", "audio"], + translation_params={"language": "en"}, + ) + else: + session.commit.assert_called_once() + session.create_response.assert_called_once() + session.end_session.assert_not_called() + send.assert_called_once() + input_audio = send.call_args.args[3] + if use_wav: + assert input_audio._file is None + sounddevice.check_input_settings.assert_not_called() + else: + assert input_audio is None + sounddevice.check_input_settings.assert_called_once() + assert closed == ["session", "client"] + session.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +@pytest.mark.parametrize("fail", [False, True]) +def test_reuse_source_and_context_cleanup( + monkeypatch, + tmp_path, + opened_files, + kind, + fail, +): + connection = SimpleNamespace(closed=False) + connection.close = Mock( + side_effect=lambda: setattr(connection, "closed", True), + ) + connect = Mock(return_value=connection) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield SimpleNamespace( + ws_connection=connect, + config=SimpleNamespace( + region="cn-beijing", + workspace="ws-test", + ), + ) + finally: + closed.append("client") + + monkeypatch.setattr(connection_reuse, "Client", client_context) + sources = [] + + def run_one(client, shared, args, *extra): + assert shared is connection + assert not shared.closed + index = extra[-1] + if kind == "asr": + sources.append(extra[0]) + assert extra[0].getframerate() == 16000 + if fail and index == 2: + raise RuntimeError("task failed") + return {"task_id": f"task-{index}"} + + monkeypatch.setattr(connection_reuse, "recognize_one", run_one) + monkeypatch.setattr(connection_reuse, "synthesize_one", run_one) + source_args = [] + if kind == "asr": + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM) + source_args = [str(source)] + folder = tmp_path / "reuse" + argv = [kind, *source_args, "--output-dir", str(folder)] + if fail: + with pytest.raises(RuntimeError, match="task failed"): + connection_reuse.main(argv) + else: + assert connection_reuse.main(argv) == 0 + result = json.loads((folder / "summary.json").read_text()) + assert result["status"] == ("failed" if fail else "completed") + assert len(result["runs"]) == (1 if fail else 5) + assert result["connection_closed"] is True + assert result["connection_count"] == 1 + connect.assert_called_once() + assert connection.closed + assert closed == ["client"] + if kind == "asr": + assert all(source is sources[0] for source in sources) + assert sources[0]._file is None + + +@pytest.mark.parametrize( + "module,model,settings", + [ + (module, model, settings) + for modules, model, settings in [ + ( + (realtime_omni, realtime_omni_direct_call), + "qwen3.8-omni-flash-realtime", + { + "modalities": ["text", "audio"], + "audio": { + "input": { + "format": {"type": "pcm", "sample_rate": 16000}, + }, + "output": { + "format": {"type": "pcm", "sample_rate": 24000}, + "voice": "longanlingxin", + }, + }, + "input_audio_transcription": None, + "turn_detection": None, + "instructions": "请用简洁自然的中文回答。", + }, + ), + ( + (realtime_qwen_audio, realtime_qwen_audio_direct_call), + "qwen-audio-3.0-realtime-plus", + { + "modalities": ["text", "audio"], + "voice": "longanqian", + "input_audio_format": "pcm", + "output_audio_format": "pcm", + "turn_detection": None, + "instructions": "请用简洁自然的中文回答。", + }, + ), + ( + (realtime_translation, realtime_translation_direct_call), + "qwen3.8-livetranslate-flash-realtime", + { + "output_modalities": ["text", "audio"], + "translation": {"language": "en"}, + }, + ), + ] + for module in modules + ], +) +def test_realtime_examples_sdk_wire( + monkeypatch, + tmp_path, + module, + model, + settings, +): + translating = module in ( + realtime_translation, + realtime_translation_direct_call, + ) + inbox = Queue() + inbox.put({"type": "session.created", "session": {"id": "test-session"}}) + sent = [] + urls = [] + + def response(identifier): + inbox.put({"type": "response.created", "response": {"id": identifier}}) + inbox.put( + {"type": "response.audio_transcript.delta", "delta": "Hello"}, + ) + inbox.put( + { + "type": "response.audio.delta", + "delta": base64.b64encode(PCM).decode(), + }, + ) + inbox.put( + {"type": "response.audio_transcript.done", "transcript": "Hello"}, + ) + inbox.put( + { + "type": "response.done", + "response": { + "id": identifier, + "status": "completed", + "usage": {}, + }, + }, + ) + + def send(payload): + event = json.loads(payload) + sent.append(event) + if event["type"] == "session.update": + inbox.put( + {"type": "session.updated", "session": {"id": "test-session"}}, + ) + elif event["type"] == "input_audio_buffer.append": + if ( + translating + and sum( + item["type"] == "input_audio_buffer.append" + for item in sent + ) + == 1 + ): + response("translation-1") + elif event["type"] == "response.create": + response("reply-1") + elif event["type"] == "session.finish": + response("translation-2") + inbox.put( + { + "type": "session.finished", + "session": {"id": "test-session"}, + }, + ) + + def recv(): + event = inbox.get(timeout=2) + return json.dumps(event) if event is not None else None + + ws = SimpleNamespace( + send=send, + recv=recv, + settimeout=Mock(), + close=Mock(side_effect=lambda: inbox.put(None)), + ) + client = Client(api_key="sk-test", workspace="ws-test", max_retries=0) + connect = client.audio.realtime.connect + + def connector(url, headers): + urls.append(url) + return ws + + monkeypatch.setattr( + client.audio.realtime, + "connect", + lambda **kwargs: connect( + **kwargs, + connector=connector, + ), + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: client) + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + output = tmp_path / "output.wav" + module.main(["--wav", str(source), "--output", str(output), "--no-play"]) + assert len(urls) == 1 + assert f"model={model}" in urls[0] + assert sent[0]["type"] == "session.update" + assert sent[0]["session"] == settings + audio_events = [ + event for event in sent if event["type"] == "input_audio_buffer.append" + ] + sent_audio = b"".join( + base64.b64decode(event["audio"]) for event in audio_events + ) + assert sent_audio == PCM * 16 + kinds = [event["type"] for event in sent] + if translating: + assert kinds[-1] == "session.finish" + assert "response.create" not in kinds + assert "input_audio_buffer.commit" not in kinds + else: + assert kinds[-2:] == ["input_audio_buffer.commit", "response.create"] + assert "session.finish" not in kinds + if module in (realtime_omni, realtime_omni_direct_call): + assert "response" not in sent[-1] + else: + assert sent[-1]["response"] == {} + assert_wav(output, PCM * (2 if translating else 1)) + ws.close.assert_called_once() + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_qwen_audio, + realtime_translation, + ], +) +def test_realtime_send_failure_closes_before_join( + monkeypatch, + tmp_path, + module, +): + closed = Event() + receiving = Event() + session = Mock() + + def recv(): + if not receiving.is_set(): + receiving.set() + return {"type": "session.updated"} + assert closed.wait(2) + return None + + session.recv_event.side_effect = recv + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=nullcontext(session)), + ), + ), + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: nullcontext(client)) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "failed.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + module.run_turn(args) + session.close.assert_called_once() + session.commit.assert_not_called() + session.create_response.assert_not_called() + session.end_session.assert_not_called() + + +@pytest.mark.parametrize( + "module", + [ + realtime_translation, + realtime_translation_direct_call, + ], +) +@pytest.mark.parametrize( + "events,error", + [ + ( + [ + {"type": "response.audio.delta", "delta": "AAE="}, + {"type": "response.done", "response": {"status": "completed"}}, + None, + ], + RuntimeError, + ), + ( + [{"type": "response.done", "response": {"status": "failed"}}], + RuntimeError, + ), + ([{"type": "response.audio.delta", "delta": "!invalid!"}], ValueError), + ([{"type": "session.finished"}], RuntimeError), + ], +) +def test_translation_rejects_incomplete_response(events, error, module): + session = Mock() + session.recv_event.side_effect = events + with pytest.raises(error): + module.receive_until( + session, + "session.finished", + module.time.monotonic() + 2, + Mock(), + ) + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_omni_direct_call, + realtime_qwen_audio, + realtime_qwen_audio_direct_call, + realtime_translation, + realtime_translation_direct_call, + ], +) +def test_realtime_existing_output_prevents_connection( + monkeypatch, + tmp_path, + module, +): + client = Mock(side_effect=AssertionError("unexpected client")) + monkeypatch.setattr(module, "Client", client) + path = tmp_path / "keep.wav" + path.write_bytes(b"keep") + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(path), + "--no-play", + ], + ) + with pytest.raises(FileExistsError): + module.run_turn(args) + assert path.read_bytes() == b"keep" + client.assert_not_called() + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_omni_direct_call, + realtime_qwen_audio, + realtime_qwen_audio_direct_call, + realtime_translation, + realtime_translation_direct_call, + ], +) +def test_realtime_examples_do_not_switch_model_families(module): + with pytest.raises(SystemExit) as caught: + module.make_parser().parse_args( + [ + "--microphone", + "--output", + "unused.wav", + "--model", + "other-family", + ], + ) + assert caught.value.code == 2 diff --git a/tests/v2/test_audio_samples_direct_call.py b/tests/v2/test_audio_samples_direct_call.py new file mode 100644 index 0000000..88485eb --- /dev/null +++ b/tests/v2/test_audio_samples_direct_call.py @@ -0,0 +1,685 @@ +# -*- coding: utf-8 -*- +"""Explicit audio sample startup, ownership, and shutdown contracts.""" + +import ast +import importlib +import json +import wave +from pathlib import Path +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import dashscope + + +def _direct(name): + # ``01_atomic_capabilities`` is not a valid identifier in source-level + # imports, so the samples are loaded through importlib instead. + return importlib.import_module( + f"examples.01_atomic_capabilities.audio.direct_call.{name}", + ) + + +_common = _direct("_common") +asr_microphone = _direct("asr_microphone") +asr_websocket = _direct("asr_websocket") +connection_reuse = _direct("connection_reuse") +realtime_omni = _direct("realtime_omni") +realtime_qwen_audio = _direct("realtime_qwen_audio") +realtime_translation = _direct("realtime_translation") +tts_websocket = _direct("tts_websocket") +voice_clone = _direct("voice_clone") + + +SAMPLES = ( + Path(__file__).resolve().parents[2] + / "examples" + / "01_atomic_capabilities" + / "audio" +) +NAMES = sorted( + path.stem + for path in SAMPLES.glob("*.py") + # tingwu is a standalone example without a direct_call copy. + if not path.name.startswith("_") and path.stem != "tingwu" +) +REALTIME = [realtime_omni, realtime_qwen_audio, realtime_translation] +PCM = b"\x01\x00" * 1600 + + +@pytest.fixture +def wav_input(tmp_path): + path = tmp_path / "input.wav" + with wave.open(str(path), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM) + return path + + +@pytest.mark.parametrize("name", ["_common", *NAMES]) +def test_copies_do_not_use_context_managers_or_original_helpers(name): + path = SAMPLES / "direct_call" / f"{name}.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + assert not isinstance(node, (ast.With, ast.AsyncWith)) + if isinstance(node, ast.Attribute): + assert node.attr not in ("__enter__", "__exit__") + if isinstance(node, ast.ImportFrom): + assert node.module != "contextlib" + # Samples import their own package relatively; a level-2 import + # (``from ..``) would reach the original context-manager helpers. + assert node.level <= 1 + assert len(NAMES) == 12 + + +@pytest.mark.parametrize("name", NAMES) +def test_direct_cli_help_has_no_side_effects(monkeypatch, capsys, name): + module = _direct(name) + forbidden = Mock(side_effect=AssertionError("help opened a resource")) + monkeypatch.setattr(dashscope, "Client", forbidden) + if hasattr(module, "Client"): + monkeypatch.setattr(module, "Client", forbidden) + monkeypatch.setattr(_common, "load_sounddevice", forbidden) + with pytest.raises(SystemExit) as error: + module.main(["--help"]) + assert error.value.code == 0 + assert ( + "examples.01_atomic_capabilities.audio.direct_call." + in capsys.readouterr().out + ) + forbidden.assert_not_called() + + +@pytest.mark.parametrize( + "name,arguments", + [ + ( + "asr_file", + ["--url", "https://cdn.test/input.wav", "--output-dir", "result"], + ), + ( + "asr_http_stream", + ["--file", "input.wav", "--transcript", "asr.txt"], + ), + ("asr_websocket", ["input.wav", "--transcript", "asr.txt"]), + ( + "asr_microphone", + [ + "--seconds", + "0.1", + "--transcript", + "asr.txt", + "--recording", + "recording.wav", + ], + ), + ("tts_http", ["--text", "你好", "--output", "output.wav", "--no-play"]), + ( + "tts_http_stream", + ["--text", "你好", "--output", "output.wav", "--no-play"], + ), + ( + "tts_websocket", + ["--text", "你好", "--output", "output.wav", "--no-play"], + ), + ("voice_clone", ["list", "--output", "voices.json"]), + ("connection_reuse", ["asr", "input.wav", "--output-dir", "result"]), + ("connection_reuse", ["tts", "--output-dir", "result"]), + *[ + ( + module.__name__.rsplit(".", 1)[1], + [ + "--wav", + "input.wav", + "--output", + "output.wav", + "--no-play", + ], + ) + for module in REALTIME + ], + ], +) +def test_client_creation_failure_closes_earlier_files( + monkeypatch, + tmp_path, + wav_input, + name, + arguments, +): + module = _direct(name) + files, readers = [], [] + original_open, original_wave = Path.open, wave.open + + def open_file(path, *args, **kwargs): + file = original_open(path, *args, **kwargs) + files.append(file) + return file + + def open_wave(path, mode=None): + audio = original_wave(path, mode) + if mode == "rb": + readers.append(audio) + return audio + + monkeypatch.setattr(Path, "open", open_file) + monkeypatch.setattr(wave, "open", open_wave) + factory = Mock(side_effect=RuntimeError("client failed")) + monkeypatch.setattr(dashscope, "Client", factory) + if hasattr(module, "Client"): + monkeypatch.setattr(module, "Client", factory) + stream = Mock() + if module is asr_microphone: + monkeypatch.setattr(module, "load_sounddevice", Mock()) + monkeypatch.setattr(module, "microphone", Mock(return_value=stream)) + filenames = { + "input.wav", + "asr.txt", + "recording.wav", + "output.wav", + "voices.json", + "result", + } + argv = [ + str(tmp_path / arg) if arg in filenames else arg for arg in arguments + ] + if module in (asr_microphone, asr_websocket): + assert module.main(argv) == 1 + else: + with pytest.raises(RuntimeError, match="client failed"): + module.main(argv) + factory.assert_called_once() + assert files and all(file.closed for file in files) + assert all(reader._file is None for reader in readers) + if module is asr_microphone: + stream.close.assert_called_once() + stream.start.assert_not_called() + + +@pytest.mark.parametrize("module", REALTIME) +@pytest.mark.parametrize("failure", ["connect", "executor", "send", "close"]) +def test_realtime_cleanup_on_partial_start_and_send_failure( + monkeypatch, + tmp_path, + module, + failure, +): + session = Mock() + closed = Event() + order = [] + session.recv_event.side_effect = [{"type": "session.updated"}] + + def connect(): + if failure == "connect": + raise RuntimeError("connect failed") + + def close(): + order.append("session.close") + closed.set() + if failure == "close": + raise RuntimeError("close failed") + + session.connect.side_effect = connect + session.close.side_effect = close + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=session), + ), + ), + close=Mock(side_effect=lambda: order.append("client.close")), + ) + monkeypatch.setattr(module, "Client", Mock(return_value=client)) + executor = Mock() + executor.submit.return_value = Mock() + + def shutdown(*, wait): + assert wait and closed.is_set() + order.append("executor.shutdown") + + executor.shutdown.side_effect = shutdown + factory = Mock(return_value=executor) + if failure == "executor": + factory.side_effect = RuntimeError("executor failed") + monkeypatch.setattr(module, "ThreadPoolExecutor", factory) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + raw, output = Mock(), Mock() + monkeypatch.setattr(module, "wav_output", Mock(return_value=(raw, output))) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match=f"{failure} failed"): + module.run_turn(args) + session.connect.assert_called_once() + session.close.assert_called_once() + client.close.assert_called_once() + output.close.assert_called_once() + raw.close.assert_called_once() + if failure in ("send", "close"): + assert order == ["session.close", "executor.shutdown", "client.close"] + else: + executor.shutdown.assert_not_called() + + +@pytest.mark.parametrize("module", REALTIME) +def test_realtime_close_unblocks_real_receiver(monkeypatch, tmp_path, module): + closed = Event() + calls = 0 + + def receive(): + nonlocal calls + calls += 1 + if calls == 1: + return {"type": "session.updated"} + assert closed.wait(2) + return None + + session = Mock() + session.recv_event.side_effect = receive + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=session), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(module, "Client", Mock(return_value=client)) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + module.run_turn(args) + session.connect.assert_called_once() + session.close.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("module", REALTIME) +@pytest.mark.parametrize("overflow", [False, True]) +def test_realtime_microphone_explicit_lifecycle(monkeypatch, module, overflow): + stream = Mock() + stream.read.return_value = (PCM, overflow) + monkeypatch.setattr(module, "microphone", Mock(return_value=stream)) + receiver = Mock() + receiver.done.return_value = False + session = Mock() + args = SimpleNamespace(device=None, seconds=0.1) + if overflow: + with pytest.raises(RuntimeError, match="溢出"): + module.send_input( + session, + receiver, + args, + None, + module.time.monotonic() + 2, + ) + session.append_audio.assert_not_called() + else: + module.send_input( + session, + receiver, + args, + None, + module.time.monotonic() + 2, + ) + session.append_audio.assert_called_once_with(PCM) + stream.start.assert_called_once() + stream.stop.assert_called_once() + stream.close.assert_called_once() + + +class TaskSession: + def __init__(self, identifier): + self.session = SimpleNamespace( + task_id=identifier, + request_id=identifier, + ) + self.request_id = identifier + self.first_package_delay = 1 + self.last_package_delay = 2 + self.usage = {} + self.last_response = None + self.stopped = Event() + self.started = False + self.closed = False + self.sent = [] + + def start(self): + assert not self.started + self.started = True + + def send_audio_frame(self, data): + assert self.started and not self.closed + self.sent.append(data) + + def send_text(self, text): + assert self.started and not self.closed + self.sent.append(text) + + def stop(self): + assert self.started and self.sent + self.stopped.set() + + def finish(self): + assert self.stopped.wait(2) + self.last_response = { + "header": { + "event": "task-finished", + "task_id": self.session.task_id, + }, + } + + def results(self): + self.finish() + yield SimpleNamespace( + get_sentence=lambda: {"text": "你好"}, + is_sentence_end=lambda: True, + ) + + def events(self): + self.finish() + yield PCM + yield self.last_response + + def close(self): + self.closed = True + self.stopped.set() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +def test_websocket_samples_start_and_drain( + monkeypatch, + tmp_path, + wav_input, + kind, +): + session = TaskSession("single-task") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=Mock(return_value=session)), + tts=SimpleNamespace(connect=Mock(return_value=session)), + ), + close=Mock(), + ) + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + if kind == "asr": + transcript = tmp_path / "transcript.txt" + assert ( + asr_websocket.main( + [ + str(wav_input), + "--transcript", + str(transcript), + ], + ) + == 0 + ) + assert transcript.read_text() == "你好\n" + assert b"".join(session.sent) == PCM + else: + monkeypatch.setattr(tts_websocket, "Client", Mock(return_value=client)) + output = tmp_path / "output.wav" + tts_websocket.main( + [ + "--text", + "你好", + "--output", + str(output), + "--no-play", + ], + ) + with wave.open(str(output), "rb") as audio: + assert audio.readframes(audio.getnframes()) == PCM + assert session.sent == ["你好"] + assert session.started and session.closed and session.last_response + client.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +@pytest.mark.parametrize("failure", ["start", "executor"]) +def test_websocket_partial_start_cleanup(monkeypatch, kind, failure): + session = Mock() + if failure == "start": + session.start.side_effect = RuntimeError("start failed") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=Mock(return_value=session)), + tts=SimpleNamespace(connect=Mock(return_value=session)), + ), + close=Mock(), + ) + executor = Mock() + factory = Mock(return_value=executor) + if failure == "executor": + factory.side_effect = RuntimeError("executor failed") + module = asr_websocket if kind == "asr" else tts_websocket + monkeypatch.setattr(module, "ThreadPoolExecutor", factory) + if kind == "asr": + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + args = SimpleNamespace( + timeout=1, + region=None, + workspace=None, + model="qwen-audio-3.1-asr-flash-streaming", + vocabulary_id=None, + partials=False, + ) + with pytest.raises(asr_websocket.SampleError): + module.run_recognition(args, 16000, Mock(), Mock()) + client.close.assert_called_once() + else: + args = module.make_parser().parse_args( + [ + "--text", + "你好", + "--output", + "unused.wav", + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match=f"{failure} failed"): + module.synthesize(client, args, ["你好"], Mock(), None) + session.close.assert_called_once() + session.stop.assert_not_called() + if failure == "start" and kind == "asr": + executor.shutdown.assert_called_once_with(wait=True) + + +def test_llm_stream_closes_when_tts_fails(monkeypatch, tmp_path): + stream = SimpleNamespace(text_stream=iter(["你好"]), close=Mock()) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=Mock(return_value=stream), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(tts_websocket, "Client", Mock(return_value=client)) + monkeypatch.setattr( + tts_websocket, + "synthesize", + Mock( + side_effect=RuntimeError("synthesis failed"), + ), + ) + with pytest.raises(RuntimeError, match="synthesis failed"): + tts_websocket.main( + [ + "--prompt", + "你好", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + stream.close.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +def test_five_tasks_keep_shared_connection_open( + monkeypatch, + tmp_path, + wav_input, + kind, +): + sessions = [] + connection = SimpleNamespace(closed=False) + connection.close = Mock( + side_effect=lambda: setattr(connection, "closed", True), + ) + + def task(**kwargs): + assert kwargs["connection"] is connection and not connection.closed + assert all(session.closed for session in sessions) + session = TaskSession(f"task-{len(sessions) + 1}") + sessions.append(session) + return session + + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=task), + tts=SimpleNamespace(connect=task), + ), + ws_connection=Mock(return_value=connection), + close=Mock(), + ) + monkeypatch.setattr(connection_reuse, "Client", Mock(return_value=client)) + folder = tmp_path / "reuse" + source_args = [str(wav_input)] if kind == "asr" else [] + assert ( + connection_reuse.main( + [ + kind, + *source_args, + "--output-dir", + str(folder), + ], + ) + == 0 + ) + summary = json.loads((folder / "summary.json").read_text()) + assert summary["status"] == "completed" + assert len(summary["runs"]) == len(sessions) == 5 + assert all(session.started and session.closed for session in sessions) + assert summary["connection_count"] == 1 and summary["connection_closed"] + connection.close.assert_called_once() + client.ws_connection.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("record", [False, True]) +def test_asr_microphone_direct_start_stop(monkeypatch, tmp_path, record): + stream = Mock() + stream.read.return_value = (PCM, False) + monkeypatch.setattr(asr_microphone, "load_sounddevice", Mock()) + monkeypatch.setattr( + asr_microphone, + "microphone", + Mock(return_value=stream), + ) + session = TaskSession("microphone-task") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace( + recognize=Mock(return_value=session), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + recording = tmp_path / "recording.wav" + options = ["--recording", str(recording)] if record else ["--no-recording"] + assert ( + asr_microphone.main( + [ + "--seconds", + "0.1", + "--transcript", + str(tmp_path / "asr.txt"), + *options, + ], + ) + == 0 + ) + stream.start.assert_called_once() + stream.stop.assert_called_once() + stream.close.assert_called_once() + assert session.started and session.closed and session.sent == [PCM] + assert recording.exists() == record + if record: + with wave.open(str(recording), "rb") as audio: + assert audio.readframes(audio.getnframes()) == PCM + + +@pytest.mark.parametrize("save", [False, True]) +def test_voice_query_closes_client_and_optional_output( + monkeypatch, + tmp_path, + save, +): + voice = SimpleNamespace( + status="OK", + model_dump=Mock(return_value={"voice_id": "v-test"}), + ) + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace( + voices=SimpleNamespace(get=Mock(return_value=voice)), + ), + close=Mock(), + ) + monkeypatch.setattr(voice_clone, "Client", Mock(return_value=client)) + output = tmp_path / "voice.json" + options = ["--output", str(output)] if save else [] + assert voice_clone.main(["get", "--voice-id", "v-test", *options]) == 0 + client.close.assert_called_once() + assert output.exists() == save + + +def test_wav_helpers_own_resources_explicitly(monkeypatch, tmp_path): + raw = Mock() + writer = Mock() + monkeypatch.setattr(Path, "open", Mock(return_value=raw)) + monkeypatch.setattr(wave, "open", Mock(return_value=writer)) + writer.setframerate.side_effect = RuntimeError("invalid format") + with pytest.raises(RuntimeError, match="invalid format"): + _common.wav_output(tmp_path / "invalid.wav", 24000) + writer.close.assert_called_once() + raw.close.assert_called_once() + reader = Mock() + reader.getnchannels.return_value = 2 + monkeypatch.setattr(wave, "open", Mock(return_value=reader)) + with pytest.raises(ValueError): + _common.open_wav(tmp_path / "stereo.wav") + reader.close.assert_called_once() diff --git a/tests/v2/test_audio_translation.py b/tests/v2/test_audio_translation.py deleted file mode 100644 index 2c78305..0000000 --- a/tests/v2/test_audio_translation.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Tests for client.audio.translation (Gummy realtime translation).""" - -# Fake connectors only inspect the headers/url they care about. -# pylint: disable=unused-argument - -from __future__ import annotations - -import json - -import pytest - -from dashscope.v2 import Client -from dashscope.v2.errors import DashScopeError -from dashscope.v2.resources.audio.asr import FILE_CHUNK_SIZE - - -def _make_client(**kwargs): - # Realtime sessions talk over the websocket transport, but the Client - # still resolves an HTTP config; pin a workspace so the derived URLs - # stay deterministic (without one the SDK falls back to the legacy - # shared gateway with a warning). - kwargs.setdefault("workspace", "ws-test") - return Client(api_key="sk-test", **kwargs) - - -class FakeWebSocket: - """Scripted websocket stub: records sends, replays a queued inbox.""" - - def __init__(self, inbox=None): - self.sent = [] - self.inbox = list(inbox or []) - self.closed = False - - def send(self, payload, opcode=1): - assert opcode == (2 if isinstance(payload, bytes) else 1) - self.sent.append(payload) - - def recv(self): - if not self.inbox: - return None - return self.inbox.pop(0) - - def close(self): - self.closed = True - - def settimeout(self, seconds): - pass - - -def _event(name, payload=None): - return json.dumps( - { - "header": {"event": name, "task_id": "t", "request_id": "r"}, - "payload": payload or {}, - }, - ) - - -def _sent_frames(ws): - return [json.loads(item) for item in ws.sent if isinstance(item, str)] - - -def _bilingual_output(**overrides): - output = { - "transcription": { - "sentence_id": 1, - "text": "你好", - "begin_time": 0, - "current_time": 100, - "words": [], - "sentence_end": False, - }, - "translations": [ - { - "sentence_id": 1, - "lang": "en", - "text": "Hello", - "begin_time": 0, - "current_time": 100, - "words": [], - "sentence_end": False, - }, - ], - } - output.update(overrides) - return output - - -def test_translation_recognize_merges_named_parameters(): - ws = FakeWebSocket( - [ - _event("task-started"), - _event("task-finished"), - ], - ) - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - source_language="zh", - transcription_enabled=True, - translation_enabled=True, - translation_target_languages=["en"], - max_end_silence=800, - parameters={"source_language": "ja", "future_option": False}, - connector=lambda url, headers: ws, - ) as session: - session.send_audio_frame(b"AUD") - session.stop() - events = list(session.events()) - assert events[-1]["header"]["event"] == "task-finished" - - frames = _sent_frames(ws) - assert frames[0]["header"]["action"] == "run-task" - assert frames[0]["payload"]["task_group"] == "audio" - assert frames[0]["payload"]["task"] == "asr" - assert frames[0]["payload"]["function"] == "recognition" - assert frames[0]["payload"]["model"] == "gummy-realtime-v1" - params = frames[0]["payload"]["parameters"] - assert params["format"] == "pcm" - assert params["sample_rate"] == 16000 - # Named kwargs win over the parameters= escape hatch. - assert params["source_language"] == "zh" - assert params["transcription_enabled"] is True - assert params["translation_enabled"] is True - assert params["translation_target_languages"] == ["en"] - assert params["max_end_silence"] == 800 - # The parameters= pass-through itself survives. - assert params["future_option"] is False - assert ws.sent[1] == b"AUD" - assert frames[1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_results_yield_typed_bilingual(): - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - { - "output": { - "transcription": { - "sentence_id": 0, - "text": "", - "heartbeat": True, - }, - }, - }, - ), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event( - "result-generated", - { - "output": _bilingual_output( - transcription=None, - translations=[ - { - "sentence_id": 2, - "lang": "en", - "text": "Done.", - "sentence_end": True, - }, - ], - ), - }, - ), - _event("task-finished"), - ], - ) - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - translation_enabled=True, - translation_target_languages=["en"], - connector=lambda url, headers: ws, - ) as session: - session.send_audio_frame(b"AUD") - session.stop() - results = list(session.results()) - - # Heartbeat frame and task-finished are filtered out. - assert len(results) == 2 - first = results[0] - assert first.get_transcription()["text"] == "你好" - assert first.get_translations()[0]["lang"] == "en" - assert first.is_sentence_end() is False - assert first.request_id == "r" - assert first.usage == {"duration": 1} - second = results[1] - assert second.get_transcription() is None - assert second.get_translations()[0]["text"] == "Done." - assert second.is_sentence_end() is True - # Gummy reports text under ``output.transcription`` (not ``output. - # sentence``); the shared first-package-delay trigger must still fire. - assert session.first_package_delay is not None - assert session.first_package_delay >= 0 - - -def test_translation_recognize_file_chunks_and_yields_typed_results( - tmp_path, -): - audio_file = tmp_path / "sample.wav" - audio_file.write_bytes(b"\x00" * (FILE_CHUNK_SIZE + 100)) - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event("task-finished"), - ], - ) - client = _make_client() - results = list( - client.audio.translation.recognize_file( - str(audio_file), - model="gummy-realtime-v1", - translation_enabled=True, - translation_target_languages=["en"], - connector=lambda url, headers: ws, - ), - ) - - assert len(results) == 1 - assert results[0].get_transcription()["text"] == "你好" - assert results[0].get_translations()[0]["lang"] == "en" - assert ws.sent[1] == b"\x00" * FILE_CHUNK_SIZE - assert ws.sent[2] == b"\x00" * 100 - frames = _sent_frames(ws) - assert frames[0]["header"]["action"] == "run-task" - assert frames[-1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_recognize_file_validates_path(tmp_path): - client = _make_client() - with pytest.raises(ValueError, match="not found"): - client.audio.translation.recognize_file( - "/nonexistent/audio.wav", - model="gummy-realtime-v1", - ) - empty = tmp_path / "empty.wav" - empty.write_bytes(b"") - with pytest.raises(ValueError, match="empty"): - client.audio.translation.recognize_file( - str(empty), - model="gummy-realtime-v1", - ) - - -def test_translation_recognize_file_forwards_named_parameters(tmp_path): - # recognize_file re-assembles the run-task payload separately from - # recognize; assert every named param reaches the wire so the duplicate - # forwarding block cannot silently drop one (M3 wire parity). - audio_file = tmp_path / "sample.wav" - audio_file.write_bytes(b"\x00" * FILE_CHUNK_SIZE) - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event("task-finished"), - ], - ) - client = _make_client() - list( - client.audio.translation.recognize_file( - str(audio_file), - model="gummy-realtime-v1", - format="opus", - sample_rate=8000, - source_language="zh", - transcription_enabled=False, - translation_enabled=True, - translation_target_languages=["en", "ja"], - max_end_silence=800, - phrase_id="ph-1", - connector=lambda url, headers: ws, - ), - ) - - frames = _sent_frames(ws) - run = frames[0] - assert run["header"]["action"] == "run-task" - assert run["payload"]["task_group"] == "audio" - assert run["payload"]["task"] == "asr" - assert run["payload"]["function"] == "recognition" - assert run["payload"]["model"] == "gummy-realtime-v1" - assert run["payload"]["input"] == {} - params = run["payload"]["parameters"] - assert params["format"] == "opus" - assert params["sample_rate"] == 8000 - assert params["source_language"] == "zh" - # Falsey named values survive forwarding (not dropped by an ``if value``). - assert params["transcription_enabled"] is False - assert params["translation_enabled"] is True - assert params["translation_target_languages"] == ["en", "ja"] - assert params["max_end_silence"] == 800 - assert run["payload"]["resources"] == [ - {"resource_id": "ph-1", "resource_type": "asr_phrase"}, - ] - assert frames[-1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_raises_on_task_failed(): - ws = FakeWebSocket( - [ - json.dumps( - { - "header": { - "event": "task-failed", - "task_id": "t", - "error_code": "InvalidParameter", - "error_message": "bad source_language", - }, - "payload": {}, - }, - ), - ], - ) - client = _make_client() - with pytest.raises(DashScopeError, match="bad source_language"): - with client.audio.translation.recognize( - model="gummy-realtime-v1", - source_language="xx", - connector=lambda url, headers: ws, - ): - pass - assert ws.closed is True - - -def test_translation_threads_per_call_auth(): - # Per-call api_key/workspace must drive the gateway host AND the auth - # headers together, so the socket and its credentials never disagree. - captured = {} - - def connector(url, headers): - captured["url"] = url - captured["headers"] = headers - return FakeWebSocket( - [_event("task-started"), _event("task-finished")], - ) - - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - api_key="sk-override", - workspace="ws-override", - connector=connector, - ) as session: - assert captured["url"].startswith( - "wss://ws-override.cn-beijing.maas.aliyuncs.com/", - ) - assert captured["headers"]["Authorization"] == "Bearer sk-override" - assert captured["headers"]["X-DashScope-WorkSpace"] == "ws-override" - session.stop() - list(session.events()) diff --git a/tests/v2/test_audio_tts_http.py b/tests/v2/test_audio_tts_http.py index f43ac98..8737a3e 100644 --- a/tests/v2/test_audio_tts_http.py +++ b/tests/v2/test_audio_tts_http.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Tests for client.audio.tts.http (M4): sambert/cosyvoice + qwen-tts.""" +"""Tests for HTTP speech synthesis across Qwen-Audio, CosyVoice and Qwen-TTS.""" # Mock handlers intentionally ignore the request argument. # pylint: disable=unused-argument @@ -14,7 +14,15 @@ import pytest from dashscope.v2 import Client -from dashscope.v2.errors import BadRequestError +from dashscope.v2.errors import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + DashScopeError, + InternalServerError, +) BASE_URL = "https://api.test.local/api/v1" @@ -56,18 +64,18 @@ def handler(request): client = _make_client(handler) result = client.audio.tts.http.synthesize( "你好世界", - model="sambert-zhichu-v1", - voice="zhichu", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", format="wav", sample_rate=16000, ) assert captured["path"] == "/api/v1/services/audio/tts/SpeechSynthesizer" assert captured["body"] == { - "model": "sambert-zhichu-v1", + "model": "qwen-audio-3.1-tts-flash", "input": { "text": "你好世界", - "voice": "zhichu", + "voice": "longanfengyue_v3.1", "format": "wav", "sample_rate": 16000, }, @@ -147,7 +155,27 @@ def handler(request): assert captured["body"]["parameters"] == {"seed": 42} -def test_http_stream_yields_chunks_and_final_reference(): +@pytest.mark.parametrize( + "model, voice, path", + [ + ( + "qwen-audio-3.1-tts-flash", + "longanfengyue_v3.1", + "/api/v1/services/audio/tts/SpeechSynthesizer", + ), + ( + "cosyvoice-v3-flash", + "longanfengyu", + "/api/v1/services/audio/tts/SpeechSynthesizer", + ), + ( + "qwen3-tts-flash", + "Cherry", + "/api/v1/services/aigc/multimodal-generation/generation", + ), + ], +) +def test_http_stream_yields_chunks_and_final_reference(model, voice, path): sentence = {"id": 1, "text": "你好"} sse = ( "id: 1\ndata: " @@ -183,6 +211,8 @@ def test_http_stream_yields_chunks_and_final_reference(): captured = {} def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode()) captured["sse"] = request.headers.get("x-dashscope-sse") return httpx.Response( 200, @@ -197,13 +227,16 @@ def handler(request): chunks = list( client.audio.tts.http.stream( "你好", - model="cosyvoice-v3-flash", - voice="longanfengyu", + model=model, + voice=voice, format="pcm", sample_rate=24000, ), ) + assert captured["path"] == path + assert captured["body"]["model"] == model + assert captured["body"]["input"]["voice"] == voice assert captured["sse"] == "enable" assert len(chunks) == 3 @@ -240,7 +273,7 @@ def handler(request): client = _make_client(handler) stream = client.audio.tts.http.stream( "你好", - model="sambert-zhichu-v1", + model="qwen-audio-3.1-tts-flash", voice="nobody", ) assert next(stream).output["type"] == "sentence-begin" @@ -259,8 +292,8 @@ def handler(request): with pytest.raises(BadRequestError, match="text is required"): client.audio.tts.http.synthesize( "", - model="sambert-zhichu-v1", - voice="zhichu", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", ) @@ -326,3 +359,748 @@ def handler(request): assert len(chunks) == 1 assert chunks[0].is_final assert chunks[0].audio_url == "https://cdn.test/f.wav" + + +_TTS_ARGS = { + "model": "qwen-audio-3.1-tts-flash", + "voice": "longanfengyue_v3.1", +} + + +class _TrackedStream(httpx.SyncByteStream): + """Unbuffered body: Response(content=...) can hide missing close calls.""" + + def __init__(self, *parts, error=None): + self.parts = parts + self.error = error + self.closed = False + + def __iter__(self): + yield from self.parts + if self.error is not None: + raise self.error + + def close(self): + self.closed = True + + +def _stream_client(wire, *, status=200, headers=None, **kwargs): + response = httpx.Response( + status, + stream=wire, + headers={"content-type": "text/event-stream", **(headers or {})}, + ) + return ( + _make_client(lambda request: response, max_retries=0, **kwargs), + response, + ) + + +def _frame(body): + return ("data: " + json.dumps(body) + "\n\n").encode() + + +@pytest.mark.parametrize("status", [200, 400, 401, 500]) +def test_http_error_envelope_and_request_id(status): + body = { + "code": "InvalidApiKey" if status == 401 else "InvalidParameter", + "message": "synthesis rejected", + } + client = _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-id"}, + ), + max_retries=0, + ) + error_type = AuthenticationError if status == 401 else BadRequestError + with pytest.raises(error_type, match="synthesis rejected") as caught: + client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert caught.value.code == body["code"] + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.body == body + + +@pytest.mark.parametrize("status", [200, 400]) +def test_http_nonraising_failure_preserves_error_envelope(status): + # Compatibility must retain failure information, not return an empty success. + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": "err-1", + } + client = _make_client( + lambda request: httpx.Response(status, json=body), + raise_on_error=False, + max_retries=0, + ) + result = client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert result.code == "InvalidParameter" + assert result.message == "bad voice" + assert result.is_error + assert result.request_id == "err-1" + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +def test_http_redirect_is_not_success(method): + client = _make_client( + lambda request: httpx.Response( + 302, + json={}, + headers={"location": "https://other.test/"}, + ), + max_retries=0, + ) + with pytest.raises(APIStatusError) as caught: + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + list(result) + assert caught.value.status_code == 302 + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_malformed_json_is_not_suppressed(raise_on_error): + client = _make_client( + lambda request: httpx.Response(200, content=b'{"output":'), + raise_on_error=raise_on_error, + ) + with pytest.raises(ValueError): + client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize( + "transport_error, sdk_error", + [ + (httpx.ConnectError, APIConnectionError), + (httpx.ReadTimeout, APITimeoutError), + ], +) +def test_http_transport_error_not_suppressed( + method, + transport_error, + sdk_error, +): + def handler(request): + raise transport_error("offline", request=request) + + client = _make_client(handler, raise_on_error=False, max_retries=0) + with pytest.raises(sdk_error) as caught: + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + list(result) + assert isinstance(caught.value.__cause__, transport_error) + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("status", [400, 500]) +def test_http_stream_initial_http_error_closes_response( + raise_on_error, + status, +): + wire = _TrackedStream(b"gateway rejected") + client, response = _stream_client( + wire, + status=status, + headers={"x-request-id": "gateway-id"}, + raise_on_error=raise_on_error, + ) + error_type = BadRequestError if status == 400 else InternalServerError + with pytest.raises(error_type, match="gateway rejected") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.status_code == status + assert caught.value.request_id == "gateway-id" + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize( + "status_line, expected_status", + [("", 200), ("status: 400\n", 400)], +) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_error_always_raises_and_closes( + status_line, + expected_status, + raise_on_error, +): + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": "error-id", + } + wire = _TrackedStream( + _frame({"output": {"type": "sentence-begin"}}), + b"event:error\n" + status_line.encode() + _frame(body), + _frame({"output": {"finish_reason": "stop"}}), + ) + client, response = _stream_client(wire, raise_on_error=raise_on_error) + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert next(stream).output["type"] == "sentence-begin" + assert not wire.closed + with pytest.raises(BadRequestError, match="bad voice") as caught: + next(stream) + assert caught.value.request_id == "error-id" + assert caught.value.status_code == expected_status + assert wire.closed and response.is_closed + assert list(stream) == [] + + +def test_http_stream_error_uses_header_request_id_fallback(): + wire = _TrackedStream( + b"event:error\n" + + _frame({"code": "InvalidParameter", "message": "bad voice"}), + ) + client, response = _stream_client( + wire, + headers={"x-request-id": "stream-id"}, + ) + with pytest.raises(BadRequestError) as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + assert caught.value.request_id == "stream-id" + + +@pytest.mark.parametrize("event_line", [b"event: error\n", b""]) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_recognizes_standard_and_data_only_business_errors( + event_line, + raise_on_error, +): + wire = _TrackedStream( + event_line + + _frame({"code": "InvalidParameter", "message": "bad voice"}), + ) + client, response = _stream_client( + wire, + headers={"x-request-id": "stream-id"}, + raise_on_error=raise_on_error, + ) + try: + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == "stream-id" + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_http200_json_business_error_is_not_empty_success( + raise_on_error, +): + wire = _TrackedStream(b'{"code":"InvalidParameter","message":"bad voice"}') + client, response = _stream_client( + wire, + headers={ + "content-type": "application/json", + "x-request-id": "json-id", + }, + raise_on_error=raise_on_error, + ) + try: + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == "json-id" + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("data", [b"{broken", b"null", b"[]", b'"text"']) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_malformed_data_raises_and_closes(data, raise_on_error): + wire = _TrackedStream(b"data: " + data + b"\n\n") + client, response = _stream_client(wire, raise_on_error=raise_on_error) + with pytest.raises(DashScopeError, match="invalid synthesis stream data"): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("data", [b'{"output":', b"null", b"[]", b'"text"']) +def test_http_stream_malformed_json_response_raises_and_closes( + raise_on_error, + data, +): + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + headers={"content-type": "application/json"}, + raise_on_error=raise_on_error, + ) + with pytest.raises(ValueError): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("payload", ["%%%", "QUJD$", "a"]) +def test_http_stream_invalid_base64_is_rejected_and_closed(payload): + wire = _TrackedStream(_frame({"output": {"audio": {"data": payload}}})) + client, response = _stream_client(wire) + try: + # Either an SDK error or a decoding ValueError is acceptable; silently + # dropping invalid bytes and returning apparently valid audio is not. + with pytest.raises((DashScopeError, ValueError)): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + finally: + assert wire.closed and response.is_closed + + +def test_http_stream_multiline_sse_data_is_one_event(): + wire = _TrackedStream( + b': keepalive\n\nid: 1\ndata: {\ndata: "output": ' + b'{"audio": {"data": "QUJD"}, "finish_reason": "stop"}\ndata: }\n\n', + ) + client, response = _stream_client(wire) + try: + chunks = list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert len(chunks) == 1 + assert chunks[0].audio == b"ABC" + assert chunks[0].is_final + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("early_close", [False, True]) +def test_http_stream_open_and_response_lifetime(early_close): + requests = [] + wire = _TrackedStream( + _frame( + {"output": {"audio": {"data": "QUJD"}}, "request_id": "body-id"}, + ), + _frame( + {"output": {"finish_reason": "stop"}, "usage": {"characters": 1}}, + ), + ) + response = httpx.Response( + 200, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + + def handler(request): + requests.append(request) + return response + + client = _make_client(handler) + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert len(requests) == 1 + assert stream.headers["x-request-id"] == "header-id" + assert iter(stream) is stream + first = next(stream) + assert first.audio == b"ABC" + assert first.request_id == "body-id" + assert not wire.closed + if early_close: + stream.close() + else: + (final,) = list(stream) + assert final.is_final + assert final.audio is None + assert final.request_id == "header-id" + assert final.usage == {"characters": 1} + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_http_stream_close_before_first_chunk_releases_response( + raise_on_error, +): + wire = _TrackedStream( + error=AssertionError("closed stream must not be read"), + ) + client, response = _stream_client( + wire, + raise_on_error=raise_on_error, + headers={"x-request-id": "header-id"}, + ) + try: + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert stream.headers["x-request-id"] == "header-id" + assert not response.is_closed + stream.close() + stream.close() + assert wire.closed and response.is_closed + assert list(stream) == [] + assert stream.headers["x-request-id"] == "header-id" + finally: + response.close() + client.close() + client.transport.http_client.close() + + +def test_http_stream_close_failure_still_releases_response(): + from dashscope.v2.resources.audio.http_tts import ( + HttpSynthesisChunk, + HttpSynthesisStream, + ) + + def events(): + try: + yield HttpSynthesisChunk() + finally: + raise RuntimeError("generator cleanup failed") + + wire = _TrackedStream() + response = httpx.Response(200, stream=wire) + stream = HttpSynthesisStream(events(), response) + next(stream) + try: + with pytest.raises(RuntimeError, match="generator cleanup failed"): + stream.close() + assert wire.closed and response.is_closed + finally: + stream.close() + + +@pytest.mark.parametrize("status", [199, 200, 302, 500]) +def test_http_stream_read_failure_closes_unbuffered_response(status): + wire = _TrackedStream(error=httpx.ReadError("connection lost")) + client, response = _stream_client(wire, status=status) + try: + with pytest.raises((APIConnectionError, httpx.ReadError)): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + finally: + response.close() # Clean up even when the regression assertion fails. + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_http_optional_values_and_call_auth(method, explicit): + requests = [] + + def handler(request): + requests.append(request) + if method == "stream": + return httpx.Response( + 200, + stream=_TrackedStream(_frame({"output": {}})), + ) + return httpx.Response( + 200, + json={"output": {}}, + headers={"x-request-id": "header-id"}, + ) + + client = _make_client(handler) + http_client = client.transport.http_client + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + extra = { + "text": "wrong", + "voice": "wrong", + "format": "wav", + "sample_rate": 24000, + "language_type": "Chinese", + "enable_ssml": False, + } + original = dict(extra) + parameters = {} if explicit else None + call = getattr(client.audio.tts.http, method) + result = call( + "", + **_TTS_ARGS, + format="" if explicit else None, + sample_rate=0 if explicit else None, + language_type="" if explicit else None, + extra_input=extra, + parameters=parameters, + api_key="call-key", + workspace="call-ws", + timeout=0, + headers={"X-Test": "yes"}, + ) + if method == "stream": + list(result) + else: + assert result.request_id == "header-id" + inherited = call( + "你好", + **_TTS_ARGS, + api_key=None, + workspace=None, + timeout=None, + ) + if method == "stream": + list(inherited) + body = json.loads(requests[0].content) + assert body["input"] == { + "text": "", + "voice": _TTS_ARGS["voice"], + "format": "" if explicit else "wav", + "sample_rate": 0 if explicit else 24000, + "language_type": "" if explicit else "Chinese", + "enable_ssml": False, + } + if explicit: + assert body["parameters"] == {} + else: + assert "parameters" not in body + assert extra == original + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert requests[0].headers["x-test"] == "yes" + assert requests[0].extensions["timeout"]["read"] == 0 + assert requests[1].headers["authorization"] == "Bearer pool-key" + assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" + assert ( + requests[1].extensions["timeout"]["read"] == http_client.timeout.read + ) + assert all(request.url.host == "api.test.local" for request in requests) + expected_sse = "enable" if method == "stream" else None + assert requests[0].headers.get("x-dashscope-sse") == expected_sse + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("header", ["authorization", "x-dashscope-workspace"]) +def test_http_call_auth_overrides_case_insensitive_headers(method, header): + requests = [] + + def handler(request): + requests.append(request) + if method == "stream": + return httpx.Response( + 200, + stream=_TrackedStream(_frame({"output": {}})), + ) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + headers = {header: "old"} + result = getattr(client.audio.tts.http, method)( + "你好", + **_TTS_ARGS, + api_key="call-key", + workspace="call-ws", + headers=headers, + ) + if method == "stream": + list(result) + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert headers == {header: "old"} + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("request_id", [None, "body-id"]) +def test_http_results_preserve_full_body_and_empty_request_id( + method, + request_id, +): + body = { + "output": {"audio": {}, "future_output": False}, + "usage": {"characters": 0, "future_usage": []}, + "request_id": request_id, + "future_field": {"enabled": False}, + } + + def handler(request): + if method == "stream": + return httpx.Response(200, stream=_TrackedStream(_frame(body))) + return httpx.Response(200, json=body) + + client = _make_client(handler) + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + (result,) = list(result) + assert result.output == body["output"] + assert result.usage == body["usage"] + assert result.model_dump()["future_field"] == body["future_field"] + assert result.request_id == (request_id or "") + assert not result.is_error + + +@pytest.mark.parametrize("status", [302, 500]) +@pytest.mark.parametrize("body_kind", ["json", "text"]) +def test_http_nonraising_failure_without_code_keeps_http_metadata( + status, + body_kind, +): + body = { + "message": "unavailable", + "status_code": 200, + "future_field": False, + } + kwargs = {"json": body} if body_kind == "json" else {"text": "unavailable"} + client = _make_client( + lambda request: httpx.Response( + status, + headers={"x-request-id": "header-id"}, + **kwargs, + ), + raise_on_error=False, + max_retries=0, + ) + result = client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert result.status_code == status + assert result.request_id == "header-id" + assert result.message == "unavailable" + assert result.code is None + assert result.is_error + if body_kind == "json": + assert result.future_field is False + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("status", [199, 300, 302, 307]) +@pytest.mark.parametrize("body_kind", ["json", "malformed-json", "sse"]) +def test_http_stream_non2xx_rejected_before_parsing_and_closed( + raise_on_error, + status, + body_kind, +): + body = {"output": {"request_id": "inner-id"}, "status_code": 200} + if body_kind == "json": + data = json.dumps(body).encode() + elif body_kind == "malformed-json": + data = b'{"output":' + else: + data = _frame({"output": {"audio": {"data": "QUJD"}}}) + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + status=status, + raise_on_error=raise_on_error, + headers={ + "content-type": ( + "text/event-stream" + if body_kind == "sse" + else "application/json" + ), + "x-request-id": "header-id", + "location": "https://other.test/", + }, + ) + with pytest.raises(APIStatusError) as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.status_code == status + assert caught.value.code is None + expected_id = "inner-id" if body_kind == "json" else "header-id" + assert caught.value.request_id == expected_id + assert caught.value.headers == response.headers + if body_kind == "json": + assert caught.value.body == body + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize( + "kind, status", + [ + ("sse-business", 200), + ("sse-error", 200), + ("sse-status", 200), + ("json", 200), + ("json", 400), + ("json", 500), + ], +) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_http_stream_error_request_id_precedence_is_audio_local( + raise_on_error, + kind, + status, + outer_id, + inner_id, + expected_id, +): + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + if kind == "json": + data = json.dumps(body).encode() + else: + prefix = { + "sse-business": b"", + "sse-error": b"event: error\n", + "sse-status": b"event: error\nstatus: 400\n", + }[kind] + data = prefix + _frame(body) + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + status=status, + raise_on_error=raise_on_error, + headers={ + "content-type": ( + "application/json" if kind == "json" else "text/event-stream" + ), + "x-request-id": "header-id", + }, + ) + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == expected_id + assert caught.value.status_code == ( + 400 if kind == "sse-status" else status + ) + assert caught.value.body == body + assert caught.value.headers == response.headers + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_http_success_request_id_precedence( + method, + outer_id, + inner_id, + expected_id, +): + body = { + "request_id": outer_id, + "output": {"request_id": inner_id, "audio": {}}, + } + data = _frame(body) if method == "stream" else json.dumps(body).encode() + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + headers={"x-request-id": "header-id"}, + ) + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + (result,) = list(result) + expected_id = outer_id or "header-id" + assert result.request_id == expected_id + assert result.output == body["output"] + assert not result.is_error + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize("status", [None, 0, 200, 302, 503]) +def test_audio_chunk_keeps_wire_status_flag_local(raise_on_error, status): + wire = _TrackedStream(_frame({"output": {}, "status_code": status})) + client, response = _stream_client(wire, raise_on_error=raise_on_error) + try: + (chunk,) = list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert chunk.status_code == status + assert chunk.is_error == ( + status is not None and not 200 <= status < 300 + ) + assert response.is_closed and wire.closed + finally: + client.transport.http_client.close() diff --git a/tests/v2/test_audio_tts_realtime.py b/tests/v2/test_audio_tts_realtime.py index 9505846..fa29637 100644 --- a/tests/v2/test_audio_tts_realtime.py +++ b/tests/v2/test_audio_tts_realtime.py @@ -50,7 +50,16 @@ def recv(self): raise self.exc if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True diff --git a/tests/v2/test_audio_tts_session.py b/tests/v2/test_audio_tts_session.py index 804bc69..cafdebd 100644 --- a/tests/v2/test_audio_tts_session.py +++ b/tests/v2/test_audio_tts_session.py @@ -19,9 +19,9 @@ from dashscope.v2.resources.audio.tts import SynthesisSession -def _event(name, payload=None): +def _event(name, payload=None, task_id="t"): return { - "header": {"event": name, "task_id": "t", "request_id": "r"}, + "header": {"event": name, "task_id": task_id, "request_id": "r"}, "payload": payload or {}, } @@ -46,7 +46,12 @@ def recv(self): frame = self.inbox.pop(0) if self.inbox else None if isinstance(frame, Exception): raise frame # pylint: disable=raising-bad-type - return json.dumps(frame) if isinstance(frame, dict) else frame + if isinstance(frame, dict): + header = frame.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = self.sent[0]["header"]["task_id"] + return json.dumps(frame) + return frame def settimeout(self, timeout): if self.timeout_error: @@ -260,7 +265,9 @@ def test_cancel_once_before_terminal_including_after_stop( sent_count = len(ws.sent) session.cancel() assert len(ws.sent) == sent_count - assert list(session.events()) == [_event("task-finished")] + expected = _event("task-finished") + expected["header"]["task_id"] = ws.sent[0]["header"]["task_id"] + assert list(session.events()) == [expected] assert ws.close_count == 1 @@ -451,23 +458,52 @@ def test_stream_close_and_context_exception_clean_up(client): _assert_unusable(session) +@pytest.mark.parametrize("error_type", [KeyboardInterrupt, SystemExit]) +def test_interrupted_start_closes_session(client, monkeypatch, error_type): + ws = FakeWebSocket() + session = _session(client, ws) + error = error_type("interrupted handshake") + + def interrupted_run(*args, **kwargs): + raise error + + monkeypatch.setattr( + "dashscope.v2.resources.audio.tts.WsSession.run_task", + interrupted_run, + ) + try: + with pytest.raises(error_type) as caught: + session.start() + assert caught.value is error + assert ws.close_count == 1 + _assert_unusable(session) + finally: + session.close() + + def test_caller_thread_receives_while_another_sends(client): class DuplexWebSocket(FakeWebSocket): def __init__(self): super().__init__() self.frames = queue.Queue() - self.frames.put(json.dumps(_event("task-started"))) self.waiting_for_more = threading.Event() def send(self, payload): super().send(payload) frame = self.sent[-1] action = frame["header"]["action"] + task_id = frame["header"]["task_id"] data = frame["payload"]["input"] - if action == "continue-task" and "text" in data: + if action == "run-task": + self.frames.put( + json.dumps(_event("task-started", task_id=task_id)), + ) + elif action == "continue-task" and "text" in data: self.frames.put(data["text"].encode("utf-8")) elif action == "finish-task": - self.frames.put(json.dumps(_event("task-finished"))) + self.frames.put( + json.dumps(_event("task-finished", task_id=task_id)), + ) def recv(self): if self.frames.empty(): @@ -490,6 +526,9 @@ def close(self): assert received.result(timeout=3) == [ b"first", b"second", - _event("task-finished"), + _event( + "task-finished", + task_id=ws.sent[0]["header"]["task_id"], + ), ] assert ws.close_count == 1 diff --git a/tests/v2/test_audio_ws_integration.py b/tests/v2/test_audio_ws_integration.py index d90468b..fcdf9fd 100644 --- a/tests/v2/test_audio_ws_integration.py +++ b/tests/v2/test_audio_ws_integration.py @@ -23,6 +23,7 @@ BadRequestError, RateLimitError, ) +from dashscope.v2.transport.ws import WsEventError TIMEOUT = 2 @@ -496,11 +497,16 @@ async def scenario(ws, _request): assert list(events) == [_task_event("task-finished", task_id)] +@pytest.mark.parametrize( + "model", + ["qwen-omni-turbo-realtime", "qwen3.8-omni-flash-realtime"], +) def test_omni_manual_commit_two_responses_and_session_finish( client, local_ws_server, + model, ): - model = "qwen-omni-turbo-realtime" + omni38 = model == "qwen3.8-omni-flash-realtime" session_id = "local-omni-session" audio_inputs = [b"\x00\xffround-one", b"\x80\x01round-two"] settings = { @@ -508,6 +514,15 @@ def test_omni_manual_commit_two_responses_and_session_finish( "input_audio_format": "pcm16", "turn_detection": None, } + if omni38: + settings.pop("input_audio_format") + settings["audio"] = { + "input": {"format": {"type": "pcm", "sample_rate": 16000}}, + "output": { + "format": {"type": "pcm", "sample_rate": 24000}, + "voice": "longanlingxin", + }, + } created = {"type": "session.created", "session": {"id": session_id}} updated = { "type": "session.updated", @@ -579,7 +594,10 @@ async def scenario(ws, request): "type": "input_audio_buffer.commit", } response = await receive_event(ws, "response.create") - assert response["response"]["modalities"] == ["text", "audio"] + if omni38: + assert response == {"type": "response.create"} + else: + assert response["response"]["modalities"] == ["text", "audio"] for event in rounds[index]: await ws.send_json(event) assert await receive_event(ws, "session.finish") == { @@ -587,7 +605,10 @@ async def scenario(ws, request): } assert response_read[-1].done() await _wait_for_return(end_returned) - await ws.send_json(finished) + if omni38: + await ws.close() + else: + await ws.send_json(finished) with local_ws_server(scenario) as server: with client.audio.realtime.connect( @@ -596,15 +617,27 @@ async def scenario(ws, request): timeout=TIMEOUT, ) as realtime: assert realtime.recv_event() == created - realtime.send_event( - {"type": "session.update", "session": settings}, - ) + if omni38: + realtime.update_session( + ["text", "audio"], + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + voice="longanlingxin", + enable_turn_detection=False, + ) + else: + realtime.send_event( + {"type": "session.update", "session": settings}, + ) events = realtime.events() assert next(events) == updated for index, audio in enumerate(audio_inputs): realtime.append_audio(audio) realtime.commit() - realtime.create_response(modalities=["text", "audio"]) + if omni38: + realtime.create_response() + else: + realtime.create_response(modalities=["text", "audio"]) received = [next(events) for _ in rounds[index]] assert received == rounds[index] assert base64.b64decode( @@ -614,8 +647,12 @@ async def scenario(ws, request): response_read[index].set_result(None) realtime.end_session() end_returned.set_result(None) - assert list(events) == [finished] - assert realtime.last_message == finished + if omni38: + assert list(events) == [] + assert realtime.last_message == rounds[-1][-1] + else: + assert list(events) == [finished] + assert realtime.last_message == finished def test_qwen_audio_manual_turns_recover_error_and_close( @@ -1233,3 +1270,309 @@ async def scenario(ws, request): ): list(realtime.events()) assert realtime.last_message["type"] == "response.done" + + +@pytest.mark.parametrize("task", ["asr", "tts"]) +@pytest.mark.parametrize( + "failure, expected_error", + [ + ("invalid-json", json.JSONDecodeError), + ("receive-timeout", websocket.WebSocketTimeoutException), + ("tcp-abort", APIConnectionError), + ("close-frame", APIConnectionError), + ], +) +def test_run_task_faults_after_partial_output( + client, + local_ws_server, + task, + failure, + expected_error, +): + model = ( + "qwen-audio-3.1-asr-flash-streaming" + if task == "asr" + else "qwen-audio-3.0-tts-flash" + ) + partial = {"output": {"sentence": {"text": "partial", "end_time": None}}} + + async def scenario(ws, request): + run = await _receive_run(ws, task=task, model=model) + task_id = run["header"]["task_id"] + await ws.send_json(_task_event("task-started", task_id)) + if task == "asr": + await _receive_binary(ws, b"audio") + else: + await _receive_action(ws, task_id, "continue-task", {"text": "hi"}) + await _receive_action(ws, task_id, "finish-task", {}) + if task == "asr": + await ws.send_json( + _task_event("result-generated", task_id, partial), + ) + else: + await ws.send_bytes(b"partial") + if failure == "invalid-json": + await ws.send_str("{broken") + elif failure == "tcp-abort": + request.transport.abort() + elif failure == "close-frame": + await ws.close(code=1000) + + connect = ( + client.audio.asr.recognize + if task == "asr" + else client.audio.tts.connect + ) + with local_ws_server(scenario) as server: + with connect(model=model, ws_url=server.url, timeout=0.5) as session: + if task == "asr": + session.send_audio_frame(b"audio") + else: + session.send_text("hi") + session.stop() + reader = session.events() + try: + frame = next(reader) + if task == "asr": + assert frame["payload"] == partial + else: + assert frame == b"partial" + with pytest.raises(expected_error): + next(reader) + finally: + reader.close() + + +@pytest.mark.parametrize( + "model", + [ + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime", + "qwen-audio-3.0-realtime-flash", + "qwen3.5-livetranslate-flash-realtime", + "qwen3.8-livetranslate-flash-realtime", + ], +) +@pytest.mark.parametrize( + "failure, expected_error", + [ + ("invalid-json", json.JSONDecodeError), + ("receive-timeout", websocket.WebSocketTimeoutException), + ("tcp-abort", APIConnectionError), + ], +) +def test_realtime_faults_during_active_response( + client, + local_ws_server, + model, + failure, + expected_error, +): + created = {"type": "response.created", "response": {"id": "r-active"}} + + async def scenario(ws, request): + await ws.send_json(created) + if failure == "invalid-json": + await ws.send_str("{broken") + elif failure == "tcp-abort": + request.transport.abort() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=0.5, + ) as session: + assert session.recv_event() == created + with pytest.raises(expected_error): + session.recv_event() + + +@pytest.mark.parametrize("task", ["asr", "tts"]) +@pytest.mark.parametrize("during_start", [False, True]) +def test_run_task_rejects_mismatched_response_id( + client, + local_ws_server, + task, + during_start, +): + model = "fun-asr-realtime" if task == "asr" else "cosyvoice-v1" + + async def scenario(ws, _request): + run = await _receive_run(ws, task=task, model=model) + task_id = run["header"]["task_id"] + await ws.send_json( + _task_event( + "task-started", + "another-task" if during_start else task_id, + ), + ) + if not during_start: + await _receive_action(ws, task_id, "finish-task", {}) + await ws.send_json(_task_event("task-finished", "another-task")) + + connect = ( + client.audio.asr.recognize + if task == "asr" + else client.audio.tts.connect + ) + with local_ws_server(scenario) as server: + with pytest.raises(WsEventError, match="different task"): + with connect(model=model, ws_url=server.url) as session: + assert not during_start + session.stop() + list(session.events()) + + +@pytest.mark.parametrize( + "failure", + [None, "asr-failed", "close-frame", "tcp-abort", "invalid-json"], +) +def test_shared_audio_tasks_route_output_and_isolate_failures( + client, + local_ws_server, + failure, +): + partial = {"output": {"sentence": {"text": "partial"}}} + + async def scenario(ws, request): + run_asr = await _receive_run(ws, task="asr", model="fun-asr-realtime") + asr_id = run_asr["header"]["task_id"] + await ws.send_json(_task_event("task-started", asr_id)) + run_tts = await _receive_run(ws, task="tts", model="cosyvoice-v1") + tts_id = run_tts["header"]["task_id"] + assert asr_id != tts_id + await ws.send_json(_task_event("task-started", tts_id)) + await _receive_binary(ws, b"input-audio") + await _receive_action(ws, tts_id, "continue-task", {"text": "hello"}) + await _receive_action(ws, asr_id, "finish-task", {}) + await _receive_action(ws, tts_id, "finish-task", {}) + await ws.send_bytes(b"output-audio") + await ws.send_json(_task_event("result-generated", asr_id, partial)) + if failure == "close-frame": + await ws.close(code=1000) + elif failure == "tcp-abort": + request.transport.abort() + elif failure == "invalid-json": + await ws.send_str("{broken") + else: + if failure == "asr-failed": + failed = _task_event("task-failed", asr_id) + failed["header"].update( + error_code="InvalidParameter", + error_message="bad audio", + ) + await ws.send_json(failed) + else: + await ws.send_json(_task_event("task-finished", asr_id)) + await ws.send_json(_task_event("task-finished", tts_id)) + + with local_ws_server(scenario) as server: + connection = client.ws_connection(url=server.url) + try: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connection=connection, + ) as asr, client.audio.tts.connect( + model="cosyvoice-v1", + connection=connection, + ) as tts: + asr.send_audio_frame(b"input-audio") + tts.send_text("hello") + asr.stop() + tts.stop() + asr_events = asr.events() + tts_audio = tts.audio_chunks() + try: + assert next(asr_events)["payload"] == partial + assert next(tts_audio) == b"output-audio" + if failure in {"close-frame", "tcp-abort", "invalid-json"}: + expected = ( + json.JSONDecodeError + if failure == "invalid-json" + else APIConnectionError + ) + for stream in (asr_events, tts_audio): + with pytest.raises(expected): + list(stream) + assert connection.closed + else: + if failure == "asr-failed": + with pytest.raises( + BadRequestError, + match="bad audio", + ): + list(asr_events) + else: + assert list(asr_events)[-1]["header"]["event"] == ( + "task-finished" + ) + assert list(tts_audio) == [] + assert not connection.closed + finally: + asr_events.close() + tts_audio.close() + finally: + connection.close() + connection._reader.join(timeout=TIMEOUT) + assert not connection._reader.is_alive() + + +@pytest.mark.parametrize( + "model", + [ + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime", + "qwen-audio-3.0-realtime-flash", + ], +) +@pytest.mark.parametrize("disconnect", ["close-frame", "tcp-abort"]) +def test_realtime_requested_response_cannot_end_before_created( + client, + local_ws_server, + model, + disconnect, +): + async def scenario(ws, request): + await _receive_realtime_event(ws, {"type": "response.create"}, set()) + if disconnect == "close-frame": + await ws.close(code=1000) + else: + request.transport.abort() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + ) as session: + session.send_event({"type": "response.create"}) + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + list(session.events()) + + +def test_omni38_finish_ack_does_not_complete_active_response( + client, + local_ws_server, +): + async def scenario(ws, request): + await ws.send_json( + {"type": "response.created", "response": {"id": "r1"}}, + ) + await _receive_realtime_event(ws, {"type": "session.finish"}, set()) + await ws.send_json({"type": "session.finished"}) + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ws_url=server.url, + ) as session: + assert session.recv_event()["type"] == "response.created" + session.end_session() + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + list(session.events()) diff --git a/tests/v2/test_chat.py b/tests/v2/test_chat.py index 949bd3b..f9ce1c9 100644 --- a/tests/v2/test_chat.py +++ b/tests/v2/test_chat.py @@ -44,6 +44,67 @@ def _make_async_client(handler, **kwargs): return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("status", [199, 200, 302, 307, 308]) +async def test_chat_preserves_non_error_http_statuses( + asynchronous, + streaming, + status, +): + body = { + "request_id": "body-id", + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": "unchanged"}}, + ], + }, + } + response = httpx.Response( + status, + content=_sse([body]) if streaming else json.dumps(body), + headers={ + "content-type": "text/event-stream" + if streaming + else "application/json", + "location": f"{BASE_URL}/redirect-target", + }, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + client = (_make_async_client if asynchronous else _make_client)(handler) + try: + result = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=streaming, + ) + if asynchronous: + result = await result + if streaming: + texts = ( + [text async for text in result.text_stream] + if asynchronous + else list(result.text_stream) + ) + assert texts == ["unchanged"] + else: + assert result.output_text == "unchanged" + assert not result.is_error + assert len(requests) == 1 + assert response.is_closed + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + def test_create_non_streaming(): captured = {} @@ -997,7 +1058,7 @@ def handler(request): parameters={"incremental_output": False}, ) # Raw iteration stays server-cumulative (1.x passthrough behavior). - # pylint: disable-next=not-an-iterable # stream=True overload returns ChatStream + # pylint: disable-next=not-an-iterable assert [c.delta.content for c in stream] == ["你", "你好。"] client2 = _make_client(handler) diff --git a/tests/v2/test_errors.py b/tests/v2/test_errors.py index 617abff..f22b3ca 100644 --- a/tests/v2/test_errors.py +++ b/tests/v2/test_errors.py @@ -13,9 +13,12 @@ from __future__ import annotations +import json + import httpx import pytest +from dashscope.v2 import AsyncClient, Client from dashscope.v2 import _error_registry as registry from dashscope.v2 import errors from dashscope.v2.errors import ( @@ -44,7 +47,7 @@ _STATUS_MAP, _WIRE_TO_PUBLIC, ) -from dashscope.v2.types.common import JobStatus, ResponseBase +from dashscope.v2.types.common import JobStatus, ResponseBase, response_body def _resp(status, body=None, headers=None): @@ -227,6 +230,45 @@ def test_response_base_subclasses_inherit_the_flag(): assert not JobStatus(status="SUCCEEDED").is_error +@pytest.mark.parametrize("model", [ResponseBase, JobStatus]) +@pytest.mark.parametrize("status", [None, 0, 199, 200, 302, 400, 503]) +def test_response_base_does_not_infer_http_failure_from_body_status( + model, + status, +): + assert not model(status_code=status).is_error + assert model(status_code=status, code="Throttling").is_error + assert model(status_code=status, is_error=True).is_error + + +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize( + "outer_id, header_id, expected_id", + [ + ("outer-id", "header-id", "outer-id"), + (None, "header-id", "header-id"), + (None, None, None), + ], +) +def test_shared_error_request_id_ignores_nested_output( + status, + outer_id, + header_id, + expected_id, +): + body = { + "code": "InvalidParameter", + "message": "rejected", + "request_id": outer_id, + "output": {"request_id": "inner-id"}, + } + headers = {"x-request-id": header_id} if header_id is not None else {} + with pytest.raises(BadRequestError) as caught: + check_response(_resp(status, body, headers)) + assert caught.value.request_id == expected_id + assert caught.value.body == body + + def test_non_json_body(): resp = httpx.Response( 502, @@ -306,6 +348,284 @@ def test_sse_body_without_decodable_frame_keeps_raw_text(): assert ei.value.message == "event:error\ndata:oops" +_SSE_ERROR_PAYLOAD = { + "code": "DataInspectionFailed", + "message": "blocked\n请稍后", + "request_id": "sse-body-id", + "output": {"request_id": "ignored-inner-id"}, +} + + +def _multiline_sse_error(newline="\n", terminated=False): + lines = [": keepalive", "event: error", "id: ignored-event-id"] + lines.extend( + f"data: {line}" + for line in json.dumps( + _SSE_ERROR_PAYLOAD, + indent=2, + ensure_ascii=False, + ).splitlines() + ) + return newline.join(lines) + (newline * 2 if terminated else "") + + +@pytest.mark.parametrize("newline", ["\n", "\r\n", "\r"]) +@pytest.mark.parametrize("terminated", [False, True]) +def test_sse_error_body_accumulates_event_data(newline, terminated): + text = _multiline_sse_error(newline, terminated) + if terminated: + text += 'data: {"code":"InvalidParameter","message":"later"}' + response = httpx.Response( + 400, + text=text, + headers={ + "content-type": "application/json", + "x-request-id": "header-id", + }, + ) + with pytest.raises(ContentFilterError) as caught: + check_response(response) + assert caught.value.body == _SSE_ERROR_PAYLOAD + assert caught.value.code == "DataInspectionFailed" + assert caught.value.message == _SSE_ERROR_PAYLOAD["message"] + assert caught.value.request_id == "sse-body-id" + assert caught.value.status_code == 400 + assert caught.value.headers["x-request-id"] == "header-id" + + +@pytest.mark.parametrize("separator", ["\n", "\n\n", "\r\n\r\n"]) +@pytest.mark.parametrize( + "prefix", + [ + "data: {}", + 'event: ping\ndata: {"type":"ping"}', + 'event: ping\ndata: {"message":"keepalive"}', + 'data: {"output":{"text":"ok"}}', + 'data: {"type":"message_start","message":{"id":"msg"}}', + "data: not-json", + "data: [DONE]", + ], +) +def test_sse_error_body_skips_non_error_frames(prefix, separator): + text = ( + prefix + + separator + + "event: error\ndata: " + + json.dumps( + _SSE_ERROR_PAYLOAD, + ) + ) + with pytest.raises(ContentFilterError) as caught: + check_response(httpx.Response(400, text=text)) + assert caught.value.body == _SSE_ERROR_PAYLOAD + assert caught.value.message == _SSE_ERROR_PAYLOAD["message"] + assert caught.value.request_id == "sse-body-id" + + +@pytest.mark.parametrize( + "payload, expected_code", + [ + ({"message": "rejected"}, None), + ({"error": {"message": "rejected", "code": "bad_key"}}, "bad_key"), + ( + { + "type": "error", + "error": {"message": "rejected", "type": "bad_key"}, + }, + "bad_key", + ), + ], +) +def test_sse_error_body_preserves_message_and_nested_envelopes( + payload, + expected_code, +): + text = "data: {}\n\nevent: error\n" + "\n".join( + f"data: {line}" for line in json.dumps(payload, indent=2).splitlines() + ) + with pytest.raises(AuthenticationError) as caught: + check_response(httpx.Response(401, text=text)) + assert caught.value.body == payload + assert caught.value.message == "rejected" + assert caught.value.code == expected_code + + +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize( + "text", + [ + "event: error\ndata: not-json\n\n", + 'event: error\ndata: {"code":\ndata: invalid}\n\n', + "data: {}\n\ndata: [DONE]\n\n", + 'event: ping\ndata: {"type":"ping"}\n\n', + 'data: {"output":{"text":"ok"}}', + 'data: {"message":{"id":"msg"},"error":{}}', + 'data: [\ndata: {"code":"InvalidParameter"}\ndata: ]\n\n', + "data:\n\ndata\n\n", + ], +) +def test_sse_body_without_business_error_keeps_raw_fallback(status, text): + response = httpx.Response(status, text=text) + error = errors.response_error(response) + if status == 200: + assert error is None + else: + assert type(error) is BadRequestError + assert error.body is None + assert error.code is None + assert error.message == text + assert error.status_code == 400 + + +def test_normal_json_error_body_is_not_reinterpreted_as_sse(): + payload = { + "code": "InvalidParameter", + "message": 'event: error\ndata: {"code":"DataInspectionFailed"}', + "request_id": "json-body-id", + } + with pytest.raises(BadRequestError) as caught: + check_response(_resp(400, payload)) + assert type(caught.value) is BadRequestError + assert caught.value.body == payload + assert caught.value.message == payload["message"] + assert caught.value.request_id == "json-body-id" + + +class _ErrorBodyStream(httpx.SyncByteStream, httpx.AsyncByteStream): + def __init__(self, text): + self.content = text.encode("utf-8") + self.closed = False + + def __iter__(self): + for offset in range(0, len(self.content), 7): + yield self.content[offset : offset + 7] + + async def __aiter__(self): + for chunk in self: + yield chunk + + def close(self): + self.closed = True + + async def aclose(self): + self.closed = True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize( + "text, payload", + [ + (_multiline_sse_error("\r\n"), _SSE_ERROR_PAYLOAD), + (_multiline_sse_error("\n", True), _SSE_ERROR_PAYLOAD), + ("data: {}\n\n" + _multiline_sse_error(), _SSE_ERROR_PAYLOAD), + ( + "data: {}\ndata: " + json.dumps(_SSE_ERROR_PAYLOAD), + _SSE_ERROR_PAYLOAD, + ), + ("event: error\ndata: broken-json", None), + ('data: {"type":"ping"}\n\n', None), + (json.dumps(_SSE_ERROR_PAYLOAD), _SSE_ERROR_PAYLOAD), + ], +) +async def test_public_http_error_propagates_and_closes( + monkeypatch, + asynchronous, + streaming, + text, + payload, +): + monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr( + "dashscope.v2._config._api_key_from_file", + lambda: None, + ) + wire = _ErrorBodyStream(text) + response = httpx.Response( + 400, + stream=wire, + headers={ + "content-type": "text/event-stream", + "x-request-id": "header-id", + }, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + base_url = "https://errors.test.local" + http_client = (httpx.AsyncClient if asynchronous else httpx.Client)( + transport=httpx.MockTransport(handler), + base_url=base_url, + ) + client = (AsyncClient if asynchronous else Client)( + api_key="sk-test-only", + base_url=base_url, + protocol="native", + max_retries=0, + http_client=http_client, + ) + expected_class = ContentFilterError if payload else BadRequestError + try: + assert not wire.closed and not response.is_closed + with pytest.raises(expected_class) as caught: + result = client.chat.completions.create( + model="test-model", + messages="hi", + stream=streaming, + ) + if asynchronous: + await result + error = caught.value + assert type(error) is expected_class + assert error.body == payload + assert error.status_code == 400 + assert error.code == (payload["code"] if payload else None) + assert error.message == (payload["message"] if payload else text) + assert error.request_id == ( + payload["request_id"] if payload else "header-id" + ) + assert error.headers["x-request-id"] == "header-id" + assert len(requests) == 1 + assert requests[0].url.host == "errors.test.local" + assert wire.closed and response.is_closed + finally: + if asynchronous: + await client.aclose() + await http_client.aclose() + else: + client.close() + http_client.close() + + +@pytest.mark.parametrize("raw", [None, [], "gateway failed", 0]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_strict_http_envelope_retains_non_object_failures(raw, raise_on_error): + response = _resp(503, raw, {"x-request-id": "header-id"}) + if raise_on_error: + with pytest.raises(ServiceUnavailableError) as caught: + response_body(response) + assert caught.value.body == raw + assert caught.value.request_id == "header-id" + else: + result = ResponseBase(**response_body(response, raise_on_error=False)) + assert result.is_error + assert result.status_code == 503 + assert result.code is None + assert result.message == response.text + assert result.request_id == "header-id" + + +@pytest.mark.parametrize("raw", [None, [], "not an envelope", 0]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_strict_http_envelope_rejects_non_object_success(raw, raise_on_error): + with pytest.raises(ValueError): + response_body(_resp(200, raw), raise_on_error=raise_on_error) + + def test_error_code_enum(): assert ErrorCode.INVALID_API_KEY.value == "InvalidApiKey" assert ErrorCode.DATA_INSPECTION_FAILED.value == "DataInspectionFailed" diff --git a/tests/v2/test_finetune_deployments.py b/tests/v2/test_finetune_deployments.py index ba9f871..afc3238 100644 --- a/tests/v2/test_finetune_deployments.py +++ b/tests/v2/test_finetune_deployments.py @@ -793,3 +793,283 @@ def handler(request): deployments = await client.deployments.list() assert deployments.is_error and deployments.code == "Throttling" await client.aclose() + + +_EVENT_CLIENT_CASES = [ + pytest.param( + resource, + is_async, + raise_on_error, + id=f"{resource}-{'async' if is_async else 'sync'}-raise-{raise_on_error}", + ) + for resource in ("fine_tunes", "deployments") + for is_async in (False, True) + for raise_on_error in (False, True) +] + + +class _EventStream(httpx.SyncByteStream, httpx.AsyncByteStream): + """Unbuffered mock shared by both APIs, with a read-ahead tripwire.""" + + def __init__(self, *parts, require_first_consumed=False, error=None): + self.parts = parts + self.require_first_consumed = require_first_consumed + self.first_consumed = False + self.error = error + self.reads = 0 + self.closed = False + + def __iter__(self): + for index, part in enumerate(self.parts): + if index and self.require_first_consumed: + assert ( + self.first_consumed + ), "read ahead before first data was consumed" + self.reads += 1 + yield part + if self.error is not None: + raise self.error + + async def __aiter__(self): + for part in self: + yield part + + def close(self): + self.closed = True + + async def aclose(self): + self.close() + + +def _event_client(wire, resource, is_async, raise_on_error, content_type): + response = httpx.Response( + 200, + stream=wire, + headers={"content-type": content_type, "x-request-id": "header-id"}, + ) + + def handler(request): + assert ( + request.url.path + == f"/api/v1/{resource.replace('_', '-')}/job-1/stream" + ) + return response + + factory = _make_async_client if is_async else _make_client + client = factory(handler, raise_on_error=raise_on_error, max_retries=0) + return client, response, getattr(client, resource).stream_events("job-1") + + +async def _close_event_client(client, is_async): + # The injected HTTP client is caller-owned, so close that pool explicitly. + if is_async: + await client.transport.http_client.aclose() + else: + client.transport.http_client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "content_type", + ["application/json", "application/problem+json"], +) +@pytest.mark.parametrize( + "body", + [ + b'{"code":"Throttling","message":"slow down"}', + b'{"output":{"status":"SUCCEEDED"}}', + b'{"output":', + ], +) +async def test_job_events_http200_json_remains_empty( + resource, + is_async, + raise_on_error, + content_type, + body, +): + wire = _EventStream(body) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + content_type, + ) + try: + events = ( + [event async for event in stream] if is_async else list(stream) + ) + assert events == [] + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +async def test_job_events_data_lines_are_immediate_and_code_is_not_error( + resource, + is_async, + raise_on_error, +): + first_body = {"code": "Throttling", "message": "ordinary event"} + wire = _EventStream( + ("data: " + json.dumps(first_body) + "\n").encode(), + b'data: {"status":"SUCCEEDED"}\n', + require_first_consumed=True, + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == first_body + assert not first.is_error + assert wire.reads == 1 and not wire.closed + wire.first_consumed = True + rest = [event async for event in stream] if is_async else list(stream) + assert len(rest) == 1 + assert rest[0].output == {"status": "SUCCEEDED"} + assert not rest[0].is_error + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "status, code, error_type", + [ + (None, "Throttling", RateLimitError), + (400, "InvalidParameter", BadRequestError), + (429, "Throttling", RateLimitError), + ], +) +async def test_job_events_legacy_error_mapping_and_nonraising_event( + resource, + is_async, + raise_on_error, + status, + code, + error_type, +): + body = { + "code": code, + "message": "rejected", + "output": {"request_id": "inner-id"}, + } + status_line = "" if status is None else f"status: {status}\n" + wire = _EventStream( + b'data: {"status":"RUNNING"}\n\n', + ( + "event:error\n" + + status_line + + "data: " + + json.dumps(body) + + "\n\n" + ).encode(), + b'data: {"status":"SHOULD_NOT_BE_READ"}\n\n', + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == {"status": "RUNNING"} and not first.is_error + if raise_on_error: + with pytest.raises(error_type, match="rejected") as caught: + if is_async: + await stream.__anext__() + else: + next(stream) + assert caught.value.status_code == (status or 200) + assert caught.value.code == code + assert caught.value.body == body + # Job events use only the top-level body request ID. + assert caught.value.request_id is None + assert caught.value.headers == {} + else: + rest = ( + [event async for event in stream] if is_async else list(stream) + ) + assert len(rest) == 1 and rest[0].is_error + assert rest[0].output == body + assert wire.reads == 2 + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "failure", + ["invalid-status", "read-error", "early-close"], +) +async def test_job_events_close_on_original_exceptions_and_early_exit( + resource, + is_async, + raise_on_error, + failure, +): + # Disabling error-event exceptions does not suppress parse or read failures. + wire = _EventStream( + b'data: {"status":"RUNNING"}\n', + b"status: invalid\ndata: value\n" + if failure == "invalid-status" + else b"", + error=httpx.ReadError("connection lost") + if failure == "read-error" + else None, + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == {"status": "RUNNING"} + assert not wire.closed + if failure == "early-close": + if is_async: + await stream.aclose() + else: + stream.close() + else: + error_type = ( + ValueError if failure == "invalid-status" else httpx.ReadError + ) + with pytest.raises(error_type): + if is_async: + await stream.__anext__() + else: + next(stream) + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) diff --git a/tests/v2/test_jobs.py b/tests/v2/test_jobs.py index 192abe4..bdf2c53 100644 --- a/tests/v2/test_jobs.py +++ b/tests/v2/test_jobs.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +from types import SimpleNamespace import httpx import pytest @@ -39,6 +40,27 @@ def _job_body(status="PENDING", task_id="task-1", **extra): return {"request_id": "req-1", "output": output} +@pytest.fixture +def wait_clock(): + now = [10.0] + sleeps = [] + + def sleep(seconds): + sleeps.append(seconds) + now[0] += seconds + + async def async_sleep(seconds): + sleep(seconds) + + # Replace only the resource's time binding, not asyncio's global clock. + return SimpleNamespace( + monotonic=lambda: now[0], + sleep=sleep, + async_sleep=async_sleep, + sleeps=sleeps, + ) + + def test_job_from_payload(): job = Job.from_payload( { @@ -93,6 +115,73 @@ def handler(request): assert job.finished is False +@pytest.mark.parametrize( + "parameter_kwargs, expected_fields", + [ + pytest.param({}, {}, id="omitted"), + pytest.param({"parameters": None}, {}, id="none"), + pytest.param({"parameters": {}}, {"parameters": {}}, id="empty"), + ], +) +def test_create_distinguishes_none_and_empty_parameters( + parameter_kwargs, + expected_fields, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_job_body()) + + with _make_client(handler) as client: + Jobs(client, "aigc").create( + model="wanx2.1-t2i", + input={"prompt": "a cat"}, + **parameter_kwargs, + ) + assert bodies == [ + { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat"}, + **expected_fields, + }, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "parameter_kwargs, expected_fields", + [ + pytest.param({}, {}, id="omitted"), + pytest.param({"parameters": None}, {}, id="none"), + pytest.param({"parameters": {}}, {"parameters": {}}, id="empty"), + ], +) +async def test_async_create_distinguishes_none_and_empty_parameters( + parameter_kwargs, + expected_fields, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_job_body()) + + async with _make_async_client(handler) as client: + await AsyncJobs(client, "aigc").create( + model="wanx2.1-t2i", + input={"prompt": "a cat"}, + **parameter_kwargs, + ) + assert bodies == [ + { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat"}, + **expected_fields, + }, + ] + + def test_wait_polls_with_exponential_backoff(): statuses = iter(["RUNNING", "RUNNING", "SUCCEEDED"]) calls = [] @@ -143,14 +232,15 @@ def handler(request): assert delays == [1.0, 2.0, 2.0] -def test_wait_times_out(): +def test_wait_times_out(monkeypatch, wait_clock): def handler(request): return httpx.Response(200, json=_job_body("RUNNING")) + monkeypatch.setattr("dashscope.v2.resources._jobs.time", wait_clock) client = _make_client(handler) jobs = Jobs(client, "aigc") with pytest.raises(DashScopeError, match="timed out"): - jobs.wait("task-1", timeout=0.001, _sleep=lambda seconds: None) + jobs.wait("task-1", timeout=0.5, _sleep=wait_clock.sleep) def test_fetch_failed_raises_mapped_error(): @@ -389,7 +479,7 @@ def handler(request): job = jobs.wait( "task-1", timeout=60.0, - _sleep=sleeps.append, + _sleep=lambda seconds: sleeps.append(seconds), ) # The error envelope has no task status; wait must return it # instead of polling until the timeout. @@ -503,3 +593,385 @@ def handler(request): assert job.succeeded is True assert len(calls) == 3 assert delays == [1.0, 2.0] + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "status", + ["SUCCEEDED", "FAILED", "CANCELED", "UNKNOWN"], +) +async def test_non_asr_wait_terminal_snapshot_contract( + modality, + asynchronous, + raise_on_error, + status, +): + def handler(request): + pytest.fail("terminal snapshots must not trigger another request") + + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + job = Job.from_payload( + _job_body( + status, + **( + {"code": "InvalidParameter", "message": "invalid input"} + if status == "FAILED" + else {} + ), + ), + ) + try: + jobs = getattr(client, modality).jobs + if raise_on_error and status == "CANCELED": + with pytest.raises(DashScopeError, match="canceled") as caught: + result = jobs.wait(job) + if asynchronous: + await result + assert type(caught.value) is DashScopeError + assert caught.value.request_id == "req-1" + else: + result = jobs.wait(job) + if asynchronous: + result = await result + assert result is job + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +async def test_non_asr_wait_allows_final_fetch_at_deadline( + monkeypatch, + wait_clock, + modality, + asynchronous, + raise_on_error, +): + requests = [] + + def handler(request): + requests.append(request) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/task-1" + assert wait_clock.monotonic() == 10.5 + return httpx.Response(200, json=_job_body("SUCCEEDED")) + + monkeypatch.setattr("dashscope.v2.resources._jobs.time", wait_clock) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + result = getattr(client, modality).jobs.wait( + Job(task_id="task-1", status="RUNNING"), + timeout=0.5, + poll_interval=1, + _sleep=wait_clock.async_sleep + if asynchronous + else wait_clock.sleep, + ) + if asynchronous: + result = await result + assert result.succeeded is True + assert len(requests) == 1 + assert wait_clock.sleeps == [0.5] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings", "asr"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize("status_code", [None, 0]) +async def test_public_wait_polls_running_until_succeeded( + monkeypatch, + wait_clock, + modality, + asynchronous, + raise_on_error, + status_code, +): + statuses = iter(["RUNNING", "RUNNING", "RUNNING", "SUCCEEDED"]) + requests = [] + output_extra = {} if status_code is None else {"status_code": status_code} + + def handler(request): + requests.append(request) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/task-1" + assert request.headers["Authorization"] == "Bearer sk-override" + assert request.headers["X-DashScope-WorkSpace"] == "ws-9" + return httpx.Response( + 200, + json=_job_body(next(statuses), **output_extra), + ) + + clock_module = "audio.asr" if modality == "asr" else "_jobs" + monkeypatch.setattr( + f"dashscope.v2.resources.{clock_module}.time", + wait_clock, + ) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + resource = ( + client.audio.asr + if modality == "asr" + else getattr(client, modality) + ) + result = resource.jobs.wait( + "task-1", + timeout=60, + poll_interval=1, + max_interval=2, + api_key="sk-override", + workspace="ws-9", + _sleep=wait_clock.async_sleep + if asynchronous + else wait_clock.sleep, + ) + if asynchronous: + result = await result + assert result.succeeded is True + assert result.is_error is False + assert result.request_id == "req-1" + if status_code is not None: + assert result.output["status_code"] == status_code + assert len(requests) == 4 + assert wait_clock.sleeps == [1, 2, 2] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +async def test_asr_wait_does_not_fetch_after_budget_expires( + monkeypatch, + wait_clock, + asynchronous, + raise_on_error, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json=_job_body("SUCCEEDED")) + + monkeypatch.setattr("dashscope.v2.resources.audio.asr.time", wait_clock) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + with pytest.raises(DashScopeError, match="timed out"): + result = client.audio.asr.jobs.wait( + Job(task_id="task-1", status="RUNNING"), + timeout=0.5, + poll_interval=1, + _sleep=( + wait_clock.async_sleep + if asynchronous + else wait_clock.sleep + ), + ) + if asynchronous: + await result + assert requests == [] + assert wait_clock.sleeps == [0.5] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "status", + ["SUCCEEDED", "FAILED", "CANCELED", "UNKNOWN"], +) +async def test_asr_wait_terminal_snapshot_contract( + asynchronous, + raise_on_error, + status, +): + def handler(request): + pytest.fail("terminal snapshots must not trigger another request") + + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + job = Job.from_payload( + _job_body( + status, + **( + {"code": "InvalidParameter", "message": "invalid audio"} + if status == "FAILED" + else {} + ), + ), + ) + job.headers = {"x-request-id": "header-id", "x-gateway": "test"} + try: + if raise_on_error and status in ("FAILED", "CANCELED"): + with pytest.raises(DashScopeError) as caught: + result = client.audio.asr.jobs.wait(job) + if asynchronous: + await result + assert caught.value.request_id == "req-1" + assert dict(caught.value.headers) == job.headers + if status == "FAILED": + assert type(caught.value).__name__ == "BadRequestError" + assert caught.value.code == "InvalidParameter" + assert caught.value.message == "invalid audio" + else: + result = client.audio.asr.jobs.wait(job) + if asynchronous: + result = await result + assert result is job + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +async def test_async_asr_wait_cancellation_stops_polling(): + import asyncio + + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json=_job_body("RUNNING")) + + async def cancel_sleep(seconds): + raise asyncio.CancelledError() + + async with _make_async_client(handler) as client: + with pytest.raises(asyncio.CancelledError): + await client.audio.asr.jobs.wait( + "task-1", + _sleep=cancel_sleep, + ) + assert len(requests) == 1 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("entry", ["transcribe", "fetch"]) +@pytest.mark.parametrize( + "status, code, expected_error", + [ + (400, "InvalidParameter", "BadRequestError"), + (401, "InvalidApiKey", "AuthenticationError"), + (403, "AccessDenied", "PermissionDeniedError"), + (404, "NotFound", "NotFoundError"), + (429, "Throttling", "RateLimitError"), + (500, "InternalError", "InternalServerError"), + (200, "InvalidParameter", "BadRequestError"), + ], +) +async def test_asr_http_errors_preserve_request_id( + asynchronous, + entry, + status, + code, + expected_error, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response( + status, + headers={"x-request-id": "header-request"}, + json={"code": code, "message": "audio request rejected"}, + ) + + client = ( + _make_async_client(handler, max_retries=0) + if asynchronous + else _make_client(handler, max_retries=0) + ) + try: + with pytest.raises(DashScopeError) as caught: + if entry == "transcribe": + result = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + ) + else: + result = client.audio.asr.jobs.fetch("task-1") + if asynchronous: + await result + assert type(caught.value).__name__ == expected_error + assert caught.value.code == code + assert caught.value.request_id == "header-request" + assert caught.value.status_code == status + assert len(requests) == 1 + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "transport_error, expected_error", + [ + (httpx.ReadTimeout, "APITimeoutError"), + (httpx.ConnectError, "APIConnectionError"), + ], +) +async def test_asr_transport_errors_are_not_suppressed( + asynchronous, + transport_error, + expected_error, +): + calls = [] + + def handler(request): + calls.append(request) + raise transport_error("network fault", request=request) + + client = ( + _make_async_client(handler, raise_on_error=False, max_retries=0) + if asynchronous + else _make_client(handler, raise_on_error=False, max_retries=0) + ) + try: + with pytest.raises(DashScopeError) as caught: + result = client.audio.asr.transcribe(["https://example.com/a.wav"]) + if asynchronous: + await result + assert type(caught.value).__name__ == expected_error + assert len(calls) == 1 + finally: + if asynchronous: + await client.aclose() + else: + client.close() diff --git a/tests/v2/test_sse.py b/tests/v2/test_sse.py index 377955f..b57fdc4 100644 --- a/tests/v2/test_sse.py +++ b/tests/v2/test_sse.py @@ -18,6 +18,11 @@ DashScopeError, RateLimitError, ) +from dashscope.v2.resources._sse import _aiter_sse, _iter_sse, _stream_error +from dashscope.v2.resources.audio.http_tts import ( + _audio_stream_error, + _iter_audio_sse, +) from dashscope.v2.transport.sse import ( parse_event_data, stream_error, @@ -397,3 +402,231 @@ def handler(request): pass assert exc_info.value.request_id == "req-async-1" await client.aclose() + + +_AUDIO_SSE_CASES = [ + pytest.param(": keepalive\n\nid: 1\n\n", [], id="no-data"), + pytest.param( + 'status: 201\r\ndata: {\r\ndata: "status": "RUNNING"\r\n' + 'data: }\r\n\r\ndata: {"status": "SUCCEEDED"}\r\n\r\n', + [ + (False, 201, '{\n"status": "RUNNING"\n}'), + (False, None, '{"status": "SUCCEEDED"}'), + ], + id="multiline-and-status-reset", + ), + pytest.param( + 'event: errorish\ndata: {"status": "RUNNING"} \n\n', + [(False, None, ' {"status": "RUNNING"} ')], + id="exact-event-and-preserved-data-spaces", + ), + pytest.param( + "data:\n\ndata: second\n\n", + [(False, None, ""), (False, None, "second")], + id="empty-data", + ), + pytest.param( + "event: error\nstatus: 400\n\ndata: ok\n\n", + [(False, None, "ok")], + id="empty-frame-resets-metadata", + ), + pytest.param( + "event: error\nstatus: 400\ndata: rejected\n\ndata: ignored\n\n", + [(True, 400, "rejected")], + id="standard-error-stops-stream", + ), + pytest.param( + 'data: {"code": "Throttling"}\n\ndata: ignored\n\n', + [(True, None, '{"code": "Throttling"}')], + id="data-only-business-error", + ), + pytest.param( + 'data: {\ndata: "code": "Throttling"\ndata: }\n\n', + [(True, None, '{\n"code": "Throttling"\n}')], + id="multiline-business-error", + ), + pytest.param( + 'event:error\nstatus: 429\ndata: {"code": "Throttling"}', + [(True, 429, '{"code": "Throttling"}')], + id="legacy-error-without-final-newline", + ), + pytest.param( + 'data: {"status": "SUCCEEDED"}\n', + [(False, None, '{"status": "SUCCEEDED"}')], + id="legacy-eof-without-blank-line", + ), +] + + +@pytest.mark.parametrize("wire, expected", _AUDIO_SSE_CASES) +def test_audio_sse_sync_framing(wire, expected): + response = httpx.Response(200, text=wire) + assert list(_iter_audio_sse(response)) == expected + + +_JOB_SSE_CASES = [ + pytest.param(": keepalive\n\nid: 1\n\n", [], id="no-data"), + pytest.param( + "status: 201\ndata: first\ndata: second\n\ndata: third", + [(False, 201, "first"), (False, 201, "second"), (False, 201, "third")], + id="per-line-with-sticky-status", + ), + pytest.param( + "event: error\ndata: payload \n\n", + [(False, None, "payload")], + id="legacy-event-spelling-and-stripped-data", + ), + pytest.param( + "event:errorish\nstatus: 400\n\ndata: rejected\ndata: ignored\n", + [(True, 400, "rejected")], + id="legacy-prefix-error-and-no-blank-reset", + ), + pytest.param( + 'data: {"code": "Throttling"}\ndata: next\n', + [(False, None, '{"code": "Throttling"}'), (False, None, "next")], + id="code-is-ordinary-data", + ), + pytest.param( + "event:error\ndata: rejected", + [(True, None, "rejected")], + id="error-at-eof", + ), + pytest.param( + "data:\n\ndata: next\n", + [(False, None, ""), (False, None, "next")], + id="empty-data", + ), +] + + +@pytest.mark.parametrize("wire, expected", _JOB_SSE_CASES) +def test_resource_sse_sync_legacy_framing(wire, expected): + assert list(_iter_sse(httpx.Response(200, text=wire))) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wire, expected", _JOB_SSE_CASES) +async def test_resource_sse_async_legacy_framing(wire, expected): + response = httpx.Response(200, text=wire) + assert [frame async for frame in _aiter_sse(response)] == expected + + +def test_resource_sse_invalid_status_still_raises(): + # Malformed status was always an exception, unlike malformed JSON data. + response = httpx.Response(200, text="status: invalid\ndata: value\n") + with pytest.raises(ValueError): + list(_iter_sse(response)) + + +@pytest.mark.asyncio +async def test_resource_sse_async_invalid_status_still_raises(): + response = httpx.Response(200, text="status: invalid\ndata: value\n") + with pytest.raises(ValueError): + async for _ in _aiter_sse(response): + pytest.fail("malformed status must raise before yielding data") + + +@pytest.mark.parametrize("status", [None, 429]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_audio_stream_error_keeps_headers_and_request_id( + status, + outer_id, + inner_id, + expected_id, +): + body = { + "code": "Throttling", + "message": "slow down", + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + headers = {"x-request-id": "header-id"} + error = _audio_stream_error(status, json.dumps(body), headers=headers) + assert isinstance(error, RateLimitError) + assert error.status_code == (status or 200) + assert error.request_id == expected_id + assert error.body == body + assert error.headers == headers + + +@pytest.mark.parametrize( + "content_type", + [ + "application/json", + "Application/JSON; charset=utf-8", + "application/problem+json", + ], +) +def test_audio_sse_sync_json_business_error(content_type): + response = httpx.Response( + 200, + json={"code": "Throttling", "message": "slow down"}, + headers={"content-type": content_type, "x-request-id": "json-id"}, + ) + with pytest.raises(RateLimitError, match="slow down") as caught: + list(_iter_audio_sse(response)) + assert caught.value.status_code == 200 + assert caught.value.request_id == "json-id" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content_type", + [ + "application/json", + "Application/JSON; charset=utf-8", + "application/problem+json", + ], +) +async def test_resource_sse_async_json_business_error_is_empty(content_type): + response = httpx.Response( + 200, + json={"code": "Throttling", "message": "slow down"}, + headers={"content-type": content_type, "x-request-id": "json-id"}, + ) + assert [frame async for frame in _aiter_sse(response)] == [] + + +def test_audio_sse_sync_malformed_json_response(): + response = httpx.Response( + 200, + content=b'{"output":', + headers={"content-type": "application/json"}, + ) + with pytest.raises(ValueError): + list(_iter_audio_sse(response)) + + +@pytest.mark.asyncio +async def test_resource_sse_async_malformed_json_response_is_empty(): + # JSON errors belong to audio's decoder; job streams never parse JSON MIME. + response = httpx.Response( + 200, + content=b'{"output":', + headers={"content-type": "application/json"}, + ) + assert [frame async for frame in _aiter_sse(response)] == [] + + +@pytest.mark.parametrize("status", [None, 429]) +@pytest.mark.parametrize("outer_id", [None, "outer-id"]) +def test_resource_stream_error_keeps_legacy_request_id(status, outer_id): + body = { + "code": "Throttling", + "message": "slow down", + "request_id": outer_id, + "output": {"request_id": "inner-id"}, + } + error = _stream_error(status, json.dumps(body)) + assert isinstance(error, RateLimitError) + assert error.status_code == (status or 200) + assert error.request_id == outer_id + assert error.body == body + assert error.headers == {} diff --git a/tests/v2/test_tingwu.py b/tests/v2/test_tingwu.py index d085312..8d6c2e1 100644 --- a/tests/v2/test_tingwu.py +++ b/tests/v2/test_tingwu.py @@ -57,7 +57,16 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True diff --git a/tests/v2/test_transport.py b/tests/v2/test_transport.py index 88eff8b..faf964f 100644 --- a/tests/v2/test_transport.py +++ b/tests/v2/test_transport.py @@ -12,9 +12,10 @@ from dashscope.v2.errors import ( APIConnectionError, + APIStatusError, APITimeoutError, BadRequestError, - RateLimitError, + response_error, ) from dashscope.v2.transport import AsyncTransport, SyncTransport from dashscope.v2.transport import http as transport_http @@ -159,38 +160,6 @@ def test_open_stream_transport_error_mapped(no_sleep): assert rec.calls == 1 -def test_open_stream_retryable_status_not_retried(no_sleep): - # Contract relied on by examples/04_real_world_scenarios/ - # production_ready_rag_harness.py: a retryable status on a streaming - # call raises after a single attempt -- the application, not the - # SDK, owns stream backoff and failover. - rec = _Recorder() - responses = [(429, {"retry-after": "3"}), (200, {})] - t = _make_sync(rec, responses, max_retries=3) - with pytest.raises(RateLimitError): - t.open_stream("POST", "/x", json={"a": 1}) - assert rec.calls == 1 - assert no_sleep == [] - - -@pytest.mark.asyncio -async def test_open_stream_retryable_status_not_retried_async(no_sleep): - rec = _Recorder() - responses = [(429, {"retry-after": "3"}), (200, {})] - mock = httpx.MockTransport(rec.handler(responses)) - http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) - t = AsyncTransport( - base_url=BASE_URL, - http_client=http_client, - max_retries=3, - ) - with pytest.raises(RateLimitError): - await t.open_stream("POST", "/x", json={"a": 1}) - assert rec.calls == 1 - assert no_sleep == [] - await t.aclose() - - _SSE_ERROR_BODY = ( "event:error\n" 'data:{"code":"InvalidParameter","message":"bad combo",' @@ -583,3 +552,216 @@ def test_backoff_delay_bounds(): assert d10 <= 8.0 * 1.25 assert backoff_delay(0, {"retry-after": "2"}) == 2.0 assert backoff_delay(0, {"retry-after": "999"}) == 8.0 + + +@pytest.mark.parametrize("method", ["request", "open_stream"]) +@pytest.mark.parametrize("lowercase", [False, True]) +def test_sync_call_auth_wins_case_insensitively(method, lowercase): + rec = _Recorder() + transport = _make_sync(rec, []) + with transport.http_client as http_client: + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + headers = { + "Authorization": "Bearer header-key", + "X-DashScope-WorkSpace": "header-ws", + } + if lowercase: + headers = {key.lower(): value for key, value in headers.items()} + original = dict(headers) + call = getattr(transport, method) + call( + "GET", + "/x", + headers=headers, + api_key="call-key", + workspace="call-ws", + ).close() + call("GET", "/x").close() + assert headers == original + first, second = rec.requests + assert first.headers.get_list("authorization") == ["Bearer call-key"] + assert first.headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert second.headers.get_list("authorization") == ["Bearer pool-key"] + assert second.headers.get_list("x-dashscope-workspace") == ["pool-ws"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["request", "open_stream"]) +@pytest.mark.parametrize("lowercase", [False, True]) +async def test_async_call_auth_wins_case_insensitively(method, lowercase): + rec = _Recorder() + async with httpx.AsyncClient( + transport=httpx.MockTransport(rec.handler([])), + base_url=BASE_URL, + headers={ + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) as http_client: + transport = AsyncTransport(base_url=BASE_URL, http_client=http_client) + headers = { + "Authorization": "Bearer header-key", + "X-DashScope-WorkSpace": "header-ws", + } + if lowercase: + headers = {key.lower(): value for key, value in headers.items()} + original = dict(headers) + call = getattr(transport, method) + response = await call( + "GET", + "/x", + headers=headers, + api_key="call-key", + workspace="call-ws", + ) + await response.aclose() + response = await call("GET", "/x") + await response.aclose() + assert headers == original + first, second = rec.requests + assert first.headers.get_list("authorization") == ["Bearer call-key"] + assert first.headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert second.headers.get_list("authorization") == ["Bearer pool-key"] + assert second.headers.get_list("x-dashscope-workspace") == ["pool-ws"] + + +class _TrackedSyncStream(httpx.SyncByteStream): + def __init__(self, read_error): + self.read_error = read_error + self.closed = False + + def __iter__(self): + yield b"gateway " + if self.read_error is not None: + raise self.read_error("connection lost") + yield b"rejected" + + def close(self): + self.closed = True + + +class _TrackedAsyncStream(httpx.AsyncByteStream): + def __init__(self, read_error): + self.read_error = read_error + self.closed = False + + async def __aiter__(self): + yield b"gateway " + if self.read_error is not None: + raise self.read_error("connection lost") + yield b"rejected" + + async def aclose(self): + self.closed = True + + +@pytest.mark.parametrize("status", [199, 302, 400, 500]) +@pytest.mark.parametrize( + "read_error", + [None, httpx.ReadError, httpx.ReadTimeout], +) +def test_sync_open_stream_status_and_response_ownership(status, read_error): + wire = _TrackedSyncStream(read_error) + response = httpx.Response( + status, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + with httpx.Client( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ) as http_client: + transport = SyncTransport(base_url=BASE_URL, http_client=http_client) + if status >= 400: + with pytest.raises(read_error or APIStatusError) as caught: + transport.open_stream("GET", "/x") + if read_error is None: + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.message == "gateway rejected" + else: + opened = transport.open_stream("GET", "/x") + assert opened is response + assert not wire.closed and not response.is_stream_consumed + try: + if read_error is None: + assert opened.read() == b"gateway rejected" + else: + with pytest.raises(read_error): + opened.read() + finally: + opened.close() + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [199, 302, 400, 500]) +@pytest.mark.parametrize( + "read_error", + [None, httpx.ReadError, httpx.ReadTimeout], +) +async def test_async_open_stream_status_and_response_ownership( + status, + read_error, +): + wire = _TrackedAsyncStream(read_error) + response = httpx.Response( + status, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ) as http_client: + transport = AsyncTransport(base_url=BASE_URL, http_client=http_client) + if status >= 400: + with pytest.raises(read_error or APIStatusError) as caught: + await transport.open_stream("GET", "/x") + if read_error is None: + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.message == "gateway rejected" + else: + opened = await transport.open_stream("GET", "/x") + assert opened is response + assert not wire.closed and not response.is_stream_consumed + try: + if read_error is None: + assert await opened.aread() == b"gateway rejected" + else: + with pytest.raises(read_error): + await opened.aread() + finally: + await opened.aclose() + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.parametrize("status", [199, 200, 204, 299, 302, 400, 500]) +def test_response_error_preserves_shared_http_status_contract(status): + response = httpx.Response(status, json={}) + error = response_error(response) + if status < 400: + assert error is None + else: + assert isinstance(error, APIStatusError) + assert error.status_code == status diff --git a/tests/v2/test_ws_connection.py b/tests/v2/test_ws_connection.py index 9a6c3b6..5716cb4 100644 --- a/tests/v2/test_ws_connection.py +++ b/tests/v2/test_ws_connection.py @@ -6,11 +6,12 @@ import json import queue +import threading import pytest from dashscope.v2.errors import APIConnectionError, DashScopeError -from dashscope.v2.transport.ws import WsEventError +from dashscope.v2.transport.ws import WsEventError, WsSession from dashscope.v2.transport.ws_connection import WsConnection URL = "wss://ws.test.local/api-ws/v1/inference" @@ -316,3 +317,601 @@ def test_close_broadcasts_and_closes_socket(): with pytest.raises(APIConnectionError, match="connection closed"): task.recv() conn.close() # idempotent + + +@pytest.fixture +def bounded_connection(): + """Always stop and join readers, including when a contract assertion fails.""" + connections = [] + + def make(ws=None, timeout=0.5): + ws = QueueWebSocket() if ws is None else ws + conn = WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=timeout, + ) + connections.append(conn) + return conn + + yield make + for conn in connections: + conn.close() + conn._reader.join(timeout=1) + assert not conn._reader.is_alive(), "websocket reader leaked" + + +@pytest.mark.parametrize("transport", [WsSession, WsConnection]) +def test_initial_timeout_setup_failure_closes_socket(transport): + class BrokenTimeoutSocket(QueueWebSocket): + def settimeout(self, seconds): + raise OSError("timeout setup failed") + + ws = BrokenTimeoutSocket() + try: + with pytest.raises(OSError, match="timeout setup failed"): + transport(url=URL, connector=lambda url, headers: ws, timeout=0.5) + assert ws.closed + finally: + ws.close() + + +@pytest.mark.parametrize( + "error_type", + [OSError, KeyboardInterrupt, SystemExit], +) +def test_shared_blocking_mode_setup_failure_closes_socket(error_type): + error = error_type("blocking mode failed") + + class BrokenBlockingSocket(QueueWebSocket): + def settimeout(self, seconds): + super().settimeout(seconds) + if seconds is None: + raise error + + ws = BrokenBlockingSocket() + try: + with pytest.raises(error_type) as caught: + WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + assert caught.value is error + assert ( + ws.closed + ), "post-handshake initialization must release the socket" + finally: + ws.close() + + +@pytest.mark.parametrize("transport", [WsSession, WsConnection]) +def test_connector_failure_creates_no_reader(transport): + before = set(threading.enumerate()) + + def connector(url, headers): + raise OSError("handshake failed") + + with pytest.raises(OSError, match="handshake failed"): + transport(url=URL, connector=connector, timeout=0.5) + assert not [ + thread + for thread in threading.enumerate() + if thread not in before and thread.name == "dashscope-ws-reader" + ] + + +@pytest.mark.parametrize("action", ["run", "binary", "continue", "finish"]) +def test_shared_send_failure_closes_and_wakes_other_tasks( + bounded_connection, + action, +): + class BrokenSendSocket(QueueWebSocket): + fail_send = False + + def send(self, payload, opcode=1): + if self.fail_send: + raise OSError("send failed") + super().send(payload, opcode=opcode) + + ws = BrokenSendSocket() + conn = bounded_connection(ws) + task, other = conn.open_task(), conn.open_task() + _started(conn, other) + if action != "run": + _started(conn, task) + ws.fail_send = True + with pytest.raises(OSError, match="send failed"): + if action == "run": + _started(conn, task) + elif action == "binary": + task.send_binary(b"audio") + elif action == "continue": + task.continue_task({"context": "next"}) + else: + task.finish_task() + assert conn.closed and ws.closed + with pytest.raises(APIConnectionError): + other.recv() + with pytest.raises(RuntimeError, match="closed"): + conn.open_task() + task.close() + other.close() + + +@pytest.mark.parametrize("raw", [None, "", OSError("receive failed")]) +def test_shared_premature_eof_and_recv_failure_stop_reader( + bounded_connection, + raw, +): + conn = bounded_connection() + tasks = [conn.open_task(), conn.open_task()] + for task in tasks: + _started(conn, task) + conn._ws.inbox.put(raw) + for task in tasks: + with pytest.raises( + OSError if isinstance(raw, OSError) else APIConnectionError, + ): + task.recv() + assert conn.closed and conn._ws.closed + + +@pytest.mark.parametrize( + "raw, error", + [ + pytest.param("{", json.JSONDecodeError, id="invalid-json"), + pytest.param("[]", WsEventError, id="non-object"), + pytest.param("null", WsEventError, id="null-event"), + pytest.param("{}", WsEventError, id="missing-header"), + pytest.param('{"header": []}', WsEventError, id="invalid-header"), + pytest.param('{"header": null}', WsEventError, id="null-header"), + pytest.param( + '{"header": {"event": "result-generated"}}', + WsEventError, + id="missing-task-id", + ), + *[ + pytest.param( + _event("result-generated", task_id), + WsEventError, + id=name, + ) + for task_id, name in [ + ([], "list-task-id"), + ({}, "object-task-id"), + (None, "null-task-id"), + (1, "numeric-task-id"), + (True, "boolean-task-id"), + ("", "empty-task-id"), + ] + ], + ], +) +def test_shared_invalid_control_frame_fails_all_tasks( + bounded_connection, + raw, + error, +): + conn = bounded_connection() + tasks = [conn.open_task(), conn.open_task()] + for task in tasks: + _started(conn, task) + conn._ws.inbox.put(raw) + for task in tasks: + for _ in range(2): + with pytest.raises(error): + task.recv() + assert conn.closed and conn._ws.closed + + +@pytest.mark.parametrize("terminal", ["task-finished", "task-failed", "close"]) +def test_both_binary_owners_release_for_next_task( + bounded_connection, + terminal, +): + conn = bounded_connection() + first, second = conn.open_task(), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first") + first.claim_binary_downlink() + if terminal == "close": + first.close() + else: + conn._ws.inbox.put(_event(terminal, first.task_id)) + if terminal == "task-failed": + with pytest.raises(DashScopeError): + first.recv() + else: + first.recv_event() + second.send_binary(b"second") + second.claim_binary_downlink() + conn._ws.inbox.put(b"second-output") + assert second.recv() == b"second-output" + assert not conn.closed + + +@pytest.mark.parametrize("terminal", ["task-finished", "close"]) +def test_inactive_task_cannot_reclaim_binary_downlink( + bounded_connection, + terminal, +): + conn = bounded_connection() + task, next_task = conn.open_task(), conn.open_task() + _started(conn, task) + _started(conn, next_task) + task.claim_binary_downlink() + if terminal == "close": + task.close() + else: + conn._ws.inbox.put(_event(terminal, task.task_id)) + task.recv_event() + with pytest.raises(RuntimeError): + task.claim_binary_downlink() + next_task.claim_binary_downlink() + conn._ws.inbox.put(b"next-output") + assert next_task.recv() == b"next-output" + + +@pytest.mark.parametrize("failure", ["close", "recv"]) +def test_connection_shutdown_releases_binary_owners( + bounded_connection, + failure, +): + conn = bounded_connection() + task = conn.open_task() + _started(conn, task) + task.send_binary(b"audio") + task.claim_binary_downlink() + if failure == "close": + conn.close() + else: + conn._ws.inbox.put(OSError("receive failed")) + with pytest.raises(APIConnectionError if failure == "close" else OSError): + task.recv() + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + with pytest.raises(RuntimeError, match="closed"): + task.claim_binary_downlink() + assert conn._binary_downlink_owner is None + + +@pytest.mark.parametrize("terminal", ["task-finished", "task-failed"]) +@pytest.mark.parametrize("claim", [False, True]) +def test_reader_releases_terminal_owners_before_consumer_finishes( + bounded_connection, + terminal, + claim, +): + conn = bounded_connection() + first, second = conn.open_task(), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first-input") + first.claim_binary_downlink() + conn._ws.inbox.put(_event("result-generated", first.task_id, {"n": 1})) + conn._ws.inbox.put(b"first-output") + conn._ws.inbox.put(_event(terminal, first.task_id)) + conn._ws.inbox.put( + _event("task-started", first.task_id), + ) # Late duplicate. + conn._ws.inbox.put(_event("result-generated", second.task_id)) + second.recv_event() # The reader has handled first's terminal event. + assert not first.finished # The consumer must still drain its frames. + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + with pytest.raises(RuntimeError, match="not active"): + first.claim_binary_downlink() + with pytest.raises(RuntimeError, match="not active"): + first.send_binary(b"too late") + second.send_binary(b"second-input") + if claim: + second.claim_binary_downlink() + conn._ws.inbox.put(b"second-output") + assert second.recv() == b"second-output" + assert first.recv_event()["payload"] == {"n": 1} + assert first.recv() == b"first-output" + if terminal == "task-failed": + with pytest.raises(DashScopeError): + first.recv() + else: + assert first.recv_event()["header"]["event"] == terminal + assert first.finished and first.recv() is None + # Consuming the old terminal event must not affect the new owners. + assert conn._binary_uplink_owner is second + assert conn._binary_downlink_owner is second + assert not conn.closed + + +@pytest.mark.parametrize("direction", ["uplink", "downlink"]) +def test_binary_owner_must_be_attached_to_this_connection( + bounded_connection, + direction, +): + conn, other = bounded_connection(), bounded_connection() + task, foreign = conn.open_task(), other.open_task() + foreign._task_id = ( + task.task_id + ) # Matching ids alone do not imply attachment. + with pytest.raises(RuntimeError, match="not active"): + getattr(conn, "_claim_binary_" + direction)(foreign) + assert getattr(conn, "_binary_" + direction + "_owner") is None + task.claim_binary_downlink() + + +@pytest.mark.parametrize("close_target", ["task", "connection"]) +def test_close_wakes_already_blocked_receiver( + bounded_connection, + close_target, +): + entered = threading.Event() + + class ObservedQueue(queue.Queue): + def get(self, *args, **kwargs): + entered.set() + return super().get(*args, **kwargs) + + conn = bounded_connection(timeout=None) + task = conn.open_task() + _started(conn, task) + task._queue = ObservedQueue() + errors = [] + + def receive(): + try: + task.recv() + except BaseException as exc: + errors.append(exc) + + receiver = threading.Thread( + target=receive, + name="test-ws-receiver", + daemon=True, + ) + receiver.start() + try: + assert entered.wait(timeout=1) + (task if close_target == "task" else conn).close() + receiver.join(timeout=1) + assert not receiver.is_alive(), "close did not wake the receiver" + expected = ( + RuntimeError if close_target == "task" else APIConnectionError + ) + assert len(errors) == 1 and isinstance(errors[0], expected), errors + finally: + # Test-only fallback also bounds cleanup if production close is broken. + task._queue.put(("error", RuntimeError("test cleanup"))) + receiver.join(timeout=1) + task.close() + assert not receiver.is_alive() + + +def test_close_uses_bounded_join_if_socket_does_not_wake_reader( + bounded_connection, + monkeypatch, +): + entered, release = threading.Event(), threading.Event() + + class SlowClosingSocket(QueueWebSocket): + def recv(self): + entered.set() + assert release.wait(timeout=5) + return None + + conn = bounded_connection(SlowClosingSocket()) + task = conn.open_task() + original_join = conn._reader.join + timeouts = [] + + def observe_join(timeout=None): + timeouts.append(timeout) + assert timeout is not None and 0 < timeout <= 2 + original_join(timeout=timeout) + + monkeypatch.setattr(conn._reader, "join", observe_join) + try: + assert entered.wait(timeout=1) + conn.close() + assert timeouts == [2] + assert ( + conn._reader.is_alive() + ) # close() returned without an unbounded wait. + with pytest.raises(APIConnectionError): + task.recv() + finally: + release.set() + original_join(timeout=1) + assert not conn._reader.is_alive() + + +@pytest.mark.parametrize( + "shutdown", + ["task", "connection", "recv", "invalid-json"], +) +def test_error_is_persistent_but_preserves_earlier_frames( + bounded_connection, + shutdown, +): + conn = bounded_connection() + task, barrier = conn.open_task(), conn.open_task() + _started(conn, task) + conn._ws.inbox.put(_event("result-generated", task.task_id, {"n": 1})) + conn._ws.inbox.put(b"queued-audio") + conn._ws.inbox.put(_event("result-generated", barrier.task_id)) + barrier.recv_event() # Earlier frames have reached the task's queue. + if shutdown in ("task", "connection"): + (task if shutdown == "task" else conn).close() + expected = RuntimeError if shutdown == "task" else APIConnectionError + else: + conn._ws.inbox.put( + OSError("receive failed") if shutdown == "recv" else "{", + ) + conn._reader.join(timeout=1) + assert not conn._reader.is_alive() + expected = OSError if shutdown == "recv" else json.JSONDecodeError + assert task.recv_event()["payload"] == {"n": 1} + assert task.recv() == b"queued-audio" + with pytest.raises(expected) as first: + task.recv() + task.close() # Cleanup must not overwrite an earlier connection error. + with pytest.raises(expected) as again: + task.recv() + assert again.value is first.value + + +def test_task_handshake_timeout_detaches_without_killing_connection( + bounded_connection, +): + conn = bounded_connection() + task = conn.open_task(timeout=0.02) + with pytest.raises(TimeoutError): + task.run_task( + task_group="audio", + task="asr", + function="recognition", + model="test", + ) + assert task.task_id not in conn._tasks + assert not conn.closed + next_task = conn.open_task() + _started(conn, next_task) + next_task.send_binary(b"still usable") + + +def test_task_timeout_is_isolated_and_explicit_close_releases_owners( + bounded_connection, +): + conn = bounded_connection() + first, second = conn.open_task(timeout=0.02), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first") + first.claim_binary_downlink() + with pytest.raises(TimeoutError): + first.recv() + first.close() + second.send_binary(b"second") + second.claim_binary_downlink() + conn._ws.inbox.put(_event("task-finished", second.task_id)) + assert second.recv_event()["header"]["event"] == "task-finished" + assert not conn.closed + + +@pytest.mark.parametrize("claim", [False, True]) +def test_binary_immediately_after_started_is_not_dropped( + bounded_connection, + claim, +): + """The reader may read audio before run_task consumes task-started.""" + routed = threading.Event() + + class FastReplySocket(QueueWebSocket): + def send(self, payload, opcode=1): + super().send(payload, opcode=opcode) + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.put(_event("task-started", task_id)) + self.inbox.put(b"first-output") + self.inbox.put(_event("result-generated", "reader-barrier")) + assert routed.wait(timeout=1) + + conn = bounded_connection(FastReplySocket()) + task = conn.open_task() + if claim: + task.claim_binary_downlink() # TTS claims before run_task(). + original_route = conn._route_event + + def observe_route(event): + original_route(event) + if event["header"].get("task_id") == "reader-barrier": + routed.set() + + conn._route_event = observe_route + task.run_task( + task_group="audio", + task="tts", + function="SpeechSynthesizer", + model="test", + ) + assert task.recv() == b"first-output" + + +@pytest.mark.parametrize( + "error_type", + [RuntimeError, KeyboardInterrupt, SystemExit], +) +@pytest.mark.parametrize("after_start", [False, True]) +def test_reader_start_failure_closes_connected_socket( + monkeypatch, + error_type, + after_start, +): + ws = QueueWebSocket() + error = error_type("cannot start reader") + original_start = threading.Thread.start + readers = [] + + def fail_start(self): + readers.append(self) + if after_start: + original_start(self) + raise error + + monkeypatch.setattr(threading.Thread, "start", fail_start) + try: + with pytest.raises(error_type) as caught: + WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + assert caught.value is error + assert ws.closed + assert all(not reader.is_alive() for reader in readers) + finally: + ws.close() + for reader in readers: + if reader.is_alive(): + reader.join(timeout=1) + + +def test_independent_session_close_wakes_blocked_receiver(): + entered = threading.Event() + + class ObservedSocket(QueueWebSocket): + def recv(self): + entered.set() + return super().recv() + + ws = ObservedSocket() + session = WsSession( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + errors = [] + + def receive(): + try: + session.recv() + except BaseException as exc: + errors.append(exc) + + receiver = threading.Thread( + target=receive, + name="test-session-receiver", + daemon=True, + ) + receiver.start() + try: + assert entered.wait(timeout=1) + session.close() + receiver.join(timeout=1) + assert not receiver.is_alive() + assert len(errors) == 1 and isinstance(errors[0], APIConnectionError) + assert ws.closed + finally: + session.close() + ws.close() + receiver.join(timeout=1) + assert not receiver.is_alive()