rtpbench 0.3.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.
- rtpbench/.dockerignore +4 -0
- rtpbench/Dockerfile +12 -0
- rtpbench/__init__.py +6 -0
- rtpbench/__main__.py +3 -0
- rtpbench/agent.py +746 -0
- rtpbench/analyze.py +520 -0
- rtpbench/bargein.py +272 -0
- rtpbench/bench.py +1069 -0
- rtpbench/caller.py +781 -0
- rtpbench/capture.py +33 -0
- rtpbench/carrier/compose.yaml +68 -0
- rtpbench/carrier/opensips.cfg +226 -0
- rtpbench/carrier/tls.cfg +25 -0
- rtpbench/carrier/tls.yaml +9 -0
- rtpbench/cases.py +1989 -0
- rtpbench/check.py +369 -0
- rtpbench/cli.py +616 -0
- rtpbench/clock.py +174 -0
- rtpbench/compose.yaml +55 -0
- rtpbench/corpus/README.md +55 -0
- rtpbench/corpus/cough-c1-0.wav +0 -0
- rtpbench/corpus/cough-c1-1.wav +0 -0
- rtpbench/corpus/cough-c2-0.wav +0 -0
- rtpbench/corpus/cough-c2-1.wav +0 -0
- rtpbench/corpus/cough-man-0.wav +0 -0
- rtpbench/corpus/cough-man-1.wav +0 -0
- rtpbench/corpus/cough-short-0.wav +0 -0
- rtpbench/corpus/cough-short-1.wav +0 -0
- rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
- rtpbench/corpus/human-en-dr-0.wav +0 -0
- rtpbench/corpus/human-en-dr-1.wav +0 -0
- rtpbench/corpus/human-en-sc-0.wav +0 -0
- rtpbench/corpus/human-en-sc-1.wav +0 -0
- rtpbench/corpus/human-en-sc-2.wav +0 -0
- rtpbench/corpus/human-es-f012-0.wav +0 -0
- rtpbench/corpus/human-es-f012-1.wav +0 -0
- rtpbench/corpus/human-es-f012-2.wav +0 -0
- rtpbench/corpus/human-es-f016-0.wav +0 -0
- rtpbench/corpus/human-es-f016-1.wav +0 -0
- rtpbench/corpus/human-es-f016-2.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-00.wav +0 -0
- rtpbench/corpus/lines/en/prompt-01.wav +0 -0
- rtpbench/corpus/lines/en/prompt-02.wav +0 -0
- rtpbench/corpus/lines/en/prompt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-04.wav +0 -0
- rtpbench/corpus/lines/en/prompt-05.wav +0 -0
- rtpbench/corpus/lines/en/prompt-06.wav +0 -0
- rtpbench/corpus/lines/en/prompt-07.wav +0 -0
- rtpbench/corpus/lines/en/prompt-08.wav +0 -0
- rtpbench/corpus/lines/en/prompt-09.wav +0 -0
- rtpbench/corpus/lines/en/prompt.wav +0 -0
- rtpbench/corpus/lines/en/transfer.wav +0 -0
- rtpbench/corpus/lines/en/turn-00.wav +0 -0
- rtpbench/corpus/lines/en/turn-01.wav +0 -0
- rtpbench/corpus/lines/en/turn-02.wav +0 -0
- rtpbench/corpus/lines/en/turn-03.wav +0 -0
- rtpbench/corpus/lines/en/turn-04.wav +0 -0
- rtpbench/corpus/lines/en/turn-05.wav +0 -0
- rtpbench/corpus/lines/en/turn-06.wav +0 -0
- rtpbench/corpus/lines/en/turn-07.wav +0 -0
- rtpbench/corpus/lines/en/turn-08.wav +0 -0
- rtpbench/corpus/lines/en/turn-09.wav +0 -0
- rtpbench/corpus/lines/en/turn-10.wav +0 -0
- rtpbench/corpus/lines/en/turn-11.wav +0 -0
- rtpbench/corpus/tts-ald-bc.wav +0 -0
- rtpbench/corpus/tts-ald-long.wav +0 -0
- rtpbench/corpus/tts-ald-s03.wav +0 -0
- rtpbench/corpus/tts-ald-s07.wav +0 -0
- rtpbench/corpus/tts-ald-s11.wav +0 -0
- rtpbench/corpus/tts-cori-bc.wav +0 -0
- rtpbench/corpus/tts-cori-long.wav +0 -0
- rtpbench/corpus/tts-cori-s01.wav +0 -0
- rtpbench/corpus/tts-cori-s05.wav +0 -0
- rtpbench/corpus/tts-cori-s09.wav +0 -0
- rtpbench/corpus/tts-davefx-bc.wav +0 -0
- rtpbench/corpus/tts-davefx-long.wav +0 -0
- rtpbench/corpus/tts-davefx-s00.wav +0 -0
- rtpbench/corpus/tts-davefx-s04.wav +0 -0
- rtpbench/corpus/tts-davefx-s08.wav +0 -0
- rtpbench/corpus/tts-libritts0-bc.wav +0 -0
- rtpbench/corpus/tts-libritts0-long.wav +0 -0
- rtpbench/corpus/tts-libritts0-s02.wav +0 -0
- rtpbench/corpus/tts-libritts0-s06.wav +0 -0
- rtpbench/corpus/tts-libritts0-s10.wav +0 -0
- rtpbench/corpus/tts-libritts3-bc.wav +0 -0
- rtpbench/corpus/tts-libritts3-long.wav +0 -0
- rtpbench/corpus/tts-libritts3-s03.wav +0 -0
- rtpbench/corpus/tts-libritts3-s07.wav +0 -0
- rtpbench/corpus/tts-libritts3-s11.wav +0 -0
- rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
- rtpbench/corpus/tts-ljspeech-long.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
- rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardf-long.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
- rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardm-long.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
- rtpbench/detect.py +145 -0
- rtpbench/g711.py +82 -0
- rtpbench/mock/compose.yaml +18 -0
- rtpbench/pcap.py +106 -0
- rtpbench/progress.py +184 -0
- rtpbench/realtime.py +1015 -0
- rtpbench/report.py +450 -0
- rtpbench/rtp.py +166 -0
- rtpbench/sip.py +653 -0
- rtpbench/speech.py +100 -0
- rtpbench/speechcal.py +367 -0
- rtpbench/srtp.py +191 -0
- rtpbench/targets/aivc/aivc.ini +25 -0
- rtpbench/targets/aivc/compose.yaml +72 -0
- rtpbench/targets/aivc/constraints.txt +52 -0
- rtpbench/targets/aivc/opensips.cfg +45 -0
- rtpbench/targets/ava/ai-agent.yaml +275 -0
- rtpbench/targets/ava/ari.conf +7 -0
- rtpbench/targets/ava/compose.yaml +128 -0
- rtpbench/targets/ava/constraints.txt +75 -0
- rtpbench/targets/ava/extensions.conf +10 -0
- rtpbench/targets/ava/http.conf +4 -0
- rtpbench/targets/ava/pjsip.conf +19 -0
- rtpbench/targets/ava/websocket_client.conf +15 -0
- rtpbench/targets/livekit/agent.py +111 -0
- rtpbench/targets/livekit/compose.yaml +86 -0
- rtpbench/targets/livekit/constraints.txt +72 -0
- rtpbench/tls.py +284 -0
- rtpbench/uri/advertise.yaml +7 -0
- rtpbench/uri/network.yaml +14 -0
- rtpbench/voice.py +18 -0
- rtpbench-0.3.0.dist-info/METADATA +123 -0
- rtpbench-0.3.0.dist-info/RECORD +144 -0
- rtpbench-0.3.0.dist-info/WHEEL +4 -0
- rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
- rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
; Asterisk for rtpbench: plain SIP over UDP and TCP, any caller, G.711 only
|
|
2
|
+
[udp]
|
|
3
|
+
type=transport
|
|
4
|
+
protocol=udp
|
|
5
|
+
bind=0.0.0.0:5060
|
|
6
|
+
|
|
7
|
+
[tcp]
|
|
8
|
+
type=transport
|
|
9
|
+
protocol=tcp
|
|
10
|
+
bind=0.0.0.0:5060
|
|
11
|
+
|
|
12
|
+
[anonymous]
|
|
13
|
+
type=endpoint
|
|
14
|
+
context=from-caller
|
|
15
|
+
disallow=all
|
|
16
|
+
allow=ulaw,alaw
|
|
17
|
+
direct_media=no
|
|
18
|
+
rtp_symmetric=yes
|
|
19
|
+
dtmf_mode=rfc4733
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
; AVA_TRANSPORT=websocket: for each call, AVA asks ARI for a WebSocket media channel on this
|
|
2
|
+
; client and Asterisk connects to the engine's listener, on loopback as they share a network
|
|
3
|
+
; namespace (stanza as in AVA's docs/WebSocket-Transport.md; the password is the engine's
|
|
4
|
+
; ASTERISK_MEDIA_WS_PASSWORD, a test value like ARI's)
|
|
5
|
+
[aava_media]
|
|
6
|
+
type = websocket_client
|
|
7
|
+
uri = ws://127.0.0.1:8787/media
|
|
8
|
+
protocols = media
|
|
9
|
+
username = aava_media
|
|
10
|
+
password = asterisk
|
|
11
|
+
connection_type = per_call_config
|
|
12
|
+
connection_timeout = 500
|
|
13
|
+
reconnect_interval = 500
|
|
14
|
+
reconnect_attempts = 5
|
|
15
|
+
tls_enabled = no
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""rtpbench's LiveKit target: a LiveKit Agents worker on the OpenAI Realtime model through Azure
|
|
2
|
+
OpenAI (`RealtimeModel.with_azure`, as LiveKit's Azure docs set it), on the structure of LiveKit's
|
|
3
|
+
starter agent, set up as targets/aivc sets AI Voice Connector CE: the same instructions, welcome
|
|
4
|
+
message, voice and tool descriptions, transfers to TRANSFER_TO (LiveKit's documented cold
|
|
5
|
+
transfer, a SIP REFER). Before the worker starts, an inbound trunk for the number rtpbench
|
|
6
|
+
dials (from any address, no auth: LiveKit wants a number, addresses or auth) and a dispatch rule
|
|
7
|
+
that puts each call in its own room, as LiveKit's SIP docs create them.
|
|
8
|
+
|
|
9
|
+
Env: AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT, TRANSFER_TO,
|
|
10
|
+
LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from livekit import api, rtc
|
|
19
|
+
from livekit.agents import (
|
|
20
|
+
Agent,
|
|
21
|
+
AgentServer,
|
|
22
|
+
AgentSession,
|
|
23
|
+
JobContext,
|
|
24
|
+
RunContext,
|
|
25
|
+
cli,
|
|
26
|
+
function_tool,
|
|
27
|
+
get_job_context,
|
|
28
|
+
)
|
|
29
|
+
from livekit.agents.beta import EndCallTool
|
|
30
|
+
from livekit.plugins import openai
|
|
31
|
+
|
|
32
|
+
NUMBER = "agent" # the Request-URI user rtpbench calls (bench.TARGETS)
|
|
33
|
+
# the compose healthcheck's: the server can dispatch to this worker. Its own health check answers
|
|
34
|
+
# once its HTTP server is up, seconds before it registers; a call in between gets a room no
|
|
35
|
+
# worker ever joins, and rings unanswered
|
|
36
|
+
REGISTERED = Path("/tmp/registered")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Assistant(Agent):
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(instructions="You are a helpful assistant.", tools=[EndCallTool()])
|
|
42
|
+
|
|
43
|
+
async def on_enter(self) -> None:
|
|
44
|
+
self.session.generate_reply(
|
|
45
|
+
instructions="Please greet the user with the following: Hello! How can I help you?"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@function_tool()
|
|
49
|
+
async def transfer_call(self, context: RunContext) -> str:
|
|
50
|
+
"""call the function if a request was received to transfer a call with an operator, a
|
|
51
|
+
person"""
|
|
52
|
+
job = get_job_context()
|
|
53
|
+
caller = next(
|
|
54
|
+
p
|
|
55
|
+
for p in job.room.remote_participants.values()
|
|
56
|
+
if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
|
|
57
|
+
)
|
|
58
|
+
request = api.TransferSIPParticipantRequest(
|
|
59
|
+
room_name=job.room.name,
|
|
60
|
+
participant_identity=caller.identity,
|
|
61
|
+
transfer_to=os.environ["TRANSFER_TO"],
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
await job.api.sip.transfer_sip_participant(request)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return f"transfer failed: {e}"
|
|
67
|
+
return "transferred"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
server = AgentServer()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@server.on("worker_registered")
|
|
74
|
+
def registered(worker_id: str, info: object) -> None:
|
|
75
|
+
REGISTERED.touch()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@server.rtc_session()
|
|
79
|
+
async def entrypoint(ctx: JobContext) -> None:
|
|
80
|
+
session: AgentSession = AgentSession(
|
|
81
|
+
llm=openai.realtime.RealtimeModel.with_azure(
|
|
82
|
+
azure_deployment=os.environ["AZURE_OPENAI_DEPLOYMENT"], voice="alloy"
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
await session.start(room=ctx.room, agent=Assistant())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def setup() -> None:
|
|
89
|
+
async with api.LiveKitAPI() as lk:
|
|
90
|
+
for _ in range(60): # the server may still be starting
|
|
91
|
+
try:
|
|
92
|
+
await lk.sip.list_inbound_trunk(api.ListSIPInboundTrunkRequest())
|
|
93
|
+
break
|
|
94
|
+
except Exception:
|
|
95
|
+
await asyncio.sleep(1)
|
|
96
|
+
await lk.sip.create_inbound_trunk(
|
|
97
|
+
api.CreateSIPInboundTrunkRequest(
|
|
98
|
+
trunk=api.SIPInboundTrunkInfo(name="rtpbench", numbers=[NUMBER])
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
rule = api.SIPDispatchRule(
|
|
102
|
+
dispatch_rule_individual=api.SIPDispatchRuleIndividual(room_prefix="call-")
|
|
103
|
+
)
|
|
104
|
+
await lk.sip.create_dispatch_rule(api.CreateSIPDispatchRuleRequest(rule=rule))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
if sys.argv[1:] == ["setup"]:
|
|
109
|
+
asyncio.run(setup())
|
|
110
|
+
else:
|
|
111
|
+
cli.run_app(server)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# LiveKit (server, SIP bridge, an Agents worker on the OpenAI Realtime model) as a target, merged
|
|
2
|
+
# over ../../compose.yaml. As livekit/sip's docker-compose.yaml runs it (redis, livekit-server
|
|
3
|
+
# --dev, sip; host networking there), but in the network namespace of `target` (agentnet, plus
|
|
4
|
+
# egress to the AI engine), so everything is on 127.0.0.1 as upstream's is. rtpbench routes the
|
|
5
|
+
# caller via the router, so SIP and RTP cross netem. Needs AZURE_OPENAI_ENDPOINT
|
|
6
|
+
# (https://<resource>.openai.azure.com) and AZURE_OPENAI_API_KEY. Paths are relative to the first
|
|
7
|
+
# compose file's directory.
|
|
8
|
+
services:
|
|
9
|
+
target:
|
|
10
|
+
image: ${RTPBENCH_IMAGE:?}
|
|
11
|
+
cap_add: [NET_ADMIN]
|
|
12
|
+
init: true
|
|
13
|
+
depends_on: [router]
|
|
14
|
+
networks: [agentnet, egress]
|
|
15
|
+
volumes: ["livekit-sip:/sip"]
|
|
16
|
+
# upstream's SIP config, except the address in the SDP: the one facing the caller (the
|
|
17
|
+
# router's side), not one found by STUN (use_external_ip: true there)
|
|
18
|
+
command:
|
|
19
|
+
- sh
|
|
20
|
+
- -c
|
|
21
|
+
- >-
|
|
22
|
+
python3 -c 'import socket as s; u = s.socket(2, 2);
|
|
23
|
+
u.connect((s.gethostbyname("router"), 9)); ip = u.getsockname()[0];
|
|
24
|
+
open("/sip/config.yaml", "w").write("api_key: devkey\napi_secret: secret\n"
|
|
25
|
+
"ws_url: ws://localhost:7880\nredis:\n address: localhost:6379\nsip_port: 5060\n"
|
|
26
|
+
"rtp_port: 10000-20000\nnat_1_to_1_ip: " + ip + "\nlogging:\n level: debug\n")'
|
|
27
|
+
&& exec sleep infinity
|
|
28
|
+
healthcheck:
|
|
29
|
+
test: test -e /sip/config.yaml
|
|
30
|
+
interval: 1s
|
|
31
|
+
retries: 20
|
|
32
|
+
|
|
33
|
+
redis:
|
|
34
|
+
image: redis:8.2-alpine@sha256:b51665e66f00759be7c3152ad5ac3c66fb2f619c13ef62dea7cc1f9914524635
|
|
35
|
+
network_mode: service:target
|
|
36
|
+
depends_on: [target]
|
|
37
|
+
|
|
38
|
+
livekit:
|
|
39
|
+
image: livekit/livekit-server:v1.13.9@sha256:d0c04791bf63ca8dcea123571827d56ba9504bd57c8d5de60ee03b5408cc3154
|
|
40
|
+
network_mode: service:target
|
|
41
|
+
depends_on: [redis]
|
|
42
|
+
command: --dev --redis-host localhost:6379
|
|
43
|
+
|
|
44
|
+
sip:
|
|
45
|
+
image: livekit/sip:v1.19.0@sha256:4398c3794cfb0c62fd118ffa8fec42f386bf906106e656da011e0961223cb8b8
|
|
46
|
+
network_mode: service:target
|
|
47
|
+
depends_on:
|
|
48
|
+
target: { condition: service_healthy }
|
|
49
|
+
livekit: { condition: service_started }
|
|
50
|
+
volumes: ["livekit-sip:/sip:ro"]
|
|
51
|
+
|
|
52
|
+
agent-worker:
|
|
53
|
+
build:
|
|
54
|
+
context: ./targets/livekit
|
|
55
|
+
dockerfile_inline: |
|
|
56
|
+
FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f
|
|
57
|
+
COPY constraints.txt /tmp/constraints.txt
|
|
58
|
+
RUN pip install --no-cache-dir "livekit-agents[openai]==1.8.7" -c /tmp/constraints.txt
|
|
59
|
+
WORKDIR /app
|
|
60
|
+
COPY agent.py .
|
|
61
|
+
image: ${RTPBENCH_REGISTRY:?}/rtpbench-livekit-agent:1.8.7${RTPBENCH_BUILD_AGENT_WORKER:-}
|
|
62
|
+
network_mode: service:target
|
|
63
|
+
depends_on: [livekit, sip]
|
|
64
|
+
environment:
|
|
65
|
+
LIVEKIT_URL: ws://localhost:7880
|
|
66
|
+
LIVEKIT_API_KEY: devkey
|
|
67
|
+
LIVEKIT_API_SECRET: secret
|
|
68
|
+
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:?set AZURE_OPENAI_ENDPOINT}
|
|
69
|
+
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:?set AZURE_OPENAI_API_KEY}
|
|
70
|
+
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-gpt-realtime-2.1}
|
|
71
|
+
TRANSFER_TO: sip:operator@operator
|
|
72
|
+
command: sh -c 'python agent.py setup && exec python agent.py start'
|
|
73
|
+
# ready once the worker has registered with the server (agent.py REGISTERED), while its own
|
|
74
|
+
# health check passes (it fails once the worker has lost the server for good)
|
|
75
|
+
healthcheck:
|
|
76
|
+
test: >-
|
|
77
|
+
test -e /tmp/registered &&
|
|
78
|
+
python -c 'import urllib.request as u; u.urlopen("http://127.0.0.1:8081")'
|
|
79
|
+
interval: 1s
|
|
80
|
+
retries: 60
|
|
81
|
+
|
|
82
|
+
networks:
|
|
83
|
+
egress:
|
|
84
|
+
|
|
85
|
+
volumes:
|
|
86
|
+
livekit-sip:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
aiofiles==25.1.0
|
|
2
|
+
aiohappyeyeballs==2.7.1
|
|
3
|
+
aiohttp==3.14.5
|
|
4
|
+
aiosignal==1.4.0
|
|
5
|
+
annotated-doc==0.0.5
|
|
6
|
+
annotated-types==0.8.0
|
|
7
|
+
anyio==4.15.1
|
|
8
|
+
attrs==26.1.0
|
|
9
|
+
av==19.0.1
|
|
10
|
+
certifi==2026.7.22
|
|
11
|
+
cffi==2.1.1
|
|
12
|
+
charset-normalizer==3.5.2
|
|
13
|
+
click==8.5.0
|
|
14
|
+
colorama==0.4.6
|
|
15
|
+
docstring_parser==0.18.0
|
|
16
|
+
eval_type_backport==0.4.0
|
|
17
|
+
frozenlist==1.8.0
|
|
18
|
+
googleapis-common-protos==1.75.5
|
|
19
|
+
grpcio==1.84.0
|
|
20
|
+
h11==0.16.0
|
|
21
|
+
httpcore2==2.13.1
|
|
22
|
+
httpx2==2.13.1
|
|
23
|
+
idna==3.20
|
|
24
|
+
jiter==0.17.0
|
|
25
|
+
json_repair==0.63.5
|
|
26
|
+
livekit==1.1.20
|
|
27
|
+
livekit-agents==1.8.7
|
|
28
|
+
livekit-api==1.2.2
|
|
29
|
+
livekit-blingfire==1.2.0
|
|
30
|
+
livekit-local-inference==0.2.7
|
|
31
|
+
livekit-plugins-openai==1.8.7
|
|
32
|
+
livekit-protocol==1.1.27
|
|
33
|
+
markdown-it-py==4.2.0
|
|
34
|
+
mdurl==0.1.2
|
|
35
|
+
multidict==7.1.0
|
|
36
|
+
nest-asyncio==1.6.0
|
|
37
|
+
numpy==2.5.4
|
|
38
|
+
openai==3.33.0
|
|
39
|
+
opentelemetry-api==1.45.1
|
|
40
|
+
opentelemetry-exporter-http-transport==0.66b1
|
|
41
|
+
opentelemetry-exporter-otlp==1.45.1
|
|
42
|
+
opentelemetry-exporter-otlp-common==0.66b1
|
|
43
|
+
opentelemetry-exporter-otlp-proto-common==1.45.1
|
|
44
|
+
opentelemetry-exporter-otlp-proto-grpc==1.45.1
|
|
45
|
+
opentelemetry-exporter-otlp-proto-http==1.45.1
|
|
46
|
+
opentelemetry-proto==1.45.1
|
|
47
|
+
opentelemetry-sdk==1.45.1
|
|
48
|
+
opentelemetry-semantic-conventions==0.66b1
|
|
49
|
+
pillow==13.0.0
|
|
50
|
+
prometheus_client==0.26.0
|
|
51
|
+
propcache==0.5.4
|
|
52
|
+
protobuf==7.36.2
|
|
53
|
+
psutil==7.2.2
|
|
54
|
+
pycparser==3.11
|
|
55
|
+
pydantic==2.14.1
|
|
56
|
+
pydantic_core==2.50.1
|
|
57
|
+
Pygments==2.21.0
|
|
58
|
+
PyJWT==2.15.1
|
|
59
|
+
PyYAML==6.0.3
|
|
60
|
+
requests==2.34.2
|
|
61
|
+
rich==15.0.0
|
|
62
|
+
shellingham==1.5.4
|
|
63
|
+
sniffio==1.3.1
|
|
64
|
+
sounddevice==0.5.6
|
|
65
|
+
truststore==0.10.5
|
|
66
|
+
typer==0.27.3
|
|
67
|
+
types-protobuf==7.35.1.20260906
|
|
68
|
+
typing-inspection==0.4.4
|
|
69
|
+
typing_extensions==4.16.0
|
|
70
|
+
urllib3==2.8.0
|
|
71
|
+
watchfiles==1.3.0
|
|
72
|
+
yarl==1.25.1
|
rtpbench/tls.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""SIP over TLS read back from a capture: TLS connections in the TCP streams decrypted with the
|
|
2
|
+
caller's key log (the SSLKEYLOGFILE format OpenSSL and Wireshark use), so judging reads the
|
|
3
|
+
messages that crossed the wire at the times they crossed it. TLS 1.3, and TLS 1.2 with the
|
|
4
|
+
AEAD suites the caller offers. Also the stack's test certificates."""
|
|
5
|
+
|
|
6
|
+
import bisect
|
|
7
|
+
import datetime
|
|
8
|
+
import hmac
|
|
9
|
+
import struct
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cryptography import x509
|
|
13
|
+
from cryptography.exceptions import InvalidTag
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
|
|
17
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
|
18
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
19
|
+
|
|
20
|
+
from rtpbench import pcap
|
|
21
|
+
|
|
22
|
+
Aead = type[AESGCM] | type[ChaCha20Poly1305]
|
|
23
|
+
# cipher suite: AEAD, key length, hash
|
|
24
|
+
TLS13: dict[int, tuple[Aead, int, str]] = {
|
|
25
|
+
0x1301: (AESGCM, 16, "sha256"),
|
|
26
|
+
0x1302: (AESGCM, 32, "sha384"),
|
|
27
|
+
0x1303: (ChaCha20Poly1305, 32, "sha256"),
|
|
28
|
+
}
|
|
29
|
+
TLS12: dict[int, tuple[Aead, int, str]] = {
|
|
30
|
+
0xC02B: (AESGCM, 16, "sha256"), # ECDHE-ECDSA-AES128-GCM-SHA256
|
|
31
|
+
0xC02F: (AESGCM, 16, "sha256"), # ECDHE-RSA-AES128-GCM-SHA256
|
|
32
|
+
0xC02C: (AESGCM, 32, "sha384"), # ECDHE-ECDSA-AES256-GCM-SHA384
|
|
33
|
+
0xC030: (AESGCM, 32, "sha384"), # ECDHE-RSA-AES256-GCM-SHA384
|
|
34
|
+
0xCCA8: (ChaCha20Poly1305, 32, "sha256"), # ECDHE-RSA-CHACHA20-POLY1305
|
|
35
|
+
0xCCA9: (ChaCha20Poly1305, 32, "sha256"), # ECDHE-ECDSA-CHACHA20-POLY1305
|
|
36
|
+
}
|
|
37
|
+
CIPHERS_12 = "ECDHE+AESGCM:ECDHE+CHACHA20" # what the caller offers below TLS 1.3: AEAD only
|
|
38
|
+
_HASHES = {"sha256": hashes.SHA256, "sha384": hashes.SHA384}
|
|
39
|
+
_HANDSHAKE, _CHANGE_CIPHER_SPEC, _APPLICATION_DATA, _KEY_UPDATE = 22, 20, 23, 24
|
|
40
|
+
|
|
41
|
+
Flow = tuple[str, int, str, int]
|
|
42
|
+
Records = list[tuple[int, int, bytes, bytes]] # offset in the stream, type, header, fragment
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def keylog(path: Path) -> dict[tuple[str, bytes], bytes]:
|
|
46
|
+
"""(label, client random) -> secret, from a key log."""
|
|
47
|
+
out = {}
|
|
48
|
+
for line in path.read_text().splitlines():
|
|
49
|
+
fields = line.split()
|
|
50
|
+
if len(fields) == 3 and not line.startswith("#"):
|
|
51
|
+
out[fields[0], bytes.fromhex(fields[1])] = bytes.fromhex(fields[2])
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def streams(grams: list[pcap.Datagram], keys: Path | None) -> list[pcap.Datagram]:
|
|
56
|
+
"""`pcap.streams(grams)`, but each TLS connection's application data in its place,
|
|
57
|
+
decrypted with the key log `keys`: one piece per record, at the capture time of the
|
|
58
|
+
segment that brought the record's first byte."""
|
|
59
|
+
segs = pcap.streams(grams)
|
|
60
|
+
flows: dict[Flow, tuple[bytearray, list[int], list[int]]] = {} # stream, offsets, times
|
|
61
|
+
for g in segs:
|
|
62
|
+
data, offs, times = flows.setdefault(
|
|
63
|
+
(g.src, g.sport, g.dst, g.dport), (bytearray(), [], [])
|
|
64
|
+
)
|
|
65
|
+
offs.append(len(data))
|
|
66
|
+
times.append(g.t_ns)
|
|
67
|
+
data += g.payload
|
|
68
|
+
clients = [f for f, (data, _, _) in flows.items() if _hello(data)]
|
|
69
|
+
tls = {*clients, *map(_back, clients)}
|
|
70
|
+
out = [g for g in segs if (g.src, g.sport, g.dst, g.dport) not in tls]
|
|
71
|
+
secrets = keylog(keys) if keys and keys.exists() else {}
|
|
72
|
+
for flow in clients:
|
|
73
|
+
back = _back(flow)
|
|
74
|
+
server = bytes(flows[back][0]) if back in flows else b""
|
|
75
|
+
sides = _connection(bytes(flows[flow][0]), server, secrets, flow)
|
|
76
|
+
for f, pieces in zip((flow, back), sides, strict=True):
|
|
77
|
+
for off, plain in pieces:
|
|
78
|
+
_, offs, times = flows[f]
|
|
79
|
+
t = times[bisect.bisect_right(offs, off) - 1]
|
|
80
|
+
out.append(pcap.Datagram(t, *f, plain, True))
|
|
81
|
+
return sorted(out, key=lambda g: g.t_ns)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _back(flow: Flow) -> Flow:
|
|
85
|
+
return flow[2], flow[3], flow[0], flow[1]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _hello(data: bytes | bytearray) -> bool:
|
|
89
|
+
"""The stream opens with a TLS ClientHello."""
|
|
90
|
+
return len(data) > 5 and data[0] == _HANDSHAKE and data[1] == 3 and data[5] == 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _records(data: bytes) -> Records:
|
|
94
|
+
out, pos = [], 0
|
|
95
|
+
while pos + 5 <= len(data):
|
|
96
|
+
length = struct.unpack_from("!H", data, pos + 3)[0]
|
|
97
|
+
if pos + 5 + length > len(data): # cut short: the capture ended inside it
|
|
98
|
+
break
|
|
99
|
+
out.append((pos, data[pos], data[pos : pos + 5], data[pos + 5 : pos + 5 + length]))
|
|
100
|
+
pos += 5 + length
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _connection(
|
|
105
|
+
client: bytes, server: bytes, secrets: dict[tuple[str, bytes], bytes], flow: Flow
|
|
106
|
+
) -> tuple[list[tuple[int, bytes]], list[tuple[int, bytes]]]:
|
|
107
|
+
"""Application data of each side (stream offset of its record, plaintext)."""
|
|
108
|
+
sent, got = _records(client), _records(server)
|
|
109
|
+
hello = got[0][3] if got and got[0][1] == _HANDSHAKE else b""
|
|
110
|
+
if len(hello) < 39 or hello[0] != 2 or len(hello) < 41 + hello[38]:
|
|
111
|
+
return [], [] # no ServerHello (an alert, or nothing): nothing was ever encrypted
|
|
112
|
+
random = sent[0][3][6:38] # ClientHello: type, length (3), version (2), random
|
|
113
|
+
# ServerHello (`hello`): likewise, then the session id and the cipher suite
|
|
114
|
+
suite = struct.unpack_from("!H", hello, 39 + hello[38])[0]
|
|
115
|
+
where = f"TLS {flow[0]}:{flow[1]} -> {flow[2]}:{flow[3]}"
|
|
116
|
+
if ("CLIENT_TRAFFIC_SECRET_0", random) in secrets:
|
|
117
|
+
if suite not in TLS13:
|
|
118
|
+
raise ValueError(f"{where}: TLS 1.3 cipher suite {suite:#06x} not supported")
|
|
119
|
+
|
|
120
|
+
def side(name: str, records: Records) -> list[tuple[int, bytes]]:
|
|
121
|
+
hs, app = (
|
|
122
|
+
secrets[f"{name}_{s}", random]
|
|
123
|
+
for s in ("HANDSHAKE_TRAFFIC_SECRET", "TRAFFIC_SECRET_0")
|
|
124
|
+
)
|
|
125
|
+
return _open13(records, suite, hs, app, where)
|
|
126
|
+
|
|
127
|
+
return side("CLIENT", sent), side("SERVER", got)
|
|
128
|
+
if (master := secrets.get(("CLIENT_RANDOM", random))) is None:
|
|
129
|
+
raise ValueError(f"{where}: no keys for it in the key log")
|
|
130
|
+
if suite not in TLS12:
|
|
131
|
+
raise ValueError(f"{where}: TLS 1.2 cipher suite {suite:#06x} not supported")
|
|
132
|
+
aead, size, h = TLS12[suite]
|
|
133
|
+
iv = 4 if aead is AESGCM else 12
|
|
134
|
+
block = _prf(h, master, b"key expansion", hello[6:38] + random, 2 * (size + iv))
|
|
135
|
+
keys = block[: 2 * size]
|
|
136
|
+
ivs = block[2 * size :]
|
|
137
|
+
return (
|
|
138
|
+
_open12(sent, aead, keys[:size], ivs[:iv], where),
|
|
139
|
+
_open12(got, aead, keys[size:], ivs[iv:], where),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _expand(h: str, secret: bytes, label: bytes, length: int) -> bytes:
|
|
144
|
+
"""HKDF-Expand-Label (RFC 8446 7.1), with an empty context."""
|
|
145
|
+
full = b"tls13 " + label
|
|
146
|
+
info = struct.pack("!HB", length, len(full)) + full + b"\x00"
|
|
147
|
+
return HKDFExpand(_HASHES[h](), length, info).derive(secret)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _open13(
|
|
151
|
+
records: Records, suite: int, handshake: bytes, application: bytes, where: str
|
|
152
|
+
) -> list[tuple[int, bytes]]:
|
|
153
|
+
"""One side's records of a TLS 1.3 connection: handshake keys first, then the application
|
|
154
|
+
keys (whichever opens the record; AEAD tags make the choice unambiguous), following any
|
|
155
|
+
KeyUpdate (RFC 8446 7.2)."""
|
|
156
|
+
aead, size, h = TLS13[suite]
|
|
157
|
+
|
|
158
|
+
def keys(secret: bytes) -> tuple[AESGCM | ChaCha20Poly1305, int]:
|
|
159
|
+
return aead(_expand(h, secret, b"key", size)), int.from_bytes(_expand(h, secret, b"iv", 12))
|
|
160
|
+
|
|
161
|
+
(cipher, iv), secret, seq, stage = keys(handshake), handshake, 0, 0
|
|
162
|
+
out = []
|
|
163
|
+
for off, kind, head, frag in records:
|
|
164
|
+
if kind != _APPLICATION_DATA: # hellos and change_cipher_spec, in the clear
|
|
165
|
+
continue
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
plain = cipher.decrypt((iv ^ seq).to_bytes(12), frag, head)
|
|
169
|
+
break
|
|
170
|
+
except InvalidTag:
|
|
171
|
+
if stage:
|
|
172
|
+
raise ValueError(f"{where}: a record does not decrypt") from None
|
|
173
|
+
(cipher, iv), secret, seq, stage = keys(application), application, 0, 1
|
|
174
|
+
seq += 1
|
|
175
|
+
plain = plain.rstrip(b"\x00") # padding, then the real content type
|
|
176
|
+
inner, plain = plain[-1], plain[:-1]
|
|
177
|
+
if inner == _APPLICATION_DATA:
|
|
178
|
+
out.append((off, plain))
|
|
179
|
+
elif inner == _HANDSHAKE and stage and plain[:1] == bytes([_KEY_UPDATE]):
|
|
180
|
+
secret = _expand(h, secret, b"traffic upd", _HASHES[h]().digest_size)
|
|
181
|
+
(cipher, iv), seq = keys(secret), 0
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _prf(h: str, secret: bytes, label: bytes, seed: bytes, length: int) -> bytes:
|
|
186
|
+
"""The TLS 1.2 PRF (RFC 5246 5)."""
|
|
187
|
+
seed, a, out = label + seed, label + seed, b""
|
|
188
|
+
while len(out) < length:
|
|
189
|
+
a = hmac.digest(secret, a, h)
|
|
190
|
+
out += hmac.digest(secret, a + seed, h)
|
|
191
|
+
return out[:length]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _open12(
|
|
195
|
+
records: Records, aead: Aead, key: bytes, iv: bytes, where: str
|
|
196
|
+
) -> list[tuple[int, bytes]]:
|
|
197
|
+
"""One side's records of a TLS 1.2 connection, encrypted from its ChangeCipherSpec on."""
|
|
198
|
+
cipher, seq, on, out = aead(key), 0, False, []
|
|
199
|
+
for off, kind, head, frag in records:
|
|
200
|
+
if kind == _CHANGE_CIPHER_SPEC:
|
|
201
|
+
on = True
|
|
202
|
+
continue
|
|
203
|
+
if not on:
|
|
204
|
+
continue
|
|
205
|
+
if aead is AESGCM: # RFC 5288: 4 bytes of the key block, 8 sent with each record
|
|
206
|
+
nonce, body = iv + frag[:8], frag[8:]
|
|
207
|
+
else: # RFC 7905
|
|
208
|
+
nonce, body = (int.from_bytes(iv) ^ seq).to_bytes(12), frag
|
|
209
|
+
aad = struct.pack("!QB", seq, kind) + head[1:3] + struct.pack("!H", len(body) - 16)
|
|
210
|
+
try:
|
|
211
|
+
plain = cipher.decrypt(nonce, body, aad)
|
|
212
|
+
except InvalidTag:
|
|
213
|
+
raise ValueError(f"{where}: a record does not decrypt") from None
|
|
214
|
+
seq += 1
|
|
215
|
+
if kind == _APPLICATION_DATA:
|
|
216
|
+
out.append((off, plain))
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def certificates(directory: Path, names: list[str]) -> None:
|
|
221
|
+
"""A test CA (stack-ca.pem) and one certificate it signs for every name (server.crt, with
|
|
222
|
+
the CA, and server.key), valid for two days: the stack's TLS servers present it."""
|
|
223
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
225
|
+
ca_key, key = ec.generate_private_key(ec.SECP256R1()), ec.generate_private_key(ec.SECP256R1())
|
|
226
|
+
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "rtpbench test CA")])
|
|
227
|
+
ski = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
|
|
228
|
+
|
|
229
|
+
def cert(subject: x509.Name, public: ec.EllipticCurvePublicKey) -> x509.CertificateBuilder:
|
|
230
|
+
return (
|
|
231
|
+
x509.CertificateBuilder()
|
|
232
|
+
.subject_name(subject)
|
|
233
|
+
.issuer_name(ca_name)
|
|
234
|
+
.public_key(public)
|
|
235
|
+
.serial_number(x509.random_serial_number())
|
|
236
|
+
.not_valid_before(now - datetime.timedelta(minutes=5))
|
|
237
|
+
.not_valid_after(now + datetime.timedelta(days=2))
|
|
238
|
+
.add_extension(x509.SubjectKeyIdentifier.from_public_key(public), critical=False)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
ca = (
|
|
242
|
+
cert(ca_name, ca_key.public_key())
|
|
243
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
|
244
|
+
.add_extension(_usage(cert_sign=True), critical=True)
|
|
245
|
+
.sign(ca_key, hashes.SHA256())
|
|
246
|
+
)
|
|
247
|
+
leaf = (
|
|
248
|
+
cert(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, names[0])]), key.public_key())
|
|
249
|
+
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
250
|
+
.add_extension(_usage(cert_sign=False), critical=True)
|
|
251
|
+
.add_extension(
|
|
252
|
+
x509.ExtendedKeyUsage(
|
|
253
|
+
[ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]
|
|
254
|
+
),
|
|
255
|
+
critical=False,
|
|
256
|
+
)
|
|
257
|
+
.add_extension(
|
|
258
|
+
x509.SubjectAlternativeName([x509.DNSName(n) for n in names]), critical=False
|
|
259
|
+
)
|
|
260
|
+
.add_extension(
|
|
261
|
+
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski), critical=False
|
|
262
|
+
)
|
|
263
|
+
.sign(ca_key, hashes.SHA256())
|
|
264
|
+
)
|
|
265
|
+
pem = serialization.Encoding.PEM
|
|
266
|
+
(directory / "stack-ca.pem").write_bytes(ca.public_bytes(pem))
|
|
267
|
+
(directory / "server.crt").write_bytes(leaf.public_bytes(pem) + ca.public_bytes(pem))
|
|
268
|
+
(directory / "server.key").write_bytes(
|
|
269
|
+
key.private_bytes(pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _usage(cert_sign: bool) -> x509.KeyUsage:
|
|
274
|
+
return x509.KeyUsage(
|
|
275
|
+
digital_signature=not cert_sign,
|
|
276
|
+
content_commitment=False,
|
|
277
|
+
key_encipherment=False,
|
|
278
|
+
data_encipherment=False,
|
|
279
|
+
key_agreement=False,
|
|
280
|
+
key_cert_sign=cert_sign,
|
|
281
|
+
crl_sign=cert_sign,
|
|
282
|
+
encipher_only=False,
|
|
283
|
+
decipher_only=False,
|
|
284
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `rtpbench target URI --advertise HOST:PORT`, merged over ../compose.yaml: the host publishes the
|
|
2
|
+
# caller's port range on the router, which forwards it to the caller (RTPBENCH_PORTS: first-last).
|
|
3
|
+
services:
|
|
4
|
+
router:
|
|
5
|
+
ports:
|
|
6
|
+
- "${RTPBENCH_PORTS:?}:${RTPBENCH_PORTS:?}/udp"
|
|
7
|
+
- "${RTPBENCH_PORTS:?}:${RTPBENCH_PORTS:?}/tcp"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# `rtpbench target URI --network NAME`, merged over ../compose.yaml: the router also joins an
|
|
2
|
+
# existing Docker network, where the agent runs (Docker drops traffic between its networks
|
|
3
|
+
# otherwise). agentnet stays the router's way out whatever the networks are called.
|
|
4
|
+
services:
|
|
5
|
+
router:
|
|
6
|
+
networks:
|
|
7
|
+
callnet: {}
|
|
8
|
+
agentnet: {gw_priority: 1}
|
|
9
|
+
user: {}
|
|
10
|
+
|
|
11
|
+
networks:
|
|
12
|
+
user:
|
|
13
|
+
external: true
|
|
14
|
+
name: ${RTPBENCH_NETWORK:?}
|
rtpbench/voice.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Synthetic voiced audio with a crisp onset, and the energy gate both ends agree on."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
RATE = 8000
|
|
6
|
+
GATE = 1000 # |sample| on the 16-bit scale; the synthetic voice never dips below it for 20 ms
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def voice(seconds: float, f0: float, amplitude: int = 12000) -> list[int]:
|
|
10
|
+
"""Eight cosine harmonics under a 4 Hz syllable envelope; sample 0 is the waveform's peak."""
|
|
11
|
+
norm = sum(1 / h for h in range(1, 9))
|
|
12
|
+
out = []
|
|
13
|
+
for i in range(round(seconds * RATE)):
|
|
14
|
+
t = i / RATE
|
|
15
|
+
env = 0.6 + 0.4 * math.cos(2 * math.pi * 4 * t)
|
|
16
|
+
x = sum(math.cos(2 * math.pi * h * f0 * t) / h for h in range(1, 9)) / norm
|
|
17
|
+
out.append(round(amplitude * env * x))
|
|
18
|
+
return out
|