pinecall-runtime 0.1.6__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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/session/call.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""What a call is while it runs, written once for a voice call and a written one."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from livekit.agents.utils import aio
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.agent import DEFAULT_LAYOUT, AgentConfig, EventSource, PromptBlock, ToolSpec
|
|
14
|
+
from pinecall.domain.call import CallContext
|
|
15
|
+
from pinecall.domain.errors import DeclarationRefused, NotAvailable
|
|
16
|
+
from pinecall.domain.names import JsonObject
|
|
17
|
+
from pinecall.session._hearing import policy_for
|
|
18
|
+
from pinecall.wire.commands import CallCallback, CallLog, SessionConfigure, StateSet, ToolsSet
|
|
19
|
+
from pinecall.wire.events import (
|
|
20
|
+
EPHEMERAL_EVENTS,
|
|
21
|
+
AgentConfigured,
|
|
22
|
+
CallbackRequested,
|
|
23
|
+
Custom,
|
|
24
|
+
EventReceived,
|
|
25
|
+
StateCause,
|
|
26
|
+
StateCauseEvent,
|
|
27
|
+
StateChanged,
|
|
28
|
+
ToolsChanged,
|
|
29
|
+
)
|
|
30
|
+
from pinecall.wire.frames import Entry, WireModel
|
|
31
|
+
from pinecall.wire.metrics import ModelUsage
|
|
32
|
+
from pinecall.wire.parts import AgentConfig as Declared
|
|
33
|
+
from pinecall.wire.parts import Contact, PlatformTool, Supervisor, ToolResult
|
|
34
|
+
from pinecall.wire.rest.calls import BatchedEntry
|
|
35
|
+
|
|
36
|
+
# What a session reaches outside the process its call runs in. In the gateway these are the
|
|
37
|
+
# call's own log and the doors behind it; in a worker, the same doors over the fleet's client.
|
|
38
|
+
# `append` is called as `append(kind, data, ephemeral=...)`, as `Log.append` is.
|
|
39
|
+
type Append = Callable[..., Awaitable[Entry]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Called as `append_many(entries, after=...)`, as `Log.append_many` is: the entries numbered, in
|
|
43
|
+
# the order they were sent.
|
|
44
|
+
type AppendMany = Callable[..., Awaitable[list[Entry]]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
type Lookup = Callable[[PlatformTool, JsonObject, str | None], Awaitable[JsonObject]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
type Seal = Callable[[list[ModelUsage], str], Awaitable[None]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# `sp_<n>`: numbered on from the highest in the log when a call is taken up again.
|
|
57
|
+
SPEECH = "sp_"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# A callback the agent took during a call; others come from the widget or an overflow.
|
|
61
|
+
BY_THE_AGENT = "agent"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
UNDECLARED = "event {name!r} is not one this agent declared the {source} may send"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# A limit under two minutes is told at its half instead.
|
|
68
|
+
WARNED_BEFORE_S = 60
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
CLOSING = (
|
|
72
|
+
"The call reaches its time limit in about a minute. Bring it to a close now: answer what is "
|
|
73
|
+
"pending in a sentence, tell the caller the call has to end soon, and say goodbye."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# What one request carries: whatever is queued when the last one was answered, up to this.
|
|
78
|
+
MOST_A_BATCH = 64
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Past this many waiting, an ephemeral entry is shed: no store keeps it. A durable one is always
|
|
82
|
+
# queued, so a gateway that was away never leaves a hole in the log.
|
|
83
|
+
MOST_QUEUED = 4096
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SHED = "call {call}: {queued} entries wait for the log, so {kind} was shed"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ToolUse:
|
|
91
|
+
"""One tool call the model made: its id, the tool, and the arguments it chose."""
|
|
92
|
+
|
|
93
|
+
call_id: str
|
|
94
|
+
name: str
|
|
95
|
+
arguments: JsonObject
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
type RunTool = Callable[[ToolUse, str | None], Awaitable[ToolResult]]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Writing:
|
|
102
|
+
"""The call's entries in the order they happened, sent to the log in batches."""
|
|
103
|
+
|
|
104
|
+
# livekit calls most listeners synchronously; one queue drained by one task keeps order. A
|
|
105
|
+
# writer that takes over a call another wrote to follows on from what the log took from it.
|
|
106
|
+
def __init__(self, append_many: AppendMany, call: str, *, after: int = 0) -> None:
|
|
107
|
+
"""An empty queue for the call, drained once `open` starts it."""
|
|
108
|
+
self.append_many = append_many
|
|
109
|
+
self.call = call
|
|
110
|
+
self.queued: asyncio.Queue[tuple[BatchedEntry, asyncio.Future[Entry]]] = asyncio.Queue()
|
|
111
|
+
self.draining: asyncio.Task[None] | None = None
|
|
112
|
+
# How many entries the log took from this call's writer: what the next batch follows.
|
|
113
|
+
self.after = after
|
|
114
|
+
self.refused: list[str] = []
|
|
115
|
+
self.shed: list[str] = []
|
|
116
|
+
|
|
117
|
+
def open(self) -> None:
|
|
118
|
+
"""Start sending."""
|
|
119
|
+
if self.draining is None:
|
|
120
|
+
self.draining = asyncio.create_task(self._drain())
|
|
121
|
+
|
|
122
|
+
# livekit calls most listeners synchronously: they queue and move on; a caller that needs
|
|
123
|
+
# the entry (its seq) awaits the future. The stamp is when it happened, not when it was sent.
|
|
124
|
+
def write(
|
|
125
|
+
self, kind: str, event: WireModel, *, ephemeral: bool | None = None
|
|
126
|
+
) -> asyncio.Future[Entry]:
|
|
127
|
+
"""Queue an entry; the future is the entry once the log holds it."""
|
|
128
|
+
written: asyncio.Future[Entry] = asyncio.get_running_loop().create_future()
|
|
129
|
+
forgettable = kind in EPHEMERAL_EVENTS if ephemeral is None else ephemeral
|
|
130
|
+
waiting = self.queued.qsize()
|
|
131
|
+
if forgettable and waiting >= MOST_QUEUED:
|
|
132
|
+
self.shed.append(kind)
|
|
133
|
+
_refused(written, NotAvailable(SHED.format(call=self.call, queued=waiting, kind=kind)))
|
|
134
|
+
return written
|
|
135
|
+
entry = BatchedEntry(type=kind, data=event.written(), ephemeral=ephemeral, ts=time.time())
|
|
136
|
+
self.queued.put_nowait((entry, written))
|
|
137
|
+
return written
|
|
138
|
+
|
|
139
|
+
async def flushed(self, within_s: float) -> None:
|
|
140
|
+
"""Wait until every queued entry was sent; TimeoutError past `within_s`."""
|
|
141
|
+
await asyncio.wait_for(self.queued.join(), within_s)
|
|
142
|
+
|
|
143
|
+
async def close(self, within_s: float) -> None:
|
|
144
|
+
"""Send what is queued for up to `within_s`, then stop and say what never went."""
|
|
145
|
+
try:
|
|
146
|
+
await self.flushed(within_s)
|
|
147
|
+
except TimeoutError:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"call %s: %d entries never reached the log within %gs",
|
|
150
|
+
self.call,
|
|
151
|
+
self.queued.qsize(),
|
|
152
|
+
within_s,
|
|
153
|
+
)
|
|
154
|
+
if self.draining is not None:
|
|
155
|
+
await aio.cancel_and_wait(self.draining)
|
|
156
|
+
self.draining = None
|
|
157
|
+
if self.refused:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"call %s: the log refused %d entries (%s)",
|
|
160
|
+
self.call,
|
|
161
|
+
len(self.refused),
|
|
162
|
+
", ".join(sorted(set(self.refused))),
|
|
163
|
+
)
|
|
164
|
+
if self.shed:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"call %s: %d ephemeral entries were shed at the queue's ceiling (%s)",
|
|
167
|
+
self.call,
|
|
168
|
+
len(self.shed),
|
|
169
|
+
", ".join(sorted(set(self.shed))),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# No timer: while one batch is out the next one fills, so an idle call sends each entry
|
|
173
|
+
# alone and a busy one batches itself.
|
|
174
|
+
async def _drain(self) -> None:
|
|
175
|
+
while True:
|
|
176
|
+
batch = [await self.queued.get()]
|
|
177
|
+
while len(batch) < MOST_A_BATCH and not self.queued.empty():
|
|
178
|
+
batch.append(self.queued.get_nowait())
|
|
179
|
+
try:
|
|
180
|
+
await self._sent(batch)
|
|
181
|
+
finally:
|
|
182
|
+
for _ in batch:
|
|
183
|
+
self.queued.task_done()
|
|
184
|
+
|
|
185
|
+
# A refused batch never ends the call: the client retries what is transient with the same
|
|
186
|
+
# `after`, so what fails here is a refusal of the whole batch, counted and named at close.
|
|
187
|
+
async def _sent(self, batch: list[tuple[BatchedEntry, asyncio.Future[Entry]]]) -> None:
|
|
188
|
+
kinds = [entry.type for entry, _ in batch]
|
|
189
|
+
try:
|
|
190
|
+
numbered = await self.append_many([entry for entry, _ in batch], after=self.after)
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
self.refused += kinds
|
|
193
|
+
raise
|
|
194
|
+
except Exception as refused:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"call %s: the log refused a batch of %d", self.call, len(batch), exc_info=True
|
|
197
|
+
)
|
|
198
|
+
self.refused += kinds
|
|
199
|
+
for _, written in batch:
|
|
200
|
+
if not written.done():
|
|
201
|
+
_refused(written, refused)
|
|
202
|
+
return
|
|
203
|
+
self.after += len(batch)
|
|
204
|
+
for (_, written), entry in zip(batch, numbered, strict=True):
|
|
205
|
+
if not written.done():
|
|
206
|
+
written.set_result(entry)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class Platform:
|
|
211
|
+
"""The log a call writes, the app's tools, the lookups, and the seal at the end."""
|
|
212
|
+
|
|
213
|
+
append_many: AppendMany
|
|
214
|
+
tool: RunTool
|
|
215
|
+
lookup: Lookup
|
|
216
|
+
seal: Seal
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Call:
|
|
220
|
+
"""One call's state that outlives a turn, and the entries the app's commands write."""
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
context: CallContext,
|
|
225
|
+
config: AgentConfig,
|
|
226
|
+
platform: Platform,
|
|
227
|
+
recording: Path | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""A call nobody has spoken on yet, every declared tool open."""
|
|
230
|
+
self.context = context
|
|
231
|
+
self.config = config
|
|
232
|
+
# Where its session records its audio: none for a written call or one the agent keeps none.
|
|
233
|
+
self.recording = recording
|
|
234
|
+
self.turn_policy = policy_for(config.language)
|
|
235
|
+
self.platform = platform
|
|
236
|
+
self.writing = Writing(platform.append_many, context.call)
|
|
237
|
+
self.speeches = 0
|
|
238
|
+
self.turns = 0
|
|
239
|
+
self.last_said = ""
|
|
240
|
+
# What the next state.set is answering: a running tool, or an event the app received.
|
|
241
|
+
self.cause: StateCause | None = None
|
|
242
|
+
self.taken_by: Supervisor | None = None
|
|
243
|
+
self.waiting_for_a_person = False
|
|
244
|
+
# As the session's agent_state events say, not as livekit's internals hold it.
|
|
245
|
+
self.agent_speaking = False
|
|
246
|
+
self.open_tools = frozenset(tool.name for tool in config.tools)
|
|
247
|
+
# Every request the model was sent, kept on a call an eval run opened and on no other.
|
|
248
|
+
self.requests: list[JsonObject] = []
|
|
249
|
+
|
|
250
|
+
def speech(self) -> str:
|
|
251
|
+
"""A new speech id, `sp_<n>`."""
|
|
252
|
+
self.speeches += 1
|
|
253
|
+
return f"{SPEECH}{self.speeches}"
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def a_person_has_the_line(self) -> bool:
|
|
257
|
+
"""Whether the model must not answer: a supervisor holds the line, or one is awaited."""
|
|
258
|
+
return self.taken_by is not None or self.waiting_for_a_person
|
|
259
|
+
|
|
260
|
+
async def set_state(self, wanted: StateSet) -> None:
|
|
261
|
+
"""The app's whole state, and what it answers when a tool or an event caused it."""
|
|
262
|
+
changed = wanted.changed if wanted.changed is not None else sorted(wanted.state)
|
|
263
|
+
cause, self.cause = self.cause, None
|
|
264
|
+
written = StateChanged(state=wanted.state, changed=changed)
|
|
265
|
+
if cause is not None:
|
|
266
|
+
written.cause = cause
|
|
267
|
+
await self.writing.write("state.changed", written)
|
|
268
|
+
|
|
269
|
+
async def receives(
|
|
270
|
+
self, name: str, data: JsonObject, *, source: EventSource, identity: str | None = None
|
|
271
|
+
) -> None:
|
|
272
|
+
"""An event the agent declared this sender may send, the cause of the next state.set."""
|
|
273
|
+
if not self.config.accepts(name, source):
|
|
274
|
+
raise DeclarationRefused(UNDECLARED.format(name=name, source=source))
|
|
275
|
+
received = EventReceived(name=name, data=data, source=source, identity=identity)
|
|
276
|
+
entry = await self.writing.write("event.received", received)
|
|
277
|
+
self.cause = StateCauseEvent(kind="event", name=name, seq=entry.seq)
|
|
278
|
+
|
|
279
|
+
async def log_custom(self, wanted: CallLog) -> None:
|
|
280
|
+
"""A line of the app's own, as it sent it."""
|
|
281
|
+
await self.writing.write("custom", Custom(name=wanted.name, data=wanted.data))
|
|
282
|
+
|
|
283
|
+
async def call_back(self, wanted: CallCallback) -> None:
|
|
284
|
+
"""A callback the agent took, in the call's log, where the callbacks door collects it."""
|
|
285
|
+
contact = self.context.contact
|
|
286
|
+
await self.writing.write(
|
|
287
|
+
"callback.requested",
|
|
288
|
+
CallbackRequested(
|
|
289
|
+
channel=self.context.channel,
|
|
290
|
+
number=wanted.number,
|
|
291
|
+
via=BY_THE_AGENT,
|
|
292
|
+
call=self.context.call,
|
|
293
|
+
contact=None if contact is None else Contact.model_validate(asdict(contact)),
|
|
294
|
+
when=wanted.when,
|
|
295
|
+
note=wanted.note,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# What the app sets up before the first turn; a declaration sent here lasts this call only.
|
|
300
|
+
async def configure(self, wanted: SessionConfigure) -> None:
|
|
301
|
+
"""The app's declaration and state for this call."""
|
|
302
|
+
if wanted.config is not None:
|
|
303
|
+
self.config = with_app_fields(self.config, wanted.config)
|
|
304
|
+
configured = AgentConfigured(changed=changed_by(wanted.config))
|
|
305
|
+
await self.writing.write("agent.configured", configured)
|
|
306
|
+
if wanted.state is not None:
|
|
307
|
+
await self.set_state(StateSet(state=wanted.state))
|
|
308
|
+
|
|
309
|
+
# Every tool stays declared for the whole call: changing what the provider is sent spends
|
|
310
|
+
# its prompt cache. The subset open now is enforced where a tool is run.
|
|
311
|
+
async def set_tools(self, wanted: ToolsSet) -> None:
|
|
312
|
+
"""Open only these of the declared tools; an empty list closes them all."""
|
|
313
|
+
declared = {tool.name for tool in self.config.tools}
|
|
314
|
+
self.open_tools = frozenset(tool.name for tool in wanted.tools if tool.name in declared)
|
|
315
|
+
await self.writing.write("tools.changed", ToolsChanged(visible=sorted(self.open_tools)))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# A declaration is a patch: only the fields sent change. What the org sets per world (the
|
|
319
|
+
# voice, the models, memory, the greeting) comes from its settings and is not taken from here.
|
|
320
|
+
def with_app_fields(current: AgentConfig, declared: Declared) -> AgentConfig:
|
|
321
|
+
"""The agent's declaration with the fields the app sent in place."""
|
|
322
|
+
sent = declared.model_fields_set
|
|
323
|
+
changed: dict[str, object] = {}
|
|
324
|
+
if "prompt" in sent:
|
|
325
|
+
changed["prompt"] = (
|
|
326
|
+
tuple(PromptBlock(block.name, block.region) for block in declared.prompt)
|
|
327
|
+
if declared.prompt
|
|
328
|
+
else DEFAULT_LAYOUT
|
|
329
|
+
)
|
|
330
|
+
if "language" in sent:
|
|
331
|
+
changed["language"] = declared.language
|
|
332
|
+
if "uses_knowledge" in sent:
|
|
333
|
+
changed["uses_knowledge"] = declared.uses_knowledge
|
|
334
|
+
if "tools" in sent:
|
|
335
|
+
changed["tools"] = tuple(
|
|
336
|
+
ToolSpec(
|
|
337
|
+
name=tool.name,
|
|
338
|
+
description=tool.description,
|
|
339
|
+
parameters=tool.parameters,
|
|
340
|
+
side_effect=tool.side_effect,
|
|
341
|
+
pii=frozenset(tool.pii or ()),
|
|
342
|
+
confirm=tool.confirm,
|
|
343
|
+
timeout_s=ToolSpec.timeout_s if tool.timeout_s is None else tool.timeout_s,
|
|
344
|
+
)
|
|
345
|
+
for tool in declared.tools or ()
|
|
346
|
+
)
|
|
347
|
+
if "state_fields" in sent:
|
|
348
|
+
changed["state_fields"] = {
|
|
349
|
+
item.name: item.visibility for item in declared.state_fields or ()
|
|
350
|
+
}
|
|
351
|
+
if "view" in sent:
|
|
352
|
+
changed["view"] = None if declared.view is None else declared.view.name
|
|
353
|
+
if "events" in sent:
|
|
354
|
+
changed["events"] = {item.name: frozenset(item.from_) for item in declared.events or ()}
|
|
355
|
+
return dataclasses.replace(current, **changed)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def changed_by(declared: Declared) -> list[str]:
|
|
359
|
+
"""The fields a declaration sets, sorted."""
|
|
360
|
+
return sorted(declared.model_fields_set)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# Most entries are queued and never awaited: read here, the refusal is not logged a second time
|
|
364
|
+
# by asyncio as an exception nobody retrieved. `close` names what was refused and what was shed.
|
|
365
|
+
def _refused(written: asyncio.Future[Entry], why: Exception) -> None:
|
|
366
|
+
written.set_exception(why)
|
|
367
|
+
written.exception()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""A call's time: its ceiling, warned before it ends, and a chat in a room ended when quiet."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from pinecall.session.call import CLOSING, WARNED_BEFORE_S
|
|
7
|
+
from pinecall.session.session import Session
|
|
8
|
+
from pinecall.wire import events as wire
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# The limit holds while a supervisor has the line; only the warning is skipped, since a
|
|
12
|
+
# generated turn would talk over them.
|
|
13
|
+
async def keep_time(
|
|
14
|
+
session: Session, limit_s: int, *, exhausted: wire.CreditsExhausted | None
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Warn the agent before the limit and end the call at it; nothing when there is none."""
|
|
17
|
+
if limit_s == 0:
|
|
18
|
+
return
|
|
19
|
+
warned_at = limit_s - WARNED_BEFORE_S if limit_s >= 2 * WARNED_BEFORE_S else limit_s / 2
|
|
20
|
+
await asyncio.sleep(warned_at)
|
|
21
|
+
if not session.call.a_person_has_the_line:
|
|
22
|
+
session.live.generate_reply(instructions=CLOSING)
|
|
23
|
+
await asyncio.sleep(limit_s - warned_at)
|
|
24
|
+
if exhausted is not None:
|
|
25
|
+
await session.call.writing.write("credits.exhausted", exhausted)
|
|
26
|
+
session.hang_up("timeout", "platform")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# A chat in a room has no ceiling, and a visitor who leaves the page open would hold its seat for
|
|
30
|
+
# ever (one did for over an hour on 2026-10-03): it ends once the person has said nothing for long.
|
|
31
|
+
async def end_when_quiet(session: Session, quiet_s: float) -> None:
|
|
32
|
+
"""End the call once the person has said nothing for `quiet_s`; nothing when zero."""
|
|
33
|
+
if quiet_s == 0:
|
|
34
|
+
return
|
|
35
|
+
while True:
|
|
36
|
+
left = quiet_s - (time.monotonic() - session.last_heard)
|
|
37
|
+
if left <= 0:
|
|
38
|
+
session.hang_up("timeout", "platform")
|
|
39
|
+
return
|
|
40
|
+
await asyncio.sleep(left)
|
pinecall/session/hold.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Hold music: the clip played into the room while the caller waits, faded in and out."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import av
|
|
11
|
+
from av.audio.fifo import AudioFifo
|
|
12
|
+
from av.audio.stream import AudioStream
|
|
13
|
+
from av.container import InputContainer, OutputContainer
|
|
14
|
+
from av.error import FFmpegError
|
|
15
|
+
from livekit import rtc
|
|
16
|
+
from livekit.agents.voice.background_audio import AudioConfig, BackgroundAudioPlayer, PlayHandle
|
|
17
|
+
|
|
18
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# The hold melody sits under the agent's voice, since a phone leg has no volume of its own. A
|
|
23
|
+
# tool that answers inside the grace plays nothing.
|
|
24
|
+
VOLUME = 0.6
|
|
25
|
+
|
|
26
|
+
GRACE_S = 2.5
|
|
27
|
+
|
|
28
|
+
FADE_IN_S = 0.4
|
|
29
|
+
|
|
30
|
+
FADE_OUT_S = 0.3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# livekit mixes at 48 kHz; mono like a phone leg; Opus for size, in whole frames only.
|
|
34
|
+
RATE = 48_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
OPUS_FRAME = 960
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
BIT_RATE = 64_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# The melody loops, so a long file only costs storage, and a very short one stutters.
|
|
44
|
+
LONGEST_S = 300
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SHORTEST_S = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
NOT_AUDIO = "that file is no audio this box can read: send a wav, an mp3, an ogg or an m4a"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# The containers a melody comes in, each by the bytes it starts with: FFmpeg is told which and
|
|
54
|
+
# probes nothing, so no playlist, script or path a file could carry is followed (an `m3u8` fetches
|
|
55
|
+
# URLs, an `ffconcat` opens files). The pair is (offset, magic) → the demuxer's name.
|
|
56
|
+
CONTAINERS: tuple[tuple[int, bytes, str], ...] = (
|
|
57
|
+
(0, b"RIFF", "wav"),
|
|
58
|
+
(0, b"OggS", "ogg"),
|
|
59
|
+
(0, b"fLaC", "flac"),
|
|
60
|
+
(4, b"ftyp", "mov,mp4,m4a,3gp,3g2,mj2"),
|
|
61
|
+
(0, b"ID3", "mp3"),
|
|
62
|
+
# An MPEG audio frame's sync with layer III: MPEG-1, MPEG-2 and MPEG-2.5, with and without CRC.
|
|
63
|
+
(0, b"\xff\xfb", "mp3"),
|
|
64
|
+
(0, b"\xff\xfa", "mp3"),
|
|
65
|
+
(0, b"\xff\xf3", "mp3"),
|
|
66
|
+
(0, b"\xff\xf2", "mp3"),
|
|
67
|
+
(0, b"\xff\xe3", "mp3"),
|
|
68
|
+
(0, b"\xff\xe2", "mp3"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Converted:
|
|
74
|
+
"""An upload as the worker plays it: Ogg Opus, 48 kHz mono, and how long it lasts."""
|
|
75
|
+
|
|
76
|
+
audio: bytes
|
|
77
|
+
seconds: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HoldMusic:
|
|
81
|
+
"""The melody played while a tool runs or the line is held, shared by what overlaps."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, clip: Path) -> None:
|
|
84
|
+
"""A melody not yet in any room."""
|
|
85
|
+
self.clip = clip
|
|
86
|
+
self.player: BackgroundAudioPlayer | None = None
|
|
87
|
+
self.handle: PlayHandle | None = None
|
|
88
|
+
self.running = 0
|
|
89
|
+
self.pending: asyncio.Task[None] | None = None
|
|
90
|
+
self.quiet = asyncio.Event()
|
|
91
|
+
self.quiet.set()
|
|
92
|
+
# When it sounded, by the clock, for the call's recording to lay it in: the player's track
|
|
93
|
+
# is the room's, not the session's, and the clip is already on this disk.
|
|
94
|
+
self.played: list[tuple[float, float]] = []
|
|
95
|
+
self.playing_since: float | None = None
|
|
96
|
+
|
|
97
|
+
# The player publishes its own track, which the SIP bridge mixes into the phone leg. A
|
|
98
|
+
# player that does not start leaves the call without a melody, and nothing else.
|
|
99
|
+
async def start(self, room: rtc.Room) -> None:
|
|
100
|
+
"""Publish the melody's track in the room."""
|
|
101
|
+
player = BackgroundAudioPlayer()
|
|
102
|
+
try:
|
|
103
|
+
await player.start(room=room) # pyright: ignore[reportUnknownMemberType]
|
|
104
|
+
except (RuntimeError, ConnectionError):
|
|
105
|
+
logger.warning(
|
|
106
|
+
"the hold melody did not start: the call goes on without it", exc_info=True
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
self.player = player
|
|
110
|
+
|
|
111
|
+
def floor(self, *, speaking: bool) -> None:
|
|
112
|
+
"""Whether the agent is speaking, as the session says."""
|
|
113
|
+
if speaking:
|
|
114
|
+
self.quiet.clear()
|
|
115
|
+
else:
|
|
116
|
+
self.quiet.set()
|
|
117
|
+
|
|
118
|
+
def began(self) -> None:
|
|
119
|
+
"""One more reason to play; the first starts the melody after the grace."""
|
|
120
|
+
self.running += 1
|
|
121
|
+
if self.running == 1 and self.player is not None:
|
|
122
|
+
self.pending = asyncio.create_task(self._after_the_grace())
|
|
123
|
+
|
|
124
|
+
def ended(self) -> None:
|
|
125
|
+
"""One reason less; the last stops the melody."""
|
|
126
|
+
self.running = max(self.running - 1, 0)
|
|
127
|
+
if self.running == 0:
|
|
128
|
+
self._stop()
|
|
129
|
+
|
|
130
|
+
# Nothing stops the melody when the caller hangs up on hold: the player goes with the room.
|
|
131
|
+
def sounded(self) -> list[tuple[float, float]]:
|
|
132
|
+
"""When the melody played, by the clock; the one still playing counted up to now."""
|
|
133
|
+
if self.playing_since is None:
|
|
134
|
+
return self.played
|
|
135
|
+
return [*self.played, (self.playing_since, time.time())]
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
"""Stop and close the player."""
|
|
139
|
+
self._stop()
|
|
140
|
+
player, self.player = self.player, None
|
|
141
|
+
if player is not None:
|
|
142
|
+
await player.aclose()
|
|
143
|
+
|
|
144
|
+
# The tool starts while its announcement still plays, so the grace counts from when the agent
|
|
145
|
+
# is quiet; checked again after it, since livekit may speak another round's preamble.
|
|
146
|
+
async def _after_the_grace(self) -> None:
|
|
147
|
+
await self.quiet.wait()
|
|
148
|
+
await asyncio.sleep(GRACE_S)
|
|
149
|
+
if self.running == 0 or not self.quiet.is_set() or self.player is None:
|
|
150
|
+
return
|
|
151
|
+
melody = AudioConfig(str(self.clip), volume=VOLUME, fade_in=FADE_IN_S, fade_out=FADE_OUT_S)
|
|
152
|
+
self.handle = self.player.play(melody, loop=True)
|
|
153
|
+
self.playing_since = time.time()
|
|
154
|
+
|
|
155
|
+
def _stop(self) -> None:
|
|
156
|
+
if self.pending is not None and not self.pending.done():
|
|
157
|
+
self.pending.cancel()
|
|
158
|
+
self.pending = None
|
|
159
|
+
if self.handle is not None:
|
|
160
|
+
self.handle.stop()
|
|
161
|
+
self.handle = None
|
|
162
|
+
if self.playing_since is not None:
|
|
163
|
+
self.played.append((self.playing_since, time.time()))
|
|
164
|
+
self.playing_since = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
TOO_LONG = f"a hold melody loops, so {LONGEST_S // 60} minutes at most: this one runs longer"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
TOO_SHORT = f"a hold melody of less than {SHORTEST_S} second would stutter as it loops"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Converted once, here, so no worker decodes a tenant's upload in the middle of a call.
|
|
174
|
+
def converted(data: bytes) -> Converted:
|
|
175
|
+
"""Audio in a container this box names, as the melody the worker plays; refused in words."""
|
|
176
|
+
container = container_of(data)
|
|
177
|
+
if container is None:
|
|
178
|
+
raise DeclarationRefused(NOT_AUDIO)
|
|
179
|
+
try:
|
|
180
|
+
source = av.open(io.BytesIO(data), mode="r", format=container)
|
|
181
|
+
except (FFmpegError, OSError, ValueError) as unreadable:
|
|
182
|
+
raise DeclarationRefused(NOT_AUDIO) from unreadable
|
|
183
|
+
with source:
|
|
184
|
+
if not source.streams.audio:
|
|
185
|
+
raise DeclarationRefused(NOT_AUDIO)
|
|
186
|
+
try:
|
|
187
|
+
audio, samples = _encoded(source)
|
|
188
|
+
except (FFmpegError, OSError, ValueError) as unreadable:
|
|
189
|
+
raise DeclarationRefused(NOT_AUDIO) from unreadable
|
|
190
|
+
seconds = samples / RATE
|
|
191
|
+
if seconds < SHORTEST_S:
|
|
192
|
+
raise DeclarationRefused(TOO_SHORT)
|
|
193
|
+
return Converted(audio=audio, seconds=round(seconds, 2))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def container_of(data: bytes) -> str | None:
|
|
197
|
+
"""The demuxer the file's first bytes name; None for a container this box does not read."""
|
|
198
|
+
for offset, magic, named in CONTAINERS:
|
|
199
|
+
if data[offset : offset + len(magic)] == magic:
|
|
200
|
+
return named
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Opus takes whole frames only: a tail shorter than one is dropped.
|
|
205
|
+
def drained(fifo: AudioFifo, stream: AudioStream, sink: OutputContainer, *, final: bool) -> None:
|
|
206
|
+
"""Encode what the fifo holds in whole Opus frames; at the end, the tail that is one."""
|
|
207
|
+
while fifo.samples >= OPUS_FRAME or (final and fifo.samples > 0):
|
|
208
|
+
frame = fifo.read(min(OPUS_FRAME, fifo.samples))
|
|
209
|
+
if frame is None or frame.samples < OPUS_FRAME:
|
|
210
|
+
return
|
|
211
|
+
for packet in stream.encode(frame): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
212
|
+
sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _encoded(source: InputContainer) -> tuple[bytes, int]:
|
|
216
|
+
out = io.BytesIO()
|
|
217
|
+
resampler = av.AudioResampler(format="s16", layout="mono", rate=RATE)
|
|
218
|
+
fifo = av.AudioFifo()
|
|
219
|
+
samples = 0
|
|
220
|
+
with av.open(out, mode="w", format="ogg") as sink:
|
|
221
|
+
stream: AudioStream = sink.add_stream("libopus", rate=RATE) # pyright: ignore[reportUnknownMemberType]
|
|
222
|
+
stream.layout = "mono"
|
|
223
|
+
stream.bit_rate = BIT_RATE
|
|
224
|
+
for decoded in source.decode(audio=0):
|
|
225
|
+
for frame in resampler.resample(decoded):
|
|
226
|
+
samples += frame.samples
|
|
227
|
+
if samples > LONGEST_S * RATE:
|
|
228
|
+
raise DeclarationRefused(TOO_LONG)
|
|
229
|
+
frame.pts = None
|
|
230
|
+
fifo.write(frame)
|
|
231
|
+
drained(fifo, stream, sink, final=False)
|
|
232
|
+
for frame in resampler.resample(None):
|
|
233
|
+
samples += frame.samples
|
|
234
|
+
frame.pts = None
|
|
235
|
+
fifo.write(frame)
|
|
236
|
+
drained(fifo, stream, sink, final=True)
|
|
237
|
+
for packet in stream.encode(None): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
238
|
+
sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
|
|
239
|
+
return out.getvalue(), samples
|