nitpick-voice 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.
- nitpick/__init__.py +136 -0
- nitpick/__main__.py +6 -0
- nitpick/agent.py +466 -0
- nitpick/billing/__init__.py +22 -0
- nitpick/billing/collector.py +228 -0
- nitpick/billing/events.py +104 -0
- nitpick/cli.py +79 -0
- nitpick/context.py +153 -0
- nitpick/frames/__init__.py +92 -0
- nitpick/frames/frames.py +268 -0
- nitpick/frames/kinds.py +164 -0
- nitpick/frames/processor.py +361 -0
- nitpick/pipeline/__init__.py +7 -0
- nitpick/pipeline/pipeline.py +53 -0
- nitpick/pipeline/task.py +294 -0
- nitpick/processors/assistant_aggregator.py +84 -0
- nitpick/processors/interruption.py +86 -0
- nitpick/processors/user_aggregator.py +102 -0
- nitpick/rooms/__init__.py +49 -0
- nitpick/rooms/control.py +207 -0
- nitpick/rooms/manager.py +419 -0
- nitpick/rooms/model.py +142 -0
- nitpick/sdk/__init__.py +6 -0
- nitpick/sdk/client.py +285 -0
- nitpick/services/__init__.py +6 -0
- nitpick/services/llm/__init__.py +6 -0
- nitpick/services/llm/openai.py +332 -0
- nitpick/services/stt/__init__.py +16 -0
- nitpick/services/stt/base.py +252 -0
- nitpick/services/stt/deepgram.py +120 -0
- nitpick/services/stt/openai.py +102 -0
- nitpick/services/stt/sarvam.py +202 -0
- nitpick/services/tts/__init__.py +6 -0
- nitpick/services/tts/openai.py +233 -0
- nitpick/signaling/server.py +560 -0
- nitpick/transport/__init__.py +436 -0
- nitpick/transport/webrtc.py +311 -0
- nitpick/vad/__init__.py +27 -0
- nitpick/vad/backends.py +337 -0
- nitpick/vad/core.py +225 -0
- nitpick/vad/processor.py +144 -0
- nitpick/web/index.html +482 -0
- nitpick_voice-0.2.0.dist-info/METADATA +434 -0
- nitpick_voice-0.2.0.dist-info/RECORD +48 -0
- nitpick_voice-0.2.0.dist-info/WHEEL +5 -0
- nitpick_voice-0.2.0.dist-info/entry_points.txt +3 -0
- nitpick_voice-0.2.0.dist-info/licenses/LICENSE +25 -0
- nitpick_voice-0.2.0.dist-info/top_level.txt +1 -0
nitpick/__init__.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""nitpick — a Python framework for building voice agents.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from nitpick import Agent, tool
|
|
6
|
+
|
|
7
|
+
@tool("get_time", "Get the current time.")
|
|
8
|
+
def get_time() -> str:
|
|
9
|
+
return "It is half past three."
|
|
10
|
+
|
|
11
|
+
agent = Agent(name="assistant", prompt="You are a friendly voice assistant.")
|
|
12
|
+
agent.run(room_id="demo", port=8000)
|
|
13
|
+
|
|
14
|
+
Then open http://localhost:8000 and talk to it.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .agent import Agent, AgentConfig, ToolDefinition, tool
|
|
19
|
+
from .billing import (
|
|
20
|
+
BillingCollector,
|
|
21
|
+
BillingEvent,
|
|
22
|
+
LogBillingStorage,
|
|
23
|
+
MemoryBillingStorage,
|
|
24
|
+
SessionBilling,
|
|
25
|
+
TranscriptRole,
|
|
26
|
+
)
|
|
27
|
+
from .context import LLMContext, Message, ToolSchema, shared_context
|
|
28
|
+
from .frames import (
|
|
29
|
+
AudioChunk,
|
|
30
|
+
Frame,
|
|
31
|
+
FrameKind,
|
|
32
|
+
FrameProcessor,
|
|
33
|
+
Transcription,
|
|
34
|
+
VadStarted,
|
|
35
|
+
VadStopped,
|
|
36
|
+
)
|
|
37
|
+
from .pipeline import FinishReason, Pipeline, PipelineParams, PipelineTask
|
|
38
|
+
from .processors.assistant_aggregator import AssistantAggregator
|
|
39
|
+
from .processors.interruption import InterruptionHandler
|
|
40
|
+
from .processors.user_aggregator import UserAggregator
|
|
41
|
+
from .rooms import (
|
|
42
|
+
Participant,
|
|
43
|
+
ParticipantRole,
|
|
44
|
+
Room,
|
|
45
|
+
RoomControlModule,
|
|
46
|
+
RoomEventType,
|
|
47
|
+
RoomManager,
|
|
48
|
+
)
|
|
49
|
+
from .sdk import RoomClient, SDKError
|
|
50
|
+
from .services.llm import LLMConfig, LLMService, LLMTool
|
|
51
|
+
from .services.stt import (
|
|
52
|
+
DeepgramSttProvider,
|
|
53
|
+
OpenAISttProvider,
|
|
54
|
+
SarvamSttProvider,
|
|
55
|
+
SttEvent,
|
|
56
|
+
SttProvider,
|
|
57
|
+
SttService,
|
|
58
|
+
)
|
|
59
|
+
from .services.tts import TTSConfig, TTSService
|
|
60
|
+
from .transport import (
|
|
61
|
+
ChannelTransport,
|
|
62
|
+
RoomTransport,
|
|
63
|
+
SpeakingGate,
|
|
64
|
+
TransportParams,
|
|
65
|
+
)
|
|
66
|
+
from .vad import (
|
|
67
|
+
EnergyVad,
|
|
68
|
+
SileroVad,
|
|
69
|
+
VadParams,
|
|
70
|
+
VadProcessor,
|
|
71
|
+
VadState,
|
|
72
|
+
WebRtcVad,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__version__ = "0.2.0"
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"Agent",
|
|
79
|
+
"AgentConfig",
|
|
80
|
+
"AssistantAggregator",
|
|
81
|
+
"AudioChunk",
|
|
82
|
+
"BillingCollector",
|
|
83
|
+
"BillingEvent",
|
|
84
|
+
"ChannelTransport",
|
|
85
|
+
"DeepgramSttProvider",
|
|
86
|
+
"EnergyVad",
|
|
87
|
+
"FinishReason",
|
|
88
|
+
"Frame",
|
|
89
|
+
"FrameKind",
|
|
90
|
+
"FrameProcessor",
|
|
91
|
+
"InterruptionHandler",
|
|
92
|
+
"LLMConfig",
|
|
93
|
+
"LLMContext",
|
|
94
|
+
"LLMService",
|
|
95
|
+
"LLMTool",
|
|
96
|
+
"LogBillingStorage",
|
|
97
|
+
"MemoryBillingStorage",
|
|
98
|
+
"Message",
|
|
99
|
+
"OpenAISttProvider",
|
|
100
|
+
"Participant",
|
|
101
|
+
"ParticipantRole",
|
|
102
|
+
"Pipeline",
|
|
103
|
+
"PipelineParams",
|
|
104
|
+
"PipelineTask",
|
|
105
|
+
"Room",
|
|
106
|
+
"RoomClient",
|
|
107
|
+
"RoomControlModule",
|
|
108
|
+
"RoomEventType",
|
|
109
|
+
"RoomManager",
|
|
110
|
+
"RoomTransport",
|
|
111
|
+
"SDKError",
|
|
112
|
+
"SarvamSttProvider",
|
|
113
|
+
"SessionBilling",
|
|
114
|
+
"SileroVad",
|
|
115
|
+
"SpeakingGate",
|
|
116
|
+
"SttEvent",
|
|
117
|
+
"SttProvider",
|
|
118
|
+
"SttService",
|
|
119
|
+
"TTSConfig",
|
|
120
|
+
"TTSService",
|
|
121
|
+
"ToolDefinition",
|
|
122
|
+
"ToolSchema",
|
|
123
|
+
"TranscriptRole",
|
|
124
|
+
"Transcription",
|
|
125
|
+
"TransportParams",
|
|
126
|
+
"UserAggregator",
|
|
127
|
+
"VadParams",
|
|
128
|
+
"VadProcessor",
|
|
129
|
+
"VadState",
|
|
130
|
+
"VadStarted",
|
|
131
|
+
"VadStopped",
|
|
132
|
+
"WebRtcVad",
|
|
133
|
+
"__version__",
|
|
134
|
+
"shared_context",
|
|
135
|
+
"tool",
|
|
136
|
+
]
|
nitpick/__main__.py
ADDED
nitpick/agent.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""The :class:`Agent` facade — the API a nitpick user actually touches.
|
|
2
|
+
|
|
3
|
+
The goal is that building a voice agent is one expression::
|
|
4
|
+
|
|
5
|
+
from nitpick import Agent, tool
|
|
6
|
+
|
|
7
|
+
agent = Agent(
|
|
8
|
+
name="assistant",
|
|
9
|
+
prompt="You are a helpful voice assistant. Keep replies short.",
|
|
10
|
+
stt="openai",
|
|
11
|
+
llm="openai:gpt-4o-mini",
|
|
12
|
+
tts="openai:alloy",
|
|
13
|
+
tools=[weather],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
agent.run(room_id="demo", port=8000)
|
|
17
|
+
|
|
18
|
+
That serves the browser client, waits for someone to join the room, and runs
|
|
19
|
+
the voice loop end to end.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import logging
|
|
26
|
+
import uuid
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
from .billing import SessionBilling
|
|
31
|
+
from .context import LLMContext, ToolSchema
|
|
32
|
+
from .frames import FrameProcessor
|
|
33
|
+
from .pipeline import PipelineParams, PipelineTask
|
|
34
|
+
from .processors.assistant_aggregator import AssistantAggregator
|
|
35
|
+
from .processors.interruption import InterruptionHandler
|
|
36
|
+
from .processors.user_aggregator import UserAggregator
|
|
37
|
+
from .rooms import RoomControlModule, RoomManager
|
|
38
|
+
from .rooms.model import Participant
|
|
39
|
+
from .services.llm import LLMConfig, LLMService, LLMTool
|
|
40
|
+
from .services.stt import (
|
|
41
|
+
DeepgramSttProvider,
|
|
42
|
+
OpenAISttProvider,
|
|
43
|
+
SarvamSttProvider,
|
|
44
|
+
SttProvider,
|
|
45
|
+
SttService,
|
|
46
|
+
)
|
|
47
|
+
from .services.tts import TTSConfig, TTSService
|
|
48
|
+
from .transport import RoomTransport, TransportParams
|
|
49
|
+
|
|
50
|
+
log = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
ToolFunction = Callable[..., Awaitable[str] | str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ToolDefinition:
|
|
57
|
+
"""A callable exposed to the model."""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
description: str
|
|
61
|
+
parameters: dict[str, Any]
|
|
62
|
+
handler: ToolFunction
|
|
63
|
+
|
|
64
|
+
def to_tool(self) -> LLMTool:
|
|
65
|
+
schema = ToolSchema(
|
|
66
|
+
name=self.name, description=self.description, parameters=self.parameters
|
|
67
|
+
)
|
|
68
|
+
return LLMTool(schema=schema, handler=self._invoke)
|
|
69
|
+
|
|
70
|
+
async def _invoke(self, arguments: str) -> str:
|
|
71
|
+
import json
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
parsed = json.loads(arguments) if arguments.strip() else {}
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
parsed = {}
|
|
77
|
+
result = self.handler(**parsed) if isinstance(parsed, dict) else self.handler(parsed)
|
|
78
|
+
if asyncio.iscoroutine(result):
|
|
79
|
+
result = await result
|
|
80
|
+
return str(result)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def tool(
|
|
84
|
+
name: str,
|
|
85
|
+
description: str,
|
|
86
|
+
parameters: dict[str, Any] | None = None,
|
|
87
|
+
) -> Callable[[ToolFunction], ToolDefinition]:
|
|
88
|
+
"""Decorator that registers a function as a model-callable tool.
|
|
89
|
+
|
|
90
|
+
Example::
|
|
91
|
+
|
|
92
|
+
@tool("get_weather", "Look up the weather for a city.")
|
|
93
|
+
def get_weather(city: str) -> str:
|
|
94
|
+
return f"It is 28 degrees in {city}."
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def decorator(func: ToolFunction) -> ToolDefinition:
|
|
98
|
+
return ToolDefinition(
|
|
99
|
+
name=name,
|
|
100
|
+
description=description,
|
|
101
|
+
parameters=parameters
|
|
102
|
+
or {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
key: {"type": "string"} for key in func.__code__.co_varnames[
|
|
106
|
+
: func.__code__.co_argcount
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"required": list(func.__code__.co_varnames[: func.__code__.co_argcount]),
|
|
110
|
+
},
|
|
111
|
+
handler=func,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return decorator
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class AgentConfig:
|
|
119
|
+
"""Everything needed to build an agent."""
|
|
120
|
+
|
|
121
|
+
name: str = "Agent"
|
|
122
|
+
prompt: str = "You are a helpful voice assistant. Keep replies to one or two sentences."
|
|
123
|
+
stt: str = "openai"
|
|
124
|
+
llm: str = "openai:gpt-4o-mini"
|
|
125
|
+
tts: str = "openai:alloy"
|
|
126
|
+
#: The base URL of the LLM endpoint, for OpenAI-compatible servers
|
|
127
|
+
#: (a self-hosted model, or a gateway).
|
|
128
|
+
llm_base_url: str | None = None
|
|
129
|
+
language: str | None = None
|
|
130
|
+
input_sample_rate: int = 16_000
|
|
131
|
+
output_sample_rate: int = 24_000
|
|
132
|
+
#: Server-side VAD backend: ``"energy"`` (default, no dependencies),
|
|
133
|
+
#: ``"webrtc"``, ``"silero"``, or ``None`` to rely on the browser's own
|
|
134
|
+
#: speaking detection.
|
|
135
|
+
vad: str | None = "energy"
|
|
136
|
+
#: Whether the user can interrupt the bot mid-sentence.
|
|
137
|
+
allow_interruptions: bool = True
|
|
138
|
+
#: Whether to collect usage and transcript events for this session.
|
|
139
|
+
billing: bool = False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Agent:
|
|
143
|
+
"""A voice agent you can run locally.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
name:
|
|
148
|
+
The agent's display name in the room.
|
|
149
|
+
prompt:
|
|
150
|
+
The system prompt.
|
|
151
|
+
stt:
|
|
152
|
+
``"openai"``, ``"deepgram"``, or ``"sarvam"``.
|
|
153
|
+
llm:
|
|
154
|
+
``"openai"`` or ``"openai:<model>"``.
|
|
155
|
+
tts:
|
|
156
|
+
``"openai"`` or ``"openai:<voice>"``.
|
|
157
|
+
tools:
|
|
158
|
+
Tools the model may call.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
name: str = "Agent",
|
|
164
|
+
*,
|
|
165
|
+
prompt: str | None = None,
|
|
166
|
+
stt: str = "openai",
|
|
167
|
+
llm: str = "openai:gpt-4o-mini",
|
|
168
|
+
tts: str = "openai:alloy",
|
|
169
|
+
llm_base_url: str | None = None,
|
|
170
|
+
language: str | None = None,
|
|
171
|
+
tools: list[ToolDefinition] | None = None,
|
|
172
|
+
input_sample_rate: int = 16_000,
|
|
173
|
+
output_sample_rate: int = 24_000,
|
|
174
|
+
vad: str | None = "energy",
|
|
175
|
+
allow_interruptions: bool = True,
|
|
176
|
+
billing: bool = False,
|
|
177
|
+
session_id: str | None = None,
|
|
178
|
+
stt_provider: SttProvider | None = None,
|
|
179
|
+
llm_service: LLMService | None = None,
|
|
180
|
+
tts_service: TTSService | None = None,
|
|
181
|
+
vad_backend: object | None = None,
|
|
182
|
+
manager: RoomManager | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
self.config = AgentConfig(
|
|
185
|
+
name=name,
|
|
186
|
+
prompt=prompt or AgentConfig.prompt,
|
|
187
|
+
stt=stt,
|
|
188
|
+
llm=llm,
|
|
189
|
+
tts=tts,
|
|
190
|
+
llm_base_url=llm_base_url,
|
|
191
|
+
language=language,
|
|
192
|
+
input_sample_rate=input_sample_rate,
|
|
193
|
+
output_sample_rate=output_sample_rate,
|
|
194
|
+
vad=vad,
|
|
195
|
+
allow_interruptions=allow_interruptions,
|
|
196
|
+
billing=billing,
|
|
197
|
+
)
|
|
198
|
+
self.tools = list(tools or [])
|
|
199
|
+
self.context = LLMContext(self.config.prompt)
|
|
200
|
+
# A shared manager lets one server host many agents, each in its
|
|
201
|
+
# own room. When None the agent owns its manager and serves it.
|
|
202
|
+
self.manager = manager if manager is not None else RoomManager()
|
|
203
|
+
self.control = RoomControlModule(self.manager)
|
|
204
|
+
self.session_id = session_id or f"s-{uuid.uuid4().hex[:12]}"
|
|
205
|
+
|
|
206
|
+
# Service overrides — used by tests and by anyone who wants to plug
|
|
207
|
+
# in a provider nitpick does not ship.
|
|
208
|
+
self._stt_provider = stt_provider
|
|
209
|
+
self._llm_service = llm_service
|
|
210
|
+
self._tts_service = tts_service
|
|
211
|
+
self._vad_backend = vad_backend
|
|
212
|
+
|
|
213
|
+
# Billing.
|
|
214
|
+
self.billing: SessionBilling | None = None
|
|
215
|
+
if billing:
|
|
216
|
+
self.billing = SessionBilling(self.session_id)
|
|
217
|
+
self.billing.start()
|
|
218
|
+
|
|
219
|
+
self._task: PipelineTask | None = None
|
|
220
|
+
self._transport: RoomTransport | None = None
|
|
221
|
+
self._participant: Participant | None = None
|
|
222
|
+
self._server: Any = None
|
|
223
|
+
self._server_task: asyncio.Task | None = None
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Service construction
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _build_stt(self) -> SttService:
|
|
230
|
+
if self._stt_provider is not None:
|
|
231
|
+
return SttService(self._stt_provider)
|
|
232
|
+
|
|
233
|
+
spec = self.config.stt
|
|
234
|
+
provider_name, _, options = spec.partition(":")
|
|
235
|
+
options = dict(
|
|
236
|
+
pair.split("=", 1) for pair in options.split(",") if "=" in pair
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
provider: SttProvider
|
|
240
|
+
if provider_name == "deepgram":
|
|
241
|
+
provider = DeepgramSttProvider(
|
|
242
|
+
language=self.config.language,
|
|
243
|
+
sample_rate=self.config.input_sample_rate,
|
|
244
|
+
**options,
|
|
245
|
+
)
|
|
246
|
+
elif provider_name == "sarvam":
|
|
247
|
+
provider = SarvamSttProvider(
|
|
248
|
+
language=self.config.language,
|
|
249
|
+
sample_rate=self.config.input_sample_rate,
|
|
250
|
+
**options,
|
|
251
|
+
)
|
|
252
|
+
elif provider_name == "openai":
|
|
253
|
+
provider = OpenAISttProvider(
|
|
254
|
+
language=self.config.language,
|
|
255
|
+
sample_rate=self.config.input_sample_rate,
|
|
256
|
+
**options,
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Unknown STT provider {provider_name!r}. "
|
|
261
|
+
"Use 'openai', 'deepgram' or 'sarvam'."
|
|
262
|
+
)
|
|
263
|
+
return SttService(provider)
|
|
264
|
+
|
|
265
|
+
def _build_vad(self, gate: object | None, on_speaking: Any = None) -> FrameProcessor | None:
|
|
266
|
+
"""Build the server-side VAD processor, if enabled.
|
|
267
|
+
|
|
268
|
+
If the backend is not installed the agent still works — the browser
|
|
269
|
+
reports its own speaking edges, which is enough for turn detection.
|
|
270
|
+
Server-side VAD is an improvement, not a requirement.
|
|
271
|
+
"""
|
|
272
|
+
if self._vad_backend is not None:
|
|
273
|
+
backend = self._vad_backend
|
|
274
|
+
elif self.config.vad == "energy":
|
|
275
|
+
from .vad import EnergyVad
|
|
276
|
+
|
|
277
|
+
backend = EnergyVad(self.config.input_sample_rate)
|
|
278
|
+
elif self.config.vad == "webrtc":
|
|
279
|
+
from .vad import WebRtcVad
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
backend = WebRtcVad(self.config.input_sample_rate)
|
|
283
|
+
except (RuntimeError, ValueError) as exc:
|
|
284
|
+
log.warning(
|
|
285
|
+
"Server-side VAD unavailable (%s) — falling back to the "
|
|
286
|
+
"browser's speaking detection",
|
|
287
|
+
exc,
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
elif self.config.vad == "silero":
|
|
291
|
+
from .vad import SileroVad
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
backend = SileroVad(self.config.input_sample_rate)
|
|
295
|
+
except (RuntimeError, ValueError, FileNotFoundError) as exc:
|
|
296
|
+
log.warning(
|
|
297
|
+
"Server-side VAD unavailable (%s) — falling back to the "
|
|
298
|
+
"browser's speaking detection",
|
|
299
|
+
exc,
|
|
300
|
+
)
|
|
301
|
+
return None
|
|
302
|
+
elif self.config.vad is None:
|
|
303
|
+
return None
|
|
304
|
+
else:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"Unknown VAD backend {self.config.vad!r}. Use 'webrtc', 'silero' or None."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
from .vad import VadProcessor
|
|
310
|
+
|
|
311
|
+
return VadProcessor(backend, speaking_gate=gate, on_speaking=on_speaking)
|
|
312
|
+
|
|
313
|
+
def _build_llm(self) -> LLMService:
|
|
314
|
+
if self._llm_service is not None:
|
|
315
|
+
return self._llm_service
|
|
316
|
+
|
|
317
|
+
spec = self.config.llm
|
|
318
|
+
provider_name, _, model = spec.partition(":")
|
|
319
|
+
if provider_name != "openai":
|
|
320
|
+
raise ValueError(f"Unknown LLM provider {provider_name!r}. Use 'openai'.")
|
|
321
|
+
config = LLMConfig(model=model or "gpt-4o-mini")
|
|
322
|
+
if self.config.llm_base_url:
|
|
323
|
+
config.base_url = self.config.llm_base_url
|
|
324
|
+
service = LLMService(self.context, config, billing=self.billing)
|
|
325
|
+
for definition in self.tools:
|
|
326
|
+
service.add_tool(definition.to_tool())
|
|
327
|
+
return service
|
|
328
|
+
|
|
329
|
+
def _build_tts(self) -> TTSService:
|
|
330
|
+
if self._tts_service is not None:
|
|
331
|
+
return self._tts_service
|
|
332
|
+
|
|
333
|
+
spec = self.config.tts
|
|
334
|
+
provider_name, _, voice = spec.partition(":")
|
|
335
|
+
if provider_name != "openai":
|
|
336
|
+
raise ValueError(f"Unknown TTS provider {provider_name!r}. Use 'openai'.")
|
|
337
|
+
config = TTSConfig(
|
|
338
|
+
voice=voice or "alloy",
|
|
339
|
+
sample_rate=self.config.output_sample_rate,
|
|
340
|
+
)
|
|
341
|
+
return TTSService(config, billing=self.billing)
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# Pipeline
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def build_pipeline(self, room_id: str) -> PipelineTask:
|
|
348
|
+
"""Assemble the pipeline for a room. Returns the task."""
|
|
349
|
+
participant = self.manager.register_agent_participant(self.config.name)
|
|
350
|
+
self._participant = participant
|
|
351
|
+
self.control.join_participant(
|
|
352
|
+
participant,
|
|
353
|
+
room_name=room_id,
|
|
354
|
+
max_participants=2,
|
|
355
|
+
create_if_missing=True,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
transport = RoomTransport(
|
|
359
|
+
self.manager,
|
|
360
|
+
participant,
|
|
361
|
+
params=TransportParams(
|
|
362
|
+
audio_in_sample_rate=self.config.input_sample_rate,
|
|
363
|
+
audio_out_sample_rate=self.config.output_sample_rate,
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
self._transport = transport
|
|
367
|
+
|
|
368
|
+
stt = self._build_stt()
|
|
369
|
+
user_agg = UserAggregator(self.context)
|
|
370
|
+
llm = self._build_llm()
|
|
371
|
+
assistant_agg = AssistantAggregator(self.context)
|
|
372
|
+
tts = self._build_tts()
|
|
373
|
+
vad = self._build_vad(transport.gate, transport.publish_user_speaking)
|
|
374
|
+
|
|
375
|
+
processors: list[FrameProcessor] = [transport.input()]
|
|
376
|
+
if vad is not None:
|
|
377
|
+
processors.append(vad)
|
|
378
|
+
processors.extend(
|
|
379
|
+
[
|
|
380
|
+
stt,
|
|
381
|
+
user_agg,
|
|
382
|
+
llm,
|
|
383
|
+
assistant_agg,
|
|
384
|
+
tts,
|
|
385
|
+
# The interruption handler sits after the TTS so it sees both
|
|
386
|
+
# the bot-speaking signals and the user's turn starts.
|
|
387
|
+
InterruptionHandler(enabled=self.config.allow_interruptions),
|
|
388
|
+
transport.output(),
|
|
389
|
+
]
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
task = PipelineTask(
|
|
393
|
+
processors,
|
|
394
|
+
PipelineParams(
|
|
395
|
+
allow_interruptions=self.config.allow_interruptions,
|
|
396
|
+
metadata={"agent": self.config.name, "session_id": self.session_id},
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
self._task = task
|
|
400
|
+
return task
|
|
401
|
+
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
# Running
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
async def serve(
|
|
407
|
+
self,
|
|
408
|
+
*,
|
|
409
|
+
room_id: str = "demo",
|
|
410
|
+
host: str = "0.0.0.0",
|
|
411
|
+
port: int = 8000,
|
|
412
|
+
serve: bool = True,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Run the agent. Blocks until interrupted."""
|
|
415
|
+
from .signaling.server import create_app
|
|
416
|
+
|
|
417
|
+
task = self.build_pipeline(room_id)
|
|
418
|
+
task.start()
|
|
419
|
+
|
|
420
|
+
app = create_app(self._server_with_manager())
|
|
421
|
+
|
|
422
|
+
if not serve:
|
|
423
|
+
await task.wait()
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
import uvicorn
|
|
427
|
+
|
|
428
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
429
|
+
self._server = uvicorn.Server(config)
|
|
430
|
+
log.info(
|
|
431
|
+
"nitpick agent %r listening on http://%s:%s (room: %s)",
|
|
432
|
+
self.config.name,
|
|
433
|
+
host,
|
|
434
|
+
port,
|
|
435
|
+
room_id,
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
await self._server.serve()
|
|
439
|
+
finally:
|
|
440
|
+
await task.cancel()
|
|
441
|
+
with contextlib.suppress(Exception):
|
|
442
|
+
await task.wait()
|
|
443
|
+
|
|
444
|
+
def _server_with_manager(self) -> Any:
|
|
445
|
+
from .signaling.server import SignalingServer
|
|
446
|
+
|
|
447
|
+
server = SignalingServer(
|
|
448
|
+
self.manager,
|
|
449
|
+
control=self.control,
|
|
450
|
+
audio_output_sample_rate=self.config.output_sample_rate,
|
|
451
|
+
)
|
|
452
|
+
return server
|
|
453
|
+
|
|
454
|
+
def run(
|
|
455
|
+
self,
|
|
456
|
+
*,
|
|
457
|
+
room_id: str = "demo",
|
|
458
|
+
host: str = "0.0.0.0",
|
|
459
|
+
port: int = 8000,
|
|
460
|
+
serve: bool = True,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Blocking convenience wrapper around :meth:`serve`."""
|
|
463
|
+
try:
|
|
464
|
+
asyncio.run(self.serve(room_id=room_id, host=host, port=port, serve=serve))
|
|
465
|
+
except KeyboardInterrupt:
|
|
466
|
+
print("\nbye")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Billing and usage tracking."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .collector import (
|
|
5
|
+
BillingCollector,
|
|
6
|
+
BillingStorage,
|
|
7
|
+
LogBillingStorage,
|
|
8
|
+
MemoryBillingStorage,
|
|
9
|
+
SessionBilling,
|
|
10
|
+
)
|
|
11
|
+
from .events import BillingEvent, TranscriptEntry, TranscriptRole
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BillingCollector",
|
|
15
|
+
"BillingEvent",
|
|
16
|
+
"BillingStorage",
|
|
17
|
+
"LogBillingStorage",
|
|
18
|
+
"MemoryBillingStorage",
|
|
19
|
+
"SessionBilling",
|
|
20
|
+
"TranscriptEntry",
|
|
21
|
+
"TranscriptRole",
|
|
22
|
+
]
|