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.
- jambonz_python_sdk-0.2.0.dist-info/METADATA +179 -0
- jambonz_python_sdk-0.2.0.dist-info/RECORD +119 -0
- jambonz_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- jambonz_sdk/__init__.py +52 -0
- jambonz_sdk/_signature.py +73 -0
- jambonz_sdk/client/__init__.py +15 -0
- jambonz_sdk/client/api.py +241 -0
- jambonz_sdk/schema/callbacks/amd.schema.json +50 -0
- jambonz_sdk/schema/callbacks/base.schema.json +29 -0
- jambonz_sdk/schema/callbacks/call-status.schema.json +22 -0
- jambonz_sdk/schema/callbacks/conference-status.schema.json +24 -0
- jambonz_sdk/schema/callbacks/conference-wait.schema.json +11 -0
- jambonz_sdk/schema/callbacks/conference.schema.json +11 -0
- jambonz_sdk/schema/callbacks/dequeue.schema.json +19 -0
- jambonz_sdk/schema/callbacks/dial-dtmf.schema.json +18 -0
- jambonz_sdk/schema/callbacks/dial-hold.schema.json +22 -0
- jambonz_sdk/schema/callbacks/dial-refer.schema.json +28 -0
- jambonz_sdk/schema/callbacks/dial.schema.json +31 -0
- jambonz_sdk/schema/callbacks/enqueue-wait.schema.json +17 -0
- jambonz_sdk/schema/callbacks/enqueue.schema.json +27 -0
- jambonz_sdk/schema/callbacks/gather-partial.schema.json +54 -0
- jambonz_sdk/schema/callbacks/gather.schema.json +60 -0
- jambonz_sdk/schema/callbacks/listen.schema.json +21 -0
- jambonz_sdk/schema/callbacks/llm.schema.json +30 -0
- jambonz_sdk/schema/callbacks/message.schema.json +35 -0
- jambonz_sdk/schema/callbacks/pipeline-turn.schema.json +109 -0
- jambonz_sdk/schema/callbacks/play.schema.json +36 -0
- jambonz_sdk/schema/callbacks/session-new.schema.json +143 -0
- jambonz_sdk/schema/callbacks/session-reconnect.schema.json +9 -0
- jambonz_sdk/schema/callbacks/session-redirect.schema.json +38 -0
- jambonz_sdk/schema/callbacks/sip-refer-event.schema.json +20 -0
- jambonz_sdk/schema/callbacks/sip-refer.schema.json +22 -0
- jambonz_sdk/schema/callbacks/sip-request.schema.json +27 -0
- jambonz_sdk/schema/callbacks/transcribe-translation.schema.json +24 -0
- jambonz_sdk/schema/callbacks/transcribe.schema.json +46 -0
- jambonz_sdk/schema/callbacks/tts-streaming-event.schema.json +77 -0
- jambonz_sdk/schema/callbacks/verb-status.schema.json +57 -0
- jambonz_sdk/schema/components/actionHook.schema.json +36 -0
- jambonz_sdk/schema/components/actionHookDelayAction.schema.json +37 -0
- jambonz_sdk/schema/components/amd.schema.json +68 -0
- jambonz_sdk/schema/components/auth.schema.json +18 -0
- jambonz_sdk/schema/components/bidirectionalAudio.schema.json +22 -0
- jambonz_sdk/schema/components/fillerNoise.schema.json +25 -0
- jambonz_sdk/schema/components/llm-base.schema.json +94 -0
- jambonz_sdk/schema/components/recognizer-assemblyAiOptions.schema.json +66 -0
- jambonz_sdk/schema/components/recognizer-awsOptions.schema.json +52 -0
- jambonz_sdk/schema/components/recognizer-azureOptions.schema.json +32 -0
- jambonz_sdk/schema/components/recognizer-cobaltOptions.schema.json +34 -0
- jambonz_sdk/schema/components/recognizer-customOptions.schema.json +27 -0
- jambonz_sdk/schema/components/recognizer-deepgramOptions.schema.json +147 -0
- jambonz_sdk/schema/components/recognizer-elevenlabsOptions.schema.json +39 -0
- jambonz_sdk/schema/components/recognizer-gladiaOptions.schema.json +8 -0
- jambonz_sdk/schema/components/recognizer-googleOptions.schema.json +35 -0
- jambonz_sdk/schema/components/recognizer-houndifyOptions.schema.json +53 -0
- jambonz_sdk/schema/components/recognizer-ibmOptions.schema.json +54 -0
- jambonz_sdk/schema/components/recognizer-nuanceOptions.schema.json +150 -0
- jambonz_sdk/schema/components/recognizer-nvidiaOptions.schema.json +39 -0
- jambonz_sdk/schema/components/recognizer-openaiOptions.schema.json +59 -0
- jambonz_sdk/schema/components/recognizer-sonioxOptions.schema.json +46 -0
- jambonz_sdk/schema/components/recognizer-speechmaticsOptions.schema.json +100 -0
- jambonz_sdk/schema/components/recognizer-verbioOptions.schema.json +46 -0
- jambonz_sdk/schema/components/recognizer.schema.json +216 -0
- jambonz_sdk/schema/components/synthesizer.schema.json +82 -0
- jambonz_sdk/schema/components/target.schema.json +105 -0
- jambonz_sdk/schema/components/vad.schema.json +48 -0
- jambonz_sdk/schema/jambonz-app.schema.json +113 -0
- jambonz_sdk/schema/verbs/alert.schema.json +34 -0
- jambonz_sdk/schema/verbs/answer.schema.json +22 -0
- jambonz_sdk/schema/verbs/conference.schema.json +107 -0
- jambonz_sdk/schema/verbs/config.schema.json +221 -0
- jambonz_sdk/schema/verbs/deepgram_s2s.schema.json +81 -0
- jambonz_sdk/schema/verbs/dequeue.schema.json +51 -0
- jambonz_sdk/schema/verbs/dial.schema.json +200 -0
- jambonz_sdk/schema/verbs/dialogflow.schema.json +148 -0
- jambonz_sdk/schema/verbs/dtmf.schema.json +49 -0
- jambonz_sdk/schema/verbs/dub.schema.json +103 -0
- jambonz_sdk/schema/verbs/elevenlabs_s2s.schema.json +81 -0
- jambonz_sdk/schema/verbs/enqueue.schema.json +53 -0
- jambonz_sdk/schema/verbs/gather.schema.json +190 -0
- jambonz_sdk/schema/verbs/google_s2s.schema.json +42 -0
- jambonz_sdk/schema/verbs/hangup.schema.json +36 -0
- jambonz_sdk/schema/verbs/leave.schema.json +22 -0
- jambonz_sdk/schema/verbs/listen.schema.json +127 -0
- jambonz_sdk/schema/verbs/llm.schema.json +44 -0
- jambonz_sdk/schema/verbs/message.schema.json +82 -0
- jambonz_sdk/schema/verbs/openai_s2s.schema.json +42 -0
- jambonz_sdk/schema/verbs/pause.schema.json +36 -0
- jambonz_sdk/schema/verbs/pipeline.schema.json +240 -0
- jambonz_sdk/schema/verbs/play.schema.json +96 -0
- jambonz_sdk/schema/verbs/redirect.schema.json +34 -0
- jambonz_sdk/schema/verbs/rest_dial.schema.json +113 -0
- jambonz_sdk/schema/verbs/s2s.schema.json +39 -0
- jambonz_sdk/schema/verbs/say.schema.json +107 -0
- jambonz_sdk/schema/verbs/sip-decline.schema.json +58 -0
- jambonz_sdk/schema/verbs/sip-refer.schema.json +58 -0
- jambonz_sdk/schema/verbs/sip-request.schema.json +54 -0
- jambonz_sdk/schema/verbs/stream.schema.json +103 -0
- jambonz_sdk/schema/verbs/tag.schema.json +41 -0
- jambonz_sdk/schema/verbs/transcribe.schema.json +57 -0
- jambonz_sdk/schema/verbs/ultravox_s2s.schema.json +41 -0
- jambonz_sdk/types/__init__.py +139 -0
- jambonz_sdk/types/components.py +250 -0
- jambonz_sdk/types/rest.py +59 -0
- jambonz_sdk/types/session.py +55 -0
- jambonz_sdk/types/verbs.py +572 -0
- jambonz_sdk/validator.py +107 -0
- jambonz_sdk/verb_builder.py +316 -0
- jambonz_sdk/verb_builder.pyi +1133 -0
- jambonz_sdk/verb_registry.py +102 -0
- jambonz_sdk/webhook/__init__.py +10 -0
- jambonz_sdk/webhook/middleware.py +63 -0
- jambonz_sdk/webhook/response.py +43 -0
- jambonz_sdk/websocket/__init__.py +15 -0
- jambonz_sdk/websocket/audio_client.py +11 -0
- jambonz_sdk/websocket/audio_stream.py +151 -0
- jambonz_sdk/websocket/client.py +165 -0
- jambonz_sdk/websocket/endpoint.py +193 -0
- jambonz_sdk/websocket/router.py +87 -0
- 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]
|