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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""What livekit hands the session, read as ours: usage, transcripts, blocks, names, switches."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from livekit.agents import (
|
|
8
|
+
NOT_GIVEN,
|
|
9
|
+
APIStatusError,
|
|
10
|
+
NotGivenOr,
|
|
11
|
+
StopResponse,
|
|
12
|
+
llm,
|
|
13
|
+
metrics,
|
|
14
|
+
stt,
|
|
15
|
+
tts,
|
|
16
|
+
utils,
|
|
17
|
+
)
|
|
18
|
+
from livekit.agents.metrics.base import AvatarMetrics
|
|
19
|
+
from livekit.agents.types import TimedString
|
|
20
|
+
from livekit.agents.voice import AgentSession
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from pinecall.providers.build import Modality
|
|
24
|
+
from pinecall.session.call import Writing
|
|
25
|
+
from pinecall.wire import events as wire
|
|
26
|
+
from pinecall.wire import metrics as measured
|
|
27
|
+
from pinecall.wire.frames import WireModel
|
|
28
|
+
|
|
29
|
+
type Adapter = llm.FallbackAdapter | stt.FallbackAdapter | tts.FallbackAdapter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Errors of the request itself; 408, 429 and 5xx are transient and livekit retries them.
|
|
33
|
+
FOREVER = frozenset({400, 401, 402, 403, 404, 422})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# The WebSocket close for a policy violation: a vendor that refuses a voice or a key sends it,
|
|
37
|
+
# and livekit's 4xx check misses it.
|
|
38
|
+
POLICY_VIOLATION = 1008
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Each block is read by the wire's fields, under livekit's own names.
|
|
42
|
+
BLOCKS: tuple[tuple[type[metrics.AgentMetrics], str, type[WireModel]], ...] = (
|
|
43
|
+
(metrics.LLMMetrics, "metrics.llm", measured.LLMMetrics),
|
|
44
|
+
(metrics.STTMetrics, "metrics.stt", measured.STTMetrics),
|
|
45
|
+
(metrics.TTSMetrics, "metrics.tts", measured.TTSMetrics),
|
|
46
|
+
(metrics.VADMetrics, "metrics.vad", measured.VADMetrics),
|
|
47
|
+
(metrics.EOUMetrics, "metrics.eou", measured.EOUMetrics),
|
|
48
|
+
(metrics.InterruptionMetrics, "metrics.interruption", measured.InterruptionMetrics),
|
|
49
|
+
(metrics.RealtimeModelMetrics, "metrics.realtime", measured.RealtimeModelMetrics),
|
|
50
|
+
(metrics.EOTInferenceMetrics, "metrics.eot", measured.EOTInferenceMetrics),
|
|
51
|
+
(AvatarMetrics, "metrics.avatar", measured.AvatarMetrics),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def given_or_unset[T](value: T | None) -> NotGivenOr[T]:
|
|
56
|
+
"""The value, or livekit's NOT_GIVEN when it is None."""
|
|
57
|
+
return NOT_GIVEN if value is None else value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def transcript_of(live: AgentSession[None], delta: str | TimedString) -> wire.AgentTranscript:
|
|
61
|
+
"""What the agent said, as the wire's transcript entry, with the word timings livekit gave."""
|
|
62
|
+
speech = live.current_speech.id if live.current_speech else ""
|
|
63
|
+
timed: dict[str, float] = {}
|
|
64
|
+
if isinstance(delta, TimedString):
|
|
65
|
+
if utils.is_given(delta.start_time):
|
|
66
|
+
timed["start"] = delta.start_time
|
|
67
|
+
if utils.is_given(delta.end_time):
|
|
68
|
+
timed["end"] = delta.end_time
|
|
69
|
+
return wire.AgentTranscript(speech_id=speech, text=str(delta), final=False, **timed)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# interrupt() raises when nothing is playing or the session has stopped.
|
|
73
|
+
async def silence(live: AgentSession[None]) -> None:
|
|
74
|
+
"""Stop the agent's speech now, and wait for livekit to say it stopped."""
|
|
75
|
+
with contextlib.suppress(RuntimeError):
|
|
76
|
+
await live.interrupt(force=True)
|
|
77
|
+
live.output.set_audio_enabled(False)
|
|
78
|
+
live.input.set_audio_enabled(False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# The ears first, then the voice, so the agent never speaks before it can hear.
|
|
82
|
+
def hearing_again(live: AgentSession[None]) -> None:
|
|
83
|
+
"""Let the caller's audio reach the ears again after a pause."""
|
|
84
|
+
live.input.set_audio_enabled(True)
|
|
85
|
+
live.output.set_audio_enabled(True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def nothing_after(_: llm.Toolset.ToolCompletedEvent) -> None:
|
|
89
|
+
"""Speak nothing after a tool finished: the app speaks when it wants to."""
|
|
90
|
+
raise StopResponse
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def forever(error: APIStatusError) -> bool:
|
|
94
|
+
"""Whether a vendor's HTTP error will fail the same way every time it is retried."""
|
|
95
|
+
return error.status_code == POLICY_VIOLATION or error.status_code in FOREVER
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def usage_of(used: metrics.ModelUsage) -> measured.ModelUsage:
|
|
99
|
+
"""The usage of one model livekit measured, as the wire writes it."""
|
|
100
|
+
match used:
|
|
101
|
+
case metrics.LLMModelUsage():
|
|
102
|
+
return _read_as(measured.LLMModelUsage, used)
|
|
103
|
+
case metrics.TTSModelUsage():
|
|
104
|
+
return _read_as(measured.TTSModelUsage, used)
|
|
105
|
+
case metrics.STTModelUsage():
|
|
106
|
+
return _read_as(measured.STTModelUsage, used)
|
|
107
|
+
case metrics.InterruptionModelUsage():
|
|
108
|
+
return _read_as(measured.InterruptionModelUsage, used)
|
|
109
|
+
case _:
|
|
110
|
+
return _read_as(measured.EOTModelUsage, used)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def block_of(block: metrics.AgentMetrics) -> tuple[str, WireModel] | None:
|
|
114
|
+
"""The metrics livekit measured for one step, as the entry type and model the log keeps."""
|
|
115
|
+
for livekits, kind, ours in BLOCKS:
|
|
116
|
+
if isinstance(block, livekits):
|
|
117
|
+
return kind, _read_as(ours, block)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# livekit emits the end-of-utterance block on the session only, so it is built again from the
|
|
122
|
+
# user turn's report, as livekit builds it.
|
|
123
|
+
def end_of_utterance(report: Mapping[str, object], speech: str) -> measured.EOUMetrics | None:
|
|
124
|
+
"""The end-of-turn metrics livekit reports, as the wire's; None when a field is missing."""
|
|
125
|
+
delays = ("end_of_turn_delay", "transcription_delay", "on_user_turn_completed_delay")
|
|
126
|
+
if not any(key in report for key in delays):
|
|
127
|
+
return None
|
|
128
|
+
# Said, or written() drops the default and the SDKs refuse the entry.
|
|
129
|
+
return measured.EOUMetrics.model_validate(
|
|
130
|
+
{
|
|
131
|
+
"type": "eou_metrics",
|
|
132
|
+
"timestamp": time.time(),
|
|
133
|
+
"end_of_utterance_delay": report.get("end_of_turn_delay", 0.0),
|
|
134
|
+
"transcription_delay": report.get("transcription_delay", 0.0),
|
|
135
|
+
"on_user_turn_completed_delay": report.get("on_user_turn_completed_delay", 0.0),
|
|
136
|
+
"speech_id": speech,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# livekit's adapter says when a vendor goes down or comes back (`<stage>_availability_changed`,
|
|
142
|
+
# the vendor under the stage's own name), and names the next to serve. Its emitter is untyped:
|
|
143
|
+
# it is read as an object.
|
|
144
|
+
def switches_written(writing: Writing, built: Iterable[object]) -> None:
|
|
145
|
+
"""Write vendor.switched each time a stage's fallback adapter loses or regains a vendor."""
|
|
146
|
+
for component in built:
|
|
147
|
+
match component:
|
|
148
|
+
case llm.FallbackAdapter():
|
|
149
|
+
stage: Modality = "llm"
|
|
150
|
+
case stt.FallbackAdapter():
|
|
151
|
+
stage = "stt"
|
|
152
|
+
case tts.FallbackAdapter():
|
|
153
|
+
stage = "tts"
|
|
154
|
+
case _:
|
|
155
|
+
continue
|
|
156
|
+
listen: object = getattr(component, "on", None)
|
|
157
|
+
if callable(listen):
|
|
158
|
+
listen(f"{stage}_availability_changed", _switch(writing, stage, component))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def switch_of(stage: Modality, event: object, adapter: Adapter) -> wire.VendorSwitched | None:
|
|
162
|
+
"""The entry for one vendor of a stage lost or back, and the one serving from now."""
|
|
163
|
+
changed: object = getattr(event, stage, None)
|
|
164
|
+
available: object = getattr(event, "available", None)
|
|
165
|
+
if not isinstance(changed, (llm.LLM, stt.STT, tts.TTS)) or not isinstance(available, bool):
|
|
166
|
+
return None
|
|
167
|
+
return wire.VendorSwitched(
|
|
168
|
+
stage=stage,
|
|
169
|
+
vendor=changed.provider,
|
|
170
|
+
model=changed.model,
|
|
171
|
+
available=available,
|
|
172
|
+
serving=adapter.provider,
|
|
173
|
+
serving_model=adapter.model,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# livekit's rows carry the wire's names, and livekit adds fields the wire does not know
|
|
178
|
+
# (`input_audio_tokens` on the ears' usage in 1.8.3). The wire refuses an unknown key, and a
|
|
179
|
+
# listener's exception is swallowed by livekit's emitter, so the call would lose its usage in
|
|
180
|
+
# silence: each row is read by the fields its wire model declares.
|
|
181
|
+
def _read_as[T: WireModel](model: type[T], livekits: BaseModel) -> T:
|
|
182
|
+
"""The wire model read from livekit's, field by field, so a field livekit grew is left out."""
|
|
183
|
+
dumped = livekits.model_dump()
|
|
184
|
+
return model.model_validate(
|
|
185
|
+
{name: dumped[name] for name in model.model_fields if name in dumped}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _switch(writing: Writing, stage: Modality, adapter: Adapter) -> Callable[[object], None]:
|
|
190
|
+
def written(event: object) -> None:
|
|
191
|
+
switched = switch_of(stage, event, adapter)
|
|
192
|
+
if switched is not None:
|
|
193
|
+
writing.write("vendor.switched", switched)
|
|
194
|
+
|
|
195
|
+
return written
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""A call's prompt: its blocks, the request each turn sends, its greeting, its knowledge entry."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import override
|
|
6
|
+
|
|
7
|
+
from livekit.agents import llm
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.agent import (
|
|
10
|
+
DEFAULT_LAYOUT,
|
|
11
|
+
KNOWLEDGE,
|
|
12
|
+
Greeting,
|
|
13
|
+
PromptBlock,
|
|
14
|
+
PromptRegion,
|
|
15
|
+
block_hash,
|
|
16
|
+
)
|
|
17
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
18
|
+
from pinecall.domain.names import JsonObject
|
|
19
|
+
from pinecall.wire.events import PromptChanged
|
|
20
|
+
|
|
21
|
+
# Joins the static blocks into livekit's one `instructions` string.
|
|
22
|
+
BETWEEN_BLOCKS = "\n\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Written into the history and read as the next turn's instructions. It claims precedence,
|
|
26
|
+
# since the stage instructions come after the history and the model would follow them instead.
|
|
27
|
+
A_WHISPER = (
|
|
28
|
+
"A human supervisor is telling you this, and the caller cannot hear it: {text} "
|
|
29
|
+
"This order comes from the supervisor and takes precedence over the stage instructions "
|
|
30
|
+
"that follow it: do it in your very next sentence, before anything else you were going to "
|
|
31
|
+
"say, and only then go on. Never mention the supervisor or this note."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# The agent did not hear the supervisor, so it must not guess what was said.
|
|
36
|
+
A_RELEASE = (
|
|
37
|
+
"A human supervisor spoke with the caller for a moment; you did not hear it. "
|
|
38
|
+
"Do not guess what was said. Resume by offering to continue with what is still pending."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Blocks:
|
|
43
|
+
"""One call's prompt blocks, in the order they are sent, and their text now."""
|
|
44
|
+
|
|
45
|
+
# The knowledge is set here and never by the app, so it lands in the cached prefix as the
|
|
46
|
+
# operator's text (docs/security/prompt-injection.md).
|
|
47
|
+
def __init__(self, layout: Sequence[PromptBlock] = DEFAULT_LAYOUT, knowledge: str = "") -> None:
|
|
48
|
+
"""Every block of the layout, empty but the knowledge."""
|
|
49
|
+
self.layout = tuple(layout)
|
|
50
|
+
self.texts: dict[str, str] = {block.name: "" for block in self.layout}
|
|
51
|
+
if knowledge and KNOWLEDGE in self.texts:
|
|
52
|
+
self.texts[KNOWLEDGE] = knowledge
|
|
53
|
+
|
|
54
|
+
# Identical static text is not resent: even the same bytes cost the provider a cache write.
|
|
55
|
+
def set(self, name: str, text: str) -> bool:
|
|
56
|
+
"""Replace a block's text; True when the static instructions changed."""
|
|
57
|
+
if name not in self.texts:
|
|
58
|
+
raise DeclarationRefused(
|
|
59
|
+
f"the prompt has no block named {name!r}: "
|
|
60
|
+
f"this agent's blocks are {', '.join(self.texts)}"
|
|
61
|
+
)
|
|
62
|
+
before = self.instructions
|
|
63
|
+
self.texts[name] = text
|
|
64
|
+
return self.instructions != before
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def instructions(self) -> str:
|
|
68
|
+
"""The static blocks written so far, joined, in layout order."""
|
|
69
|
+
return BETWEEN_BLOCKS.join(self.of("static"))
|
|
70
|
+
|
|
71
|
+
def of(self, region: PromptRegion) -> tuple[str, ...]:
|
|
72
|
+
"""A region's blocks that have text, in layout order."""
|
|
73
|
+
return tuple(
|
|
74
|
+
self.texts[block.name]
|
|
75
|
+
for block in self.layout
|
|
76
|
+
if block.region == region and self.texts[block.name]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Request(llm.ChatContext):
|
|
81
|
+
"""One turn's request, whose static blocks reach the provider one system block each."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, items: list[llm.ChatItem], static: tuple[str, ...]) -> None:
|
|
84
|
+
"""The request's items, and the static blocks it keeps apart."""
|
|
85
|
+
super().__init__(items)
|
|
86
|
+
self.static = static
|
|
87
|
+
|
|
88
|
+
# livekit's formatters keep only the first system item as the preamble
|
|
89
|
+
# (_provider_format/utils.py); a format that carries system messages apart (the one that
|
|
90
|
+
# caches up to its last block among them) gets one per static block instead.
|
|
91
|
+
@override
|
|
92
|
+
def to_provider_format( # pyright: ignore[reportIncompatibleMethodOverride]
|
|
93
|
+
self, format: str, **kwargs: object
|
|
94
|
+
) -> tuple[list[dict[str, object]], object]:
|
|
95
|
+
"""The request as the provider's formatter builds it, the static blocks kept apart."""
|
|
96
|
+
formatted = super().to_provider_format(format, **kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
97
|
+
if self.static and hasattr(formatted[1], "system_messages"):
|
|
98
|
+
formatted[1].system_messages = list(self.static)
|
|
99
|
+
return formatted # pyright: ignore[reportUnknownVariableType]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Always a new context: livekit reuses its own across a turn's tool steps, so additions would
|
|
103
|
+
# pile up. The order is the one prompt-injection.md states: static blocks, history, the view.
|
|
104
|
+
def request(
|
|
105
|
+
history: llm.ChatContext, blocks: Blocks, lookups: Sequence[llm.ChatItem] = ()
|
|
106
|
+
) -> Request:
|
|
107
|
+
"""One request: the history with this turn's lookups spliced in, then the dynamic blocks."""
|
|
108
|
+
items = _ahead_of_the_caller(history.items, lookups)
|
|
109
|
+
items += [llm.ChatMessage(role="system", content=[text]) for text in blocks.of("dynamic")]
|
|
110
|
+
return Request(items, blocks.of("static"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# The log keeps the prompt's hashes alone; a run keeps what was sent, to reproduce a failure.
|
|
114
|
+
def as_asked(sent: Request, tools: Sequence[llm.Tool]) -> JsonObject:
|
|
115
|
+
"""One request as a failing golden reproduces it: the static blocks, the tools, the items."""
|
|
116
|
+
schemas = llm.ToolContext(list(tools)).parse_function_tools("openai")
|
|
117
|
+
items: object = sent.to_dict().get("items", [])
|
|
118
|
+
return json.loads(
|
|
119
|
+
json.dumps({"system": list(sent.static), "tools": schemas, "messages": items})
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# An eval run starts in the middle of a conversation, so it never greets.
|
|
124
|
+
def greeting_for(greeting: Greeting | None, run: str | None) -> Greeting | None:
|
|
125
|
+
"""The declared greeting, or none when an eval run opened the call."""
|
|
126
|
+
return None if run is not None else greeting
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# The app never sends prompt.set for this block, so without this entry the log would not show
|
|
130
|
+
# that the file reached the model.
|
|
131
|
+
def knowledge_changed(blocks: Blocks) -> PromptChanged | None:
|
|
132
|
+
"""The entry saying the knowledge file reached the prompt, when there is one."""
|
|
133
|
+
text = blocks.texts.get(KNOWLEDGE, "")
|
|
134
|
+
return PromptChanged(name=KNOWLEDGE, hash=block_hash(text), chars=len(text)) if text else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Lookups go before the caller's newest message (livekit appends it after
|
|
138
|
+
# on_user_turn_completed); placed after it, models skip the next tool. They are spliced into
|
|
139
|
+
# the request only, never into the history. No trailing user message (a tool step, agent.reply):
|
|
140
|
+
# they go at the end.
|
|
141
|
+
def _ahead_of_the_caller(
|
|
142
|
+
items: Sequence[llm.ChatItem], lookups: Sequence[llm.ChatItem]
|
|
143
|
+
) -> list[llm.ChatItem]:
|
|
144
|
+
last = items[-1] if items else None
|
|
145
|
+
if not lookups or not (isinstance(last, llm.ChatMessage) and last.role == "user"):
|
|
146
|
+
return [*items, *lookups]
|
|
147
|
+
return [*items[:-1], *lookups, last]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""A call's audio recorded by its own session: the caller left, every other voice right."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import av
|
|
11
|
+
import numpy as np
|
|
12
|
+
from av.audio.stream import AudioStream
|
|
13
|
+
from livekit import rtc
|
|
14
|
+
from livekit.agents import AgentSession
|
|
15
|
+
from livekit.agents.voice.recorder_io import RecorderIO
|
|
16
|
+
|
|
17
|
+
from pinecall.session.hold import VOLUME, HoldMusic, drained
|
|
18
|
+
from pinecall.session.room import CallRoom
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# livekit's recorder wraps the session's audio in and out and places both on one timeline: the
|
|
23
|
+
# caller on the left, the agent on the right, encoded in a thread of this process. Egress did the
|
|
24
|
+
# same work in a process of its own per call (~0.12 vCPU, ~80 MB), and a track egress per voice
|
|
25
|
+
# costs more still (~0.1 vCPU and ~170 MB a track, the load lab 2026-10-02): the voices are already
|
|
26
|
+
# decoded here, so the recording is made here. A supervisor who takes over and the far end of a
|
|
27
|
+
# transfer are the room's, not the session's: each is heard from its own track while it speaks and
|
|
28
|
+
# spooled beside the file, and with the hold melody (from its clip, where it sounded) they are laid
|
|
29
|
+
# in on the right when the file is closed: the file decoded and encoded once more, a stretch at a
|
|
30
|
+
# time. That costs about a fiftieth of the call's length on one core (ten minutes in ~13 s on a
|
|
31
|
+
# laptop's), within the worker's sealing budget for the calls a phone line takes; a call with no
|
|
32
|
+
# other voice and no hold is never touched again. The session closes the recorder before the seal,
|
|
33
|
+
# so the file is whole when the summary names it.
|
|
34
|
+
|
|
35
|
+
# The agent's voice comes at 24 kHz and a phone at 8: 48 keeps nothing more (~10 % of a worker's
|
|
36
|
+
# cores less, the load lab).
|
|
37
|
+
RATE = 24000
|
|
38
|
+
|
|
39
|
+
# Opus takes whole frames of 20 ms (480 samples at RATE).
|
|
40
|
+
OPUS_FRAME = 480
|
|
41
|
+
|
|
42
|
+
# A sum of voices can pass full scale; it is clipped, not wrapped.
|
|
43
|
+
FULL_SCALE = 1.0
|
|
44
|
+
|
|
45
|
+
# Who in the room speaks into the recording besides the session's own two.
|
|
46
|
+
OTHERS = frozenset({"supervisor", "sip"})
|
|
47
|
+
|
|
48
|
+
LOST = "a voice in the room of %s is not in its recording"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Sound:
|
|
53
|
+
"""One sound laid on the right: the sample it starts at, how many, and any stretch of it."""
|
|
54
|
+
|
|
55
|
+
start: int
|
|
56
|
+
length: int
|
|
57
|
+
stretch: Callable[[int, int], np.ndarray]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Recorder:
|
|
61
|
+
"""The call's file: livekit's recorder for its two sides, the room's other voices beside."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, recorder: RecorderIO, audio: Path, where: CallRoom | None) -> None:
|
|
64
|
+
"""A recording that started now and heard nobody else yet."""
|
|
65
|
+
self.recorder = recorder
|
|
66
|
+
self.audio = audio
|
|
67
|
+
self.where = where
|
|
68
|
+
self.others: list[tuple[float, Path]] = []
|
|
69
|
+
self.tasks: set[asyncio.Task[None]] = set()
|
|
70
|
+
|
|
71
|
+
def listen(self) -> None:
|
|
72
|
+
"""Hear every other voice the room has, and each one that joins from now on."""
|
|
73
|
+
if self.where is None:
|
|
74
|
+
return
|
|
75
|
+
room = self.where.room
|
|
76
|
+
room.on("track_subscribed", self._subscribed) # pyright: ignore[reportUnknownMemberType]
|
|
77
|
+
for seat in room.remote_participants.values():
|
|
78
|
+
for publication in seat.track_publications.values():
|
|
79
|
+
if publication.track is not None:
|
|
80
|
+
self._subscribed(publication.track, publication, seat)
|
|
81
|
+
|
|
82
|
+
async def close(self, melody: HoldMusic | None) -> None:
|
|
83
|
+
"""Finish the file; lay in the other voices and the melody when there were any."""
|
|
84
|
+
if self.where is not None:
|
|
85
|
+
self.where.room.off("track_subscribed", self._subscribed) # pyright: ignore[reportUnknownMemberType]
|
|
86
|
+
for task in self.tasks:
|
|
87
|
+
task.cancel()
|
|
88
|
+
await asyncio.gather(*self.tasks, return_exceptions=True)
|
|
89
|
+
await self.recorder.aclose()
|
|
90
|
+
began = self.recorder.recording_started_at
|
|
91
|
+
played = [] if melody is None else melody.sounded()
|
|
92
|
+
if began is None or (not self.others and not played):
|
|
93
|
+
return
|
|
94
|
+
spans = [(start - began, end - began) for start, end in played]
|
|
95
|
+
clip = None if melody is None else melody.clip
|
|
96
|
+
await asyncio.to_thread(laid_in, self.audio, self.others, (clip, spans))
|
|
97
|
+
|
|
98
|
+
def _subscribed(
|
|
99
|
+
self, track: rtc.Track, _publication: rtc.TrackPublication, seat: rtc.RemoteParticipant
|
|
100
|
+
) -> None:
|
|
101
|
+
if self.where is None or not isinstance(track, rtc.RemoteAudioTrack):
|
|
102
|
+
return
|
|
103
|
+
if self.where.kind_of(seat, dict(seat.attributes)) not in OTHERS:
|
|
104
|
+
return
|
|
105
|
+
task = asyncio.create_task(self._heard(track))
|
|
106
|
+
self.tasks.add(task)
|
|
107
|
+
task.add_done_callback(self.tasks.discard)
|
|
108
|
+
|
|
109
|
+
# The track's frames come continuous (the jitter buffer fills a silence), so one run a voice,
|
|
110
|
+
# spooled as it comes and placed where its first frame arrived. A run that breaks is said and
|
|
111
|
+
# kept as far as it got: the recording is the call's, with or without that voice.
|
|
112
|
+
async def _heard(self, track: rtc.RemoteAudioTrack) -> None:
|
|
113
|
+
began = self.recorder.recording_started_at or time.time()
|
|
114
|
+
spool = self.audio.with_name(f"{track.sid}.pcm")
|
|
115
|
+
stream = rtc.AudioStream(track, sample_rate=RATE, num_channels=1)
|
|
116
|
+
try:
|
|
117
|
+
with spool.open("wb") as sink:
|
|
118
|
+
async for event in stream:
|
|
119
|
+
if sink.tell() == 0:
|
|
120
|
+
self.others.append((time.time() - event.frame.duration - began, spool))
|
|
121
|
+
sink.write(event.frame.data)
|
|
122
|
+
except Exception:
|
|
123
|
+
logger.warning(LOST, self.audio.parent.name, exc_info=True)
|
|
124
|
+
finally:
|
|
125
|
+
await stream.aclose()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# A stretch of silence laid in past the file's end, at a time.
|
|
129
|
+
STRETCH = RATE
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def recorded(
|
|
133
|
+
live: AgentSession[None], audio: Path | None, where: CallRoom | None
|
|
134
|
+
) -> Recorder | None:
|
|
135
|
+
"""Record the call into the file from now on; None when it keeps no audio."""
|
|
136
|
+
caller, agent = live.input.audio, live.output.audio
|
|
137
|
+
if audio is None or caller is None or agent is None:
|
|
138
|
+
return None
|
|
139
|
+
recorder = RecorderIO(agent_session=live, sample_rate=RATE)
|
|
140
|
+
live.input.audio = recorder.record_input(caller)
|
|
141
|
+
live.output.audio = recorder.record_output(agent)
|
|
142
|
+
await recorder.start(output_path=audio)
|
|
143
|
+
recording = Recorder(recorder, audio, where)
|
|
144
|
+
recording.listen()
|
|
145
|
+
return recording
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Blocking, for a thread: the file decoded a frame at a time, the sounds added on the right where
|
|
149
|
+
# each falls, encoded as it goes, put in its place. Nothing of the call's length is held at once.
|
|
150
|
+
def laid_in(
|
|
151
|
+
audio: Path,
|
|
152
|
+
others: list[tuple[float, Path]],
|
|
153
|
+
melody: tuple[Path | None, list[tuple[float, float]]],
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Add the other voices and the melody to the recording's right channel, each at its time."""
|
|
156
|
+
sounds = [*(_voice(at_s, pcm) for at_s, pcm in others), *_tune(*melody)]
|
|
157
|
+
part = audio.with_name(f"{audio.name}.part")
|
|
158
|
+
encoded(_mixed(_decoded(audio, "stereo"), sounds), part)
|
|
159
|
+
part.replace(audio)
|
|
160
|
+
for _, pcm in others:
|
|
161
|
+
pcm.unlink(missing_ok=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def encoded(stretches: Iterable[np.ndarray], out: Path) -> None:
|
|
165
|
+
"""Write stretches of two channels of floats at RATE as one stereo Ogg Opus."""
|
|
166
|
+
fifo = av.AudioFifo()
|
|
167
|
+
with av.open(str(out), mode="w", format="ogg") as sink:
|
|
168
|
+
stream: AudioStream = sink.add_stream("libopus", rate=RATE) # pyright: ignore[reportUnknownMemberType]
|
|
169
|
+
stream.layout = "stereo"
|
|
170
|
+
for stretch in stretches:
|
|
171
|
+
frame = av.AudioFrame.from_ndarray(
|
|
172
|
+
np.ascontiguousarray(stretch), format="fltp", layout="stereo"
|
|
173
|
+
)
|
|
174
|
+
frame.sample_rate = RATE
|
|
175
|
+
frame.pts = None
|
|
176
|
+
fifo.write(frame)
|
|
177
|
+
drained(fifo, stream, sink, final=False)
|
|
178
|
+
drained(fifo, stream, sink, final=True)
|
|
179
|
+
for packet in stream.encode(None): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
180
|
+
sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _voice(at_s: float, pcm: Path) -> Sound:
|
|
184
|
+
samples = np.memmap(pcm, dtype="<i2", mode="r")
|
|
185
|
+
|
|
186
|
+
def stretch(at: int, count: int) -> np.ndarray:
|
|
187
|
+
return samples[at : at + count].astype(np.float32) / 32768
|
|
188
|
+
|
|
189
|
+
return Sound(round(at_s * RATE), len(samples), stretch)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# The melody loops from the start of each span, as the player played it.
|
|
193
|
+
def _tune(clip: Path | None, spans: list[tuple[float, float]]) -> list[Sound]:
|
|
194
|
+
pieces = [] if clip is None else list(_decoded(clip, "mono"))
|
|
195
|
+
if not pieces or not spans:
|
|
196
|
+
return []
|
|
197
|
+
tune = np.concatenate(pieces, axis=1)[0] * VOLUME
|
|
198
|
+
|
|
199
|
+
def stretch(at: int, count: int) -> np.ndarray:
|
|
200
|
+
return np.take(tune, np.arange(at, at + count) % len(tune))
|
|
201
|
+
|
|
202
|
+
return [
|
|
203
|
+
Sound(round(start_s * RATE), max(round((end_s - start_s) * RATE), 0), stretch)
|
|
204
|
+
for start_s, end_s in spans
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _mixed(stretches: Iterator[np.ndarray], sounds: list[Sound]) -> Iterator[np.ndarray]:
|
|
209
|
+
cursor = 0
|
|
210
|
+
end = max((sound.start + sound.length for sound in sounds), default=0)
|
|
211
|
+
for stretch in stretches:
|
|
212
|
+
yield _laid(stretch, cursor, sounds)
|
|
213
|
+
cursor += stretch.shape[1]
|
|
214
|
+
while cursor < end:
|
|
215
|
+
count = min(STRETCH, end - cursor)
|
|
216
|
+
yield _laid(np.zeros((2, count), dtype=np.float32), cursor, sounds)
|
|
217
|
+
cursor += count
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _laid(stretch: np.ndarray, cursor: int, sounds: list[Sound]) -> np.ndarray:
|
|
221
|
+
until = cursor + stretch.shape[1]
|
|
222
|
+
for sound in sounds:
|
|
223
|
+
low, high = max(sound.start, cursor), min(sound.start + sound.length, until)
|
|
224
|
+
if low < high:
|
|
225
|
+
stretch[1, low - cursor : high - cursor] += sound.stretch(low - sound.start, high - low)
|
|
226
|
+
np.clip(stretch, -FULL_SCALE, FULL_SCALE, out=stretch)
|
|
227
|
+
return stretch
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _decoded(audio: Path, layout: str) -> Iterator[np.ndarray]:
|
|
231
|
+
resampler = av.AudioResampler(format="fltp", layout=layout, rate=RATE)
|
|
232
|
+
with av.open(str(audio)) as source:
|
|
233
|
+
for frame in source.decode(audio=0):
|
|
234
|
+
for piece in resampler.resample(frame):
|
|
235
|
+
yield piece.to_ndarray()
|
|
236
|
+
for piece in resampler.resample(None):
|
|
237
|
+
yield piece.to_ndarray()
|