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.
Files changed (48) hide show
  1. nitpick/__init__.py +136 -0
  2. nitpick/__main__.py +6 -0
  3. nitpick/agent.py +466 -0
  4. nitpick/billing/__init__.py +22 -0
  5. nitpick/billing/collector.py +228 -0
  6. nitpick/billing/events.py +104 -0
  7. nitpick/cli.py +79 -0
  8. nitpick/context.py +153 -0
  9. nitpick/frames/__init__.py +92 -0
  10. nitpick/frames/frames.py +268 -0
  11. nitpick/frames/kinds.py +164 -0
  12. nitpick/frames/processor.py +361 -0
  13. nitpick/pipeline/__init__.py +7 -0
  14. nitpick/pipeline/pipeline.py +53 -0
  15. nitpick/pipeline/task.py +294 -0
  16. nitpick/processors/assistant_aggregator.py +84 -0
  17. nitpick/processors/interruption.py +86 -0
  18. nitpick/processors/user_aggregator.py +102 -0
  19. nitpick/rooms/__init__.py +49 -0
  20. nitpick/rooms/control.py +207 -0
  21. nitpick/rooms/manager.py +419 -0
  22. nitpick/rooms/model.py +142 -0
  23. nitpick/sdk/__init__.py +6 -0
  24. nitpick/sdk/client.py +285 -0
  25. nitpick/services/__init__.py +6 -0
  26. nitpick/services/llm/__init__.py +6 -0
  27. nitpick/services/llm/openai.py +332 -0
  28. nitpick/services/stt/__init__.py +16 -0
  29. nitpick/services/stt/base.py +252 -0
  30. nitpick/services/stt/deepgram.py +120 -0
  31. nitpick/services/stt/openai.py +102 -0
  32. nitpick/services/stt/sarvam.py +202 -0
  33. nitpick/services/tts/__init__.py +6 -0
  34. nitpick/services/tts/openai.py +233 -0
  35. nitpick/signaling/server.py +560 -0
  36. nitpick/transport/__init__.py +436 -0
  37. nitpick/transport/webrtc.py +311 -0
  38. nitpick/vad/__init__.py +27 -0
  39. nitpick/vad/backends.py +337 -0
  40. nitpick/vad/core.py +225 -0
  41. nitpick/vad/processor.py +144 -0
  42. nitpick/web/index.html +482 -0
  43. nitpick_voice-0.2.0.dist-info/METADATA +434 -0
  44. nitpick_voice-0.2.0.dist-info/RECORD +48 -0
  45. nitpick_voice-0.2.0.dist-info/WHEEL +5 -0
  46. nitpick_voice-0.2.0.dist-info/entry_points.txt +3 -0
  47. nitpick_voice-0.2.0.dist-info/licenses/LICENSE +25 -0
  48. 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
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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
+ ]