jambonz-python-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. jambonz_python_sdk-0.2.0.dist-info/METADATA +179 -0
  2. jambonz_python_sdk-0.2.0.dist-info/RECORD +119 -0
  3. jambonz_python_sdk-0.2.0.dist-info/WHEEL +4 -0
  4. jambonz_sdk/__init__.py +52 -0
  5. jambonz_sdk/_signature.py +73 -0
  6. jambonz_sdk/client/__init__.py +15 -0
  7. jambonz_sdk/client/api.py +241 -0
  8. jambonz_sdk/schema/callbacks/amd.schema.json +50 -0
  9. jambonz_sdk/schema/callbacks/base.schema.json +29 -0
  10. jambonz_sdk/schema/callbacks/call-status.schema.json +22 -0
  11. jambonz_sdk/schema/callbacks/conference-status.schema.json +24 -0
  12. jambonz_sdk/schema/callbacks/conference-wait.schema.json +11 -0
  13. jambonz_sdk/schema/callbacks/conference.schema.json +11 -0
  14. jambonz_sdk/schema/callbacks/dequeue.schema.json +19 -0
  15. jambonz_sdk/schema/callbacks/dial-dtmf.schema.json +18 -0
  16. jambonz_sdk/schema/callbacks/dial-hold.schema.json +22 -0
  17. jambonz_sdk/schema/callbacks/dial-refer.schema.json +28 -0
  18. jambonz_sdk/schema/callbacks/dial.schema.json +31 -0
  19. jambonz_sdk/schema/callbacks/enqueue-wait.schema.json +17 -0
  20. jambonz_sdk/schema/callbacks/enqueue.schema.json +27 -0
  21. jambonz_sdk/schema/callbacks/gather-partial.schema.json +54 -0
  22. jambonz_sdk/schema/callbacks/gather.schema.json +60 -0
  23. jambonz_sdk/schema/callbacks/listen.schema.json +21 -0
  24. jambonz_sdk/schema/callbacks/llm.schema.json +30 -0
  25. jambonz_sdk/schema/callbacks/message.schema.json +35 -0
  26. jambonz_sdk/schema/callbacks/pipeline-turn.schema.json +109 -0
  27. jambonz_sdk/schema/callbacks/play.schema.json +36 -0
  28. jambonz_sdk/schema/callbacks/session-new.schema.json +143 -0
  29. jambonz_sdk/schema/callbacks/session-reconnect.schema.json +9 -0
  30. jambonz_sdk/schema/callbacks/session-redirect.schema.json +38 -0
  31. jambonz_sdk/schema/callbacks/sip-refer-event.schema.json +20 -0
  32. jambonz_sdk/schema/callbacks/sip-refer.schema.json +22 -0
  33. jambonz_sdk/schema/callbacks/sip-request.schema.json +27 -0
  34. jambonz_sdk/schema/callbacks/transcribe-translation.schema.json +24 -0
  35. jambonz_sdk/schema/callbacks/transcribe.schema.json +46 -0
  36. jambonz_sdk/schema/callbacks/tts-streaming-event.schema.json +77 -0
  37. jambonz_sdk/schema/callbacks/verb-status.schema.json +57 -0
  38. jambonz_sdk/schema/components/actionHook.schema.json +36 -0
  39. jambonz_sdk/schema/components/actionHookDelayAction.schema.json +37 -0
  40. jambonz_sdk/schema/components/amd.schema.json +68 -0
  41. jambonz_sdk/schema/components/auth.schema.json +18 -0
  42. jambonz_sdk/schema/components/bidirectionalAudio.schema.json +22 -0
  43. jambonz_sdk/schema/components/fillerNoise.schema.json +25 -0
  44. jambonz_sdk/schema/components/llm-base.schema.json +94 -0
  45. jambonz_sdk/schema/components/recognizer-assemblyAiOptions.schema.json +66 -0
  46. jambonz_sdk/schema/components/recognizer-awsOptions.schema.json +52 -0
  47. jambonz_sdk/schema/components/recognizer-azureOptions.schema.json +32 -0
  48. jambonz_sdk/schema/components/recognizer-cobaltOptions.schema.json +34 -0
  49. jambonz_sdk/schema/components/recognizer-customOptions.schema.json +27 -0
  50. jambonz_sdk/schema/components/recognizer-deepgramOptions.schema.json +147 -0
  51. jambonz_sdk/schema/components/recognizer-elevenlabsOptions.schema.json +39 -0
  52. jambonz_sdk/schema/components/recognizer-gladiaOptions.schema.json +8 -0
  53. jambonz_sdk/schema/components/recognizer-googleOptions.schema.json +35 -0
  54. jambonz_sdk/schema/components/recognizer-houndifyOptions.schema.json +53 -0
  55. jambonz_sdk/schema/components/recognizer-ibmOptions.schema.json +54 -0
  56. jambonz_sdk/schema/components/recognizer-nuanceOptions.schema.json +150 -0
  57. jambonz_sdk/schema/components/recognizer-nvidiaOptions.schema.json +39 -0
  58. jambonz_sdk/schema/components/recognizer-openaiOptions.schema.json +59 -0
  59. jambonz_sdk/schema/components/recognizer-sonioxOptions.schema.json +46 -0
  60. jambonz_sdk/schema/components/recognizer-speechmaticsOptions.schema.json +100 -0
  61. jambonz_sdk/schema/components/recognizer-verbioOptions.schema.json +46 -0
  62. jambonz_sdk/schema/components/recognizer.schema.json +216 -0
  63. jambonz_sdk/schema/components/synthesizer.schema.json +82 -0
  64. jambonz_sdk/schema/components/target.schema.json +105 -0
  65. jambonz_sdk/schema/components/vad.schema.json +48 -0
  66. jambonz_sdk/schema/jambonz-app.schema.json +113 -0
  67. jambonz_sdk/schema/verbs/alert.schema.json +34 -0
  68. jambonz_sdk/schema/verbs/answer.schema.json +22 -0
  69. jambonz_sdk/schema/verbs/conference.schema.json +107 -0
  70. jambonz_sdk/schema/verbs/config.schema.json +221 -0
  71. jambonz_sdk/schema/verbs/deepgram_s2s.schema.json +81 -0
  72. jambonz_sdk/schema/verbs/dequeue.schema.json +51 -0
  73. jambonz_sdk/schema/verbs/dial.schema.json +200 -0
  74. jambonz_sdk/schema/verbs/dialogflow.schema.json +148 -0
  75. jambonz_sdk/schema/verbs/dtmf.schema.json +49 -0
  76. jambonz_sdk/schema/verbs/dub.schema.json +103 -0
  77. jambonz_sdk/schema/verbs/elevenlabs_s2s.schema.json +81 -0
  78. jambonz_sdk/schema/verbs/enqueue.schema.json +53 -0
  79. jambonz_sdk/schema/verbs/gather.schema.json +190 -0
  80. jambonz_sdk/schema/verbs/google_s2s.schema.json +42 -0
  81. jambonz_sdk/schema/verbs/hangup.schema.json +36 -0
  82. jambonz_sdk/schema/verbs/leave.schema.json +22 -0
  83. jambonz_sdk/schema/verbs/listen.schema.json +127 -0
  84. jambonz_sdk/schema/verbs/llm.schema.json +44 -0
  85. jambonz_sdk/schema/verbs/message.schema.json +82 -0
  86. jambonz_sdk/schema/verbs/openai_s2s.schema.json +42 -0
  87. jambonz_sdk/schema/verbs/pause.schema.json +36 -0
  88. jambonz_sdk/schema/verbs/pipeline.schema.json +240 -0
  89. jambonz_sdk/schema/verbs/play.schema.json +96 -0
  90. jambonz_sdk/schema/verbs/redirect.schema.json +34 -0
  91. jambonz_sdk/schema/verbs/rest_dial.schema.json +113 -0
  92. jambonz_sdk/schema/verbs/s2s.schema.json +39 -0
  93. jambonz_sdk/schema/verbs/say.schema.json +107 -0
  94. jambonz_sdk/schema/verbs/sip-decline.schema.json +58 -0
  95. jambonz_sdk/schema/verbs/sip-refer.schema.json +58 -0
  96. jambonz_sdk/schema/verbs/sip-request.schema.json +54 -0
  97. jambonz_sdk/schema/verbs/stream.schema.json +103 -0
  98. jambonz_sdk/schema/verbs/tag.schema.json +41 -0
  99. jambonz_sdk/schema/verbs/transcribe.schema.json +57 -0
  100. jambonz_sdk/schema/verbs/ultravox_s2s.schema.json +41 -0
  101. jambonz_sdk/types/__init__.py +139 -0
  102. jambonz_sdk/types/components.py +250 -0
  103. jambonz_sdk/types/rest.py +59 -0
  104. jambonz_sdk/types/session.py +55 -0
  105. jambonz_sdk/types/verbs.py +572 -0
  106. jambonz_sdk/validator.py +107 -0
  107. jambonz_sdk/verb_builder.py +316 -0
  108. jambonz_sdk/verb_builder.pyi +1133 -0
  109. jambonz_sdk/verb_registry.py +102 -0
  110. jambonz_sdk/webhook/__init__.py +10 -0
  111. jambonz_sdk/webhook/middleware.py +63 -0
  112. jambonz_sdk/webhook/response.py +43 -0
  113. jambonz_sdk/websocket/__init__.py +15 -0
  114. jambonz_sdk/websocket/audio_client.py +11 -0
  115. jambonz_sdk/websocket/audio_stream.py +151 -0
  116. jambonz_sdk/websocket/client.py +165 -0
  117. jambonz_sdk/websocket/endpoint.py +193 -0
  118. jambonz_sdk/websocket/router.py +87 -0
  119. jambonz_sdk/websocket/session.py +259 -0
@@ -0,0 +1,102 @@
1
+ """Verb registry — the single source of truth for mapping spec entries to SDK methods.
2
+
3
+ This module defines which entries in ``specs.json`` are top-level verbs
4
+ (as opposed to nested component types), their Python method names, docstrings,
5
+ and any synonym/alias transforms.
6
+
7
+ When the spec adds a new verb, add one entry here — the VerbBuilder will
8
+ automatically gain a typed method for it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class VerbDef:
19
+ """Definition of a single verb method on VerbBuilder.
20
+
21
+ Attributes:
22
+ spec_name: The key in specs.json (e.g., ``"say"``, ``"sip:decline"``).
23
+ method_name: The Python method name (e.g., ``"say"``, ``"sip_decline"``).
24
+ json_verb: The ``verb`` value in the output JSON. Defaults to ``spec_name``.
25
+ doc: One-line docstring for the generated method.
26
+ inject: Properties to inject into the verb data (for synonyms like
27
+ ``openai_s2s`` → ``llm`` with ``vendor: "openai"``).
28
+ """
29
+
30
+ spec_name: str
31
+ method_name: str
32
+ json_verb: str = ""
33
+ doc: str = ""
34
+ inject: dict[str, Any] = field(default_factory=dict)
35
+
36
+ def __post_init__(self) -> None:
37
+ if not self.json_verb:
38
+ object.__setattr__(self, "json_verb", self.spec_name)
39
+
40
+
41
+ # ── Verb definitions ────────────────────────────────────────────────
42
+ # Each entry here creates a method on VerbBuilder.
43
+ # Order matches the categories in the jambonz documentation.
44
+
45
+ VERB_DEFS: list[VerbDef] = [
46
+ # Audio & Speech
47
+ VerbDef("say", "say", doc="Speak text using TTS."),
48
+ VerbDef("play", "play", doc="Play an audio file from a URL."),
49
+ VerbDef("gather", "gather", doc="Collect speech (STT) and/or DTMF input."),
50
+
51
+ # AI & Real-time
52
+ VerbDef("llm", "openai_s2s", json_verb="openai_s2s",
53
+ doc="Connect caller to OpenAI for real-time voice conversation.",
54
+ inject={"vendor": "openai"}),
55
+ VerbDef("llm", "google_s2s", json_verb="google_s2s",
56
+ doc="Connect caller to Google for real-time voice conversation.",
57
+ inject={"vendor": "google"}),
58
+ VerbDef("llm", "deepgram_s2s", json_verb="deepgram_s2s",
59
+ doc="Connect caller to Deepgram for real-time voice conversation.",
60
+ inject={"vendor": "deepgram"}),
61
+ VerbDef("llm", "elevenlabs_s2s", json_verb="elevenlabs_s2s",
62
+ doc="Connect caller to ElevenLabs Conversational AI agent.",
63
+ inject={"vendor": "elevenlabs"}),
64
+ VerbDef("llm", "ultravox_s2s", json_verb="ultravox_s2s",
65
+ doc="Connect caller to Ultravox for real-time voice conversation.",
66
+ inject={"vendor": "ultravox"}),
67
+ VerbDef("llm", "s2s", json_verb="s2s",
68
+ doc="Generic S2S verb (use when vendor is determined at runtime)."),
69
+ VerbDef("llm", "llm", doc="Legacy LLM verb (prefer s2s or vendor-specific shortcuts)."),
70
+ VerbDef("dialogflow", "dialogflow", doc="Connect caller to Google Dialogflow agent."),
71
+ VerbDef("pipeline", "pipeline", doc="Integrated STT → LLM → TTS voice AI pipeline."),
72
+
73
+ # Audio Streaming
74
+ VerbDef("listen", "listen", doc="Stream real-time audio to a websocket endpoint."),
75
+ VerbDef("listen", "stream", json_verb="stream",
76
+ doc="Stream real-time audio (preferred alias for listen)."),
77
+ VerbDef("transcribe", "transcribe", doc="Enable real-time call transcription."),
78
+
79
+ # Call Control
80
+ VerbDef("dial", "dial", doc="Place outbound call and bridge to current caller."),
81
+ VerbDef("conference", "conference", doc="Place caller into a multi-party conference room."),
82
+ VerbDef("enqueue", "enqueue", doc="Place caller into a named call queue."),
83
+ VerbDef("dequeue", "dequeue", doc="Remove caller from a queue and bridge."),
84
+ VerbDef("hangup", "hangup", doc="Terminate the call."),
85
+ VerbDef("redirect", "redirect", doc="Transfer control to a different webhook URL."),
86
+ VerbDef("pause", "pause", doc="Pause execution for a specified duration."),
87
+
88
+ # SIP
89
+ VerbDef("sip:decline", "sip_decline", doc="Reject incoming call with a SIP error response."),
90
+ VerbDef("sip:request", "sip_request", doc="Send a SIP request within the current dialog."),
91
+ VerbDef("sip:refer", "sip_refer", doc="Send a SIP REFER for call transfer."),
92
+
93
+ # Utility
94
+ VerbDef("config", "config", doc="Set session-level defaults."),
95
+ VerbDef("tag", "tag", doc="Attach metadata to the call."),
96
+ VerbDef("dtmf", "dtmf", doc="Send DTMF tones."),
97
+ VerbDef("dub", "dub", doc="Manage audio dubbing tracks."),
98
+ VerbDef("message", "message", doc="Send SMS/MMS message."),
99
+ VerbDef("alert", "alert", doc="Send SIP 180 with Alert-Info header."),
100
+ VerbDef("answer", "answer", doc="Explicitly answer the call."),
101
+ VerbDef("leave", "leave", doc="Leave a conference or queue."),
102
+ ]
@@ -0,0 +1,10 @@
1
+ """Webhook (HTTP) transport for jambonz applications."""
2
+
3
+ from jambonz_sdk.webhook.middleware import env_vars_middleware, verify_signature_middleware
4
+ from jambonz_sdk.webhook.response import WebhookResponse
5
+
6
+ __all__ = [
7
+ "WebhookResponse",
8
+ "env_vars_middleware",
9
+ "verify_signature_middleware",
10
+ ]
@@ -0,0 +1,63 @@
1
+ """Middleware utilities for webhook applications.
2
+
3
+ These are framework-agnostic helpers. For framework-specific integration,
4
+ wrap these in your framework's middleware pattern.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from jambonz_sdk._signature import verify_signature
13
+
14
+
15
+ def verify_signature_middleware(
16
+ payload: bytes,
17
+ signature_header: str | None,
18
+ secret: str,
19
+ tolerance: int = 300,
20
+ ) -> bool:
21
+ """Verify a jambonz webhook signature.
22
+
23
+ Args:
24
+ payload: Raw request body bytes.
25
+ signature_header: Value of the ``Jambonz-Signature`` header.
26
+ secret: The webhook signing secret.
27
+ tolerance: Maximum age in seconds for the timestamp.
28
+
29
+ Returns:
30
+ True if valid.
31
+
32
+ Raises:
33
+ ValueError: If verification fails.
34
+ """
35
+ if not signature_header:
36
+ raise ValueError("Missing Jambonz-Signature header")
37
+ return verify_signature(payload, signature_header, secret, tolerance)
38
+
39
+
40
+ def env_vars_middleware(env_vars: dict[str, dict[str, Any]]) -> dict[str, Any]:
41
+ """Build the OPTIONS response body for environment variable discovery.
42
+
43
+ jambonz sends an OPTIONS request to discover configurable parameters.
44
+ Return this dict as the JSON response body for OPTIONS requests.
45
+
46
+ Args:
47
+ env_vars: Environment variable schema. Each key is a parameter name,
48
+ the value describes type, description, default, etc.
49
+
50
+ Returns:
51
+ A dict suitable for JSON serialization as the OPTIONS response body.
52
+
53
+ Example::
54
+
55
+ env_schema = {
56
+ "API_KEY": {"type": "string", "description": "API key", "required": True, "obscure": True},
57
+ "LANGUAGE": {"type": "string", "description": "TTS language", "default": "en-US"},
58
+ }
59
+
60
+ # In your OPTIONS handler:
61
+ return json.dumps(env_vars_middleware(env_schema))
62
+ """
63
+ return {"env": json.loads(json.dumps(env_vars))}
@@ -0,0 +1,43 @@
1
+ """WebhookResponse class for HTTP-based jambonz applications.
2
+
3
+ Usage with any framework that accepts JSON-serializable responses::
4
+
5
+ from jambonz_sdk.webhook import WebhookResponse
6
+
7
+ # In your request handler:
8
+ jambonz = WebhookResponse()
9
+ jambonz.say(text="Hello!").gather(
10
+ input=["speech"],
11
+ actionHook="/handle-input",
12
+ timeout=10,
13
+ say={"text": "Please say something."},
14
+ )
15
+ # Return jambonz.to_json() as the HTTP response body
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+
23
+ from jambonz_sdk.verb_builder import VerbBuilder
24
+
25
+
26
+ class WebhookResponse(VerbBuilder):
27
+ """Builds a jambonz verb array for HTTP webhook responses.
28
+
29
+ Extends VerbBuilder with JSON serialization. The response can be
30
+ converted to a JSON-serializable list or a JSON string.
31
+ """
32
+
33
+ def to_json(self) -> list[dict[str, Any]]:
34
+ """Return the verb array as a JSON-serializable list and reset."""
35
+ return self.to_list() # type: ignore[return-value]
36
+
37
+ def to_json_string(self) -> str:
38
+ """Return the verb array as a JSON string and reset."""
39
+ return json.dumps(self.to_list())
40
+
41
+ def __json__(self) -> list[dict[str, Any]]:
42
+ """Support for frameworks that call __json__ for serialization."""
43
+ return self.to_json()
@@ -0,0 +1,15 @@
1
+ """WebSocket transport for jambonz applications."""
2
+
3
+ from jambonz_sdk.websocket.audio_stream import AudioStream
4
+ from jambonz_sdk.websocket.client import WsClient
5
+ from jambonz_sdk.websocket.endpoint import create_endpoint
6
+ from jambonz_sdk.websocket.router import WsRouter
7
+ from jambonz_sdk.websocket.session import Session
8
+
9
+ __all__ = [
10
+ "AudioStream",
11
+ "Session",
12
+ "WsClient",
13
+ "WsRouter",
14
+ "create_endpoint",
15
+ ]
@@ -0,0 +1,11 @@
1
+ """AudioClient - manages audio WebSocket connections.
2
+
3
+ This module is used internally by the endpoint to handle connections
4
+ on the audio.drachtio.org subprotocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # AudioClient functionality is handled within the WsRouter and _AudioService
10
+ # classes in router.py. This module is reserved for future audio protocol
11
+ # extensions.
@@ -0,0 +1,151 @@
1
+ """AudioStream - per-call audio WebSocket handler.
2
+
3
+ Handles the ``audio.drachtio.org`` subprotocol for streaming raw audio
4
+ between jambonz and the application.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("jambonz_sdk.websocket.audio_stream")
15
+
16
+ AudioHandler = Callable[..., Any]
17
+
18
+
19
+ class AudioStream:
20
+ """Represents a single audio stream WebSocket connection.
21
+
22
+ Receives raw L16 PCM audio and JSON text events from jambonz.
23
+ Can send audio back for bidirectional streaming.
24
+
25
+ Events:
26
+ - ``audio``: Binary L16 PCM frame. Handler receives ``(pcm: bytes)``.
27
+ - ``dtmf``: DTMF event. Handler receives ``(data: dict)`` with digit and duration.
28
+ - ``playDone``: Playback completed. Handler receives ``(data: dict)`` with id.
29
+ - ``mark``: Synchronization marker. Handler receives ``(data: dict)`` with name and event.
30
+ - ``close``: Connection closed. Handler receives ``(code: int, reason: str)``.
31
+ - ``error``: Error occurred. Handler receives ``(err: Exception)``.
32
+
33
+ Attributes:
34
+ call_sid: Call identifier.
35
+ sample_rate: Audio sample rate.
36
+ metadata: Initial metadata from the connection.
37
+ """
38
+
39
+ def __init__(self, ws: Any) -> None:
40
+ self._ws = ws
41
+ self._handlers: dict[str, list[AudioHandler]] = {}
42
+ self.call_sid: str = ""
43
+ self.sample_rate: int = 8000
44
+ self.metadata: dict[str, Any] = {}
45
+
46
+ def on(self, event: str, handler: AudioHandler) -> AudioStream:
47
+ """Register an event handler.
48
+
49
+ Returns self for chaining.
50
+ """
51
+ if event not in self._handlers:
52
+ self._handlers[event] = []
53
+ self._handlers[event].append(handler)
54
+ return self
55
+
56
+ async def _emit(self, event: str, *args: Any) -> None:
57
+ import asyncio
58
+
59
+ for handler in self._handlers.get(event, []):
60
+ result = handler(*args)
61
+ if asyncio.iscoroutine(result):
62
+ await result
63
+
64
+ async def _run(self) -> None:
65
+ """Main message loop for the audio stream."""
66
+ try:
67
+ async for message in self._ws:
68
+ if isinstance(message, bytes):
69
+ await self._emit("audio", message)
70
+ else:
71
+ try:
72
+ data = json.loads(message)
73
+ except json.JSONDecodeError:
74
+ continue
75
+
76
+ event_type = data.get("type", "")
77
+
78
+ if event_type == "setup":
79
+ self.call_sid = data.get("callSid", "")
80
+ self.sample_rate = data.get("sampleRate", 8000)
81
+ self.metadata = data.get("metadata", {})
82
+ elif event_type == "dtmf":
83
+ await self._emit("dtmf", data)
84
+ elif event_type == "playDone":
85
+ await self._emit("playDone", data)
86
+ elif event_type == "mark":
87
+ await self._emit("mark", data)
88
+ else:
89
+ logger.debug("Unhandled audio event: %s", event_type)
90
+
91
+ except StopAsyncIteration:
92
+ await self._emit("close", 1000, "")
93
+ except Exception as exc:
94
+ await self._emit("error", exc)
95
+
96
+ # ── Sending Audio ───────────────────────────────────────────────
97
+
98
+ async def send_audio(self, pcm: bytes) -> None:
99
+ """Send raw L16 PCM audio back to jambonz (streaming mode)."""
100
+ await self._ws.send(pcm)
101
+
102
+ async def play_audio(
103
+ self,
104
+ audio_content: str,
105
+ *,
106
+ audio_content_type: str = "raw",
107
+ sample_rate: int = 8000,
108
+ id: str | None = None,
109
+ queue_play: bool = False,
110
+ ) -> None:
111
+ """Send a complete audio clip as base64 (non-streaming mode).
112
+
113
+ Args:
114
+ audio_content: Base64-encoded audio content.
115
+ audio_content_type: ``'raw'`` or ``'wav'``.
116
+ sample_rate: Audio sample rate in Hz.
117
+ id: Optional ID returned in the playDone event.
118
+ queue_play: If True, queue after current playback; if False, interrupt.
119
+ """
120
+ msg: dict[str, Any] = {
121
+ "type": "playAudio",
122
+ "data": {
123
+ "audioContent": audio_content,
124
+ "audioContentType": audio_content_type,
125
+ "sampleRate": sample_rate,
126
+ "queuePlay": queue_play,
127
+ },
128
+ }
129
+ if id is not None:
130
+ msg["data"]["id"] = id
131
+ await self._ws.send(json.dumps(msg))
132
+
133
+ async def kill_audio(self) -> None:
134
+ """Stop playback and flush the buffer."""
135
+ await self._ws.send(json.dumps({"type": "killAudio"}))
136
+
137
+ async def disconnect(self) -> None:
138
+ """End the listen/stream verb."""
139
+ await self._ws.send(json.dumps({"type": "disconnect"}))
140
+
141
+ async def send_mark(self, name: str) -> None:
142
+ """Insert a synchronization marker."""
143
+ await self._ws.send(json.dumps({"type": "mark", "name": name}))
144
+
145
+ async def clear_marks(self) -> None:
146
+ """Clear all pending markers."""
147
+ await self._ws.send(json.dumps({"type": "clearMarks"}))
148
+
149
+ async def close(self) -> None:
150
+ """Close the WebSocket connection."""
151
+ await self._ws.close()
@@ -0,0 +1,165 @@
1
+ """WsClient - manages a jambonz WebSocket service on a specific path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from jambonz_sdk.websocket.session import EventHandler, Session
10
+
11
+ logger = logging.getLogger("jambonz_sdk.websocket.client")
12
+
13
+
14
+ class WsClient:
15
+ """Manages a jambonz WebSocket service on a specific path.
16
+
17
+ Handles incoming WebSocket connections, creates Session objects for
18
+ new calls, and routes messages to the appropriate session.
19
+
20
+ Events:
21
+ - ``session:new``: New call session. Handler receives ``(session: Session)``.
22
+ - ``session:redirect``: Call redirected. Handler receives ``(session: Session)``.
23
+ - ``error``: Error occurred. Handler receives ``(error: Exception)``.
24
+ """
25
+
26
+ def __init__(self, path: str) -> None:
27
+ self.path = path
28
+ self._handlers: dict[str, list[EventHandler]] = {}
29
+ self._sessions: dict[str, Session] = {}
30
+
31
+ def on(self, event: str, handler: EventHandler) -> WsClient:
32
+ """Register an event handler.
33
+
34
+ Args:
35
+ event: Event name (``'session:new'``, ``'session:redirect'``, ``'error'``).
36
+ handler: Callback function.
37
+
38
+ Returns:
39
+ self for chaining.
40
+ """
41
+ if event not in self._handlers:
42
+ self._handlers[event] = []
43
+ self._handlers[event].append(handler)
44
+ return self
45
+
46
+ def _emit(self, event: str, *args: Any) -> bool:
47
+ """Emit an event to registered handlers."""
48
+ handlers = self._handlers.get(event, [])
49
+ for handler in handlers:
50
+ handler(*args)
51
+ return bool(handlers)
52
+
53
+ async def _emit_async(self, event: str, *args: Any) -> bool:
54
+ """Emit an event, awaiting async handlers."""
55
+ import asyncio
56
+
57
+ handlers = self._handlers.get(event, [])
58
+ for handler in handlers:
59
+ result = handler(*args)
60
+ if asyncio.iscoroutine(result):
61
+ await result
62
+ return bool(handlers)
63
+
64
+ async def handle_connection(self, ws: Any) -> None:
65
+ """Handle a WebSocket connection for this service.
66
+
67
+ Processes messages from jambonz and dispatches to the appropriate
68
+ Session or emits service-level events.
69
+
70
+ Args:
71
+ ws: A WebSocket connection object supporting ``send()``,
72
+ ``close()``, and async iteration for messages.
73
+ """
74
+ session: Session | None = None
75
+
76
+ try:
77
+ async for raw_message in ws:
78
+ if isinstance(raw_message, bytes):
79
+ continue # Binary frames handled by audio client
80
+
81
+ try:
82
+ msg = json.loads(raw_message)
83
+ except json.JSONDecodeError:
84
+ logger.warning("Received invalid JSON: %s", raw_message[:100])
85
+ continue
86
+
87
+ msg_type = msg.get("type", "")
88
+ msgid = msg.get("msgid", "")
89
+ data = msg.get("data", {})
90
+
91
+ if msg_type in ("session:new", "session:reconnect"):
92
+ session = Session(ws, data, msgid)
93
+ self._sessions[session.call_sid] = session
94
+ await self._emit_async("session:new", session)
95
+
96
+ elif msg_type == "session:redirect":
97
+ if session:
98
+ session._update_msgid(msgid)
99
+ session.data = data
100
+ await self._emit_async("session:redirect", session)
101
+ else:
102
+ session = Session(ws, data, msgid)
103
+ self._sessions[session.call_sid] = session
104
+ await self._emit_async("session:redirect", session)
105
+
106
+ elif msg_type == "session:adulting":
107
+ # Session is being transferred, just acknowledge
108
+ if session:
109
+ session._update_msgid(msgid)
110
+
111
+ elif msg_type == "verb:hook":
112
+ if session:
113
+ session._update_msgid(msgid)
114
+ hook = msg.get("hook", "")
115
+ # Try specific handler first, then fallback
116
+ handled = await session._emit_async(hook, data)
117
+ if not handled:
118
+ handled = await session._emit_async("verb:hook", hook, data)
119
+ if not handled:
120
+ # Auto-reply with empty verb array
121
+ await session.reply()
122
+
123
+ elif msg_type == "verb:status":
124
+ if session:
125
+ await session._emit_async("verb:status", data)
126
+
127
+ elif msg_type == "call:status":
128
+ if session:
129
+ await session._emit_async("call:status", data)
130
+
131
+ elif msg_type == "llm:tool-call":
132
+ if session:
133
+ session._update_msgid(msgid)
134
+ await session._emit_async("llm:tool-call", data)
135
+
136
+ elif msg_type == "llm:event":
137
+ if session:
138
+ await session._emit_async("llm:event", data)
139
+
140
+ elif msg_type == "tts:tokens-result":
141
+ if session:
142
+ await session._emit_async("tts:tokens-result", data)
143
+
144
+ elif msg_type == "tts:streaming-event":
145
+ if session:
146
+ event_type = data.get("event_type", "")
147
+ # Emit specific event
148
+ if event_type:
149
+ await session._emit_async(f"tts:{event_type}", data)
150
+ # Emit catch-all
151
+ await session._emit_async("tts:streaming-event", data)
152
+
153
+ else:
154
+ logger.debug("Unhandled message type: %s", msg_type)
155
+
156
+ except StopAsyncIteration:
157
+ if session:
158
+ await session._emit_async("close", 1000, "")
159
+ except Exception as exc:
160
+ if session:
161
+ await session._emit_async("error", exc)
162
+ await self._emit_async("error", exc)
163
+ finally:
164
+ if session and session.call_sid in self._sessions:
165
+ del self._sessions[session.call_sid]