rtpbench 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {rtpbench-0.3.0 → rtpbench-0.4.1}/PKG-INFO +13 -9
  2. {rtpbench-0.3.0 → rtpbench-0.4.1}/README.md +12 -8
  3. {rtpbench-0.3.0 → rtpbench-0.4.1}/pyproject.toml +1 -1
  4. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/bench.py +78 -6
  5. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/cases.py +2 -0
  6. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/cli.py +19 -4
  7. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/realtime.py +21 -3
  8. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/aivc/compose.yaml +3 -3
  9. rtpbench-0.4.1/targets/aivc/source.yaml +9 -0
  10. rtpbench-0.4.1/targets/ava/source.yaml +8 -0
  11. rtpbench-0.4.1/targets/livekit/source.yaml +10 -0
  12. rtpbench-0.4.1/tests/test_images.py +131 -0
  13. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_realtime.py +16 -0
  14. rtpbench-0.3.0/tests/test_images.py +0 -64
  15. {rtpbench-0.3.0 → rtpbench-0.4.1}/.dockerignore +0 -0
  16. {rtpbench-0.3.0 → rtpbench-0.4.1}/.gitignore +0 -0
  17. {rtpbench-0.3.0 → rtpbench-0.4.1}/Dockerfile +0 -0
  18. {rtpbench-0.3.0 → rtpbench-0.4.1}/LICENSE +0 -0
  19. {rtpbench-0.3.0 → rtpbench-0.4.1}/carrier/compose.yaml +0 -0
  20. {rtpbench-0.3.0 → rtpbench-0.4.1}/carrier/opensips.cfg +0 -0
  21. {rtpbench-0.3.0 → rtpbench-0.4.1}/carrier/tls.cfg +0 -0
  22. {rtpbench-0.3.0 → rtpbench-0.4.1}/carrier/tls.yaml +0 -0
  23. {rtpbench-0.3.0 → rtpbench-0.4.1}/compose.yaml +0 -0
  24. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/README.md +0 -0
  25. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-c1-0.wav +0 -0
  26. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-c1-1.wav +0 -0
  27. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-c2-0.wav +0 -0
  28. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-c2-1.wav +0 -0
  29. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-man-0.wav +0 -0
  30. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-man-1.wav +0 -0
  31. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-short-0.wav +0 -0
  32. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-short-1.wav +0 -0
  33. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/cough-woman_three_times-0.wav +0 -0
  34. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-en-dr-0.wav +0 -0
  35. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-en-dr-1.wav +0 -0
  36. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-en-sc-0.wav +0 -0
  37. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-en-sc-1.wav +0 -0
  38. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-en-sc-2.wav +0 -0
  39. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f012-0.wav +0 -0
  40. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f012-1.wav +0 -0
  41. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f012-2.wav +0 -0
  42. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f016-0.wav +0 -0
  43. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f016-1.wav +0 -0
  44. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/human-es-f016-2.wav +0 -0
  45. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/interrupt-00.wav +0 -0
  46. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/interrupt-01.wav +0 -0
  47. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/interrupt-02.wav +0 -0
  48. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/interrupt-03.wav +0 -0
  49. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-00.wav +0 -0
  50. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-01.wav +0 -0
  51. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-02.wav +0 -0
  52. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-03.wav +0 -0
  53. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-04.wav +0 -0
  54. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-05.wav +0 -0
  55. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-06.wav +0 -0
  56. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-07.wav +0 -0
  57. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-08.wav +0 -0
  58. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt-09.wav +0 -0
  59. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/prompt.wav +0 -0
  60. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/transfer.wav +0 -0
  61. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-00.wav +0 -0
  62. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-01.wav +0 -0
  63. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-02.wav +0 -0
  64. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-03.wav +0 -0
  65. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-04.wav +0 -0
  66. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-05.wav +0 -0
  67. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-06.wav +0 -0
  68. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-07.wav +0 -0
  69. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-08.wav +0 -0
  70. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-09.wav +0 -0
  71. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-10.wav +0 -0
  72. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/lines/en/turn-11.wav +0 -0
  73. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ald-bc.wav +0 -0
  74. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ald-long.wav +0 -0
  75. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ald-s03.wav +0 -0
  76. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ald-s07.wav +0 -0
  77. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ald-s11.wav +0 -0
  78. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-cori-bc.wav +0 -0
  79. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-cori-long.wav +0 -0
  80. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-cori-s01.wav +0 -0
  81. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-cori-s05.wav +0 -0
  82. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-cori-s09.wav +0 -0
  83. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-davefx-bc.wav +0 -0
  84. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-davefx-long.wav +0 -0
  85. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-davefx-s00.wav +0 -0
  86. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-davefx-s04.wav +0 -0
  87. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-davefx-s08.wav +0 -0
  88. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts0-bc.wav +0 -0
  89. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts0-long.wav +0 -0
  90. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts0-s02.wav +0 -0
  91. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts0-s06.wav +0 -0
  92. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts0-s10.wav +0 -0
  93. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts3-bc.wav +0 -0
  94. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts3-long.wav +0 -0
  95. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts3-s03.wav +0 -0
  96. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts3-s07.wav +0 -0
  97. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-libritts3-s11.wav +0 -0
  98. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ljspeech-bc.wav +0 -0
  99. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ljspeech-long.wav +0 -0
  100. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ljspeech-s00.wav +0 -0
  101. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ljspeech-s04.wav +0 -0
  102. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-ljspeech-s08.wav +0 -0
  103. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardf-bc.wav +0 -0
  104. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardf-long.wav +0 -0
  105. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardf-s02.wav +0 -0
  106. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardf-s06.wav +0 -0
  107. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardf-s10.wav +0 -0
  108. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardm-bc.wav +0 -0
  109. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardm-long.wav +0 -0
  110. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardm-s01.wav +0 -0
  111. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardm-s05.wav +0 -0
  112. {rtpbench-0.3.0 → rtpbench-0.4.1}/corpus/tts-sharvardm-s09.wav +0 -0
  113. {rtpbench-0.3.0 → rtpbench-0.4.1}/mock/compose.yaml +0 -0
  114. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/__init__.py +0 -0
  115. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/__main__.py +0 -0
  116. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/agent.py +0 -0
  117. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/analyze.py +0 -0
  118. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/bargein.py +0 -0
  119. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/caller.py +0 -0
  120. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/capture.py +0 -0
  121. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/check.py +0 -0
  122. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/clock.py +0 -0
  123. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/detect.py +0 -0
  124. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/g711.py +0 -0
  125. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/pcap.py +0 -0
  126. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/progress.py +0 -0
  127. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/report.py +0 -0
  128. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/rtp.py +0 -0
  129. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/sip.py +0 -0
  130. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/speech.py +0 -0
  131. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/speechcal.py +0 -0
  132. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/srtp.py +0 -0
  133. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/tls.py +0 -0
  134. {rtpbench-0.3.0 → rtpbench-0.4.1}/src/rtpbench/voice.py +0 -0
  135. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/aivc/aivc.ini +0 -0
  136. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/aivc/constraints.txt +0 -0
  137. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/aivc/opensips.cfg +0 -0
  138. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/ai-agent.yaml +0 -0
  139. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/ari.conf +0 -0
  140. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/compose.yaml +0 -0
  141. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/constraints.txt +0 -0
  142. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/extensions.conf +0 -0
  143. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/http.conf +0 -0
  144. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/pjsip.conf +0 -0
  145. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/ava/websocket_client.conf +0 -0
  146. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/livekit/agent.py +0 -0
  147. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/livekit/compose.yaml +0 -0
  148. {rtpbench-0.3.0 → rtpbench-0.4.1}/targets/livekit/constraints.txt +0 -0
  149. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_bargein.py +0 -0
  150. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_cases.py +0 -0
  151. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_check.py +0 -0
  152. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_cli.py +0 -0
  153. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_clock.py +0 -0
  154. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_progress.py +0 -0
  155. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_report.py +0 -0
  156. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_speech.py +0 -0
  157. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_speechcal.py +0 -0
  158. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_srtp.py +0 -0
  159. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_timing.py +0 -0
  160. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_tls.py +0 -0
  161. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_wheel.py +0 -0
  162. {rtpbench-0.3.0 → rtpbench-0.4.1}/tests/test_wire.py +0 -0
  163. {rtpbench-0.3.0 → rtpbench-0.4.1}/uri/advertise.yaml +0 -0
  164. {rtpbench-0.3.0 → rtpbench-0.4.1}/uri/network.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rtpbench
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Wire-level latency and conformance tests for voice agents over SIP/RTP
5
5
  Author-email: Juanjo Aroztegi <juanjo@aroztegi.com>
6
6
  License-Expression: Apache-2.0
@@ -33,11 +33,12 @@ without a carrier in front, on a mock engine that holds the AI engine's share of
33
33
  | | [LiveKit](https://github.com/livekit/sip) | [AI Voice Connector CE](https://github.com/OpenSIPS/opensips-ai-voice-connector-ce) at b95f5af | Asterisk + [AVA](https://github.com/hkjarral/AVA-AI-Voice-Agent-for-Asterisk) |
34
34
  | --- | --- | --- | --- |
35
35
  | cases failed of 24 | 5, and 2 unsupported | 5, 3 unsupported and 1 inconclusive | 4, and 3 inconclusive |
36
- | turn latency, clean, p50 | 1833 ms | 1511 ms | 1588 ms |
36
+ | turn latency, clean, p50 | 1833 ms | 1511 ms | 1589 ms |
37
37
  | its own media path per turn, p50 | 335 ms | 9 ms | 88 ms |
38
38
  | stops for an interruption, clean, p50 | 306 ms | 24 ms | never |
39
39
 
40
- The counts take every profile, direct and through the carrier. Run direct only, as
40
+ The counts take every profile, direct and through the carrier, and AVA as it ships, over
41
+ AudioSocket. Run direct only, as
41
42
  `target NAME --mock` does by default, LiveKit fails 4: `late-offer` fails only through the
42
43
  carrier. All three pass the 3 cases for lost SIP messages, run direct on a clean network.
43
44
  Unsupported is an optional feature declined as its RFC allows, with the call unharmed:
@@ -47,7 +48,8 @@ the digit never reaches the engine: a failure.
47
48
 
48
49
  - LiveKit keeps sending audio to a held caller in every kind of hold, AI Voice Connector CE when
49
50
  held with `c=0.0.0.0`, and both answer a re-INVITE that changes codec with the old codec. AVA
50
- sends RTP only while it speaks, so whether its media resumes after a hold cannot be judged.
51
+ sends RTP only while it speaks, so whether its media resumes after a hold cannot be judged,
52
+ except over Asterisk's WebSocket channel, where Asterisk keeps sending to a held caller.
51
53
  - LiveKit's extra latency is its own media path: 326 ms per turn more than AI Voice Connector
52
54
  CE's, against 322 ms between their turn latencies.
53
55
  - LiveKit's RTP falls 20 ms behind its own timestamps 4 s into every call, so a receiver needs a
@@ -55,10 +57,12 @@ the digit never reaches the engine: a failure.
55
57
  - AI Voice Connector CE delivers the engine's speech sample for sample. LiveKit re-encodes it on
56
58
  the way, through its WebRTC room, and on a clean network stretches or cuts a pause by 20-40 ms
57
59
  in 6% of answers (10% in a second wave).
58
- - AVA, as its golden configuration ships, cuts the last 0.8 s of every answer, and it never
59
- stops for an interruption: it keeps the caller's audio from the engine while it speaks. Over
60
- AudioSocket under packet loss, the engine sometimes got silence instead of the caller, so 5 more
61
- cases fail now and then.
60
+ - AVA, as its golden configuration ships, cuts the last 0.6 s of speech from every answer, over
61
+ each of its three transports, and over Asterisk's WebSocket channel its pacer also breaks every
62
+ answer with 7-10 short silences. It never stops for an interruption: it keeps the caller's
63
+ audio from the engine while it speaks. Over AudioSocket under packet loss, the engine sometimes
64
+ got silence instead of the caller (a squelch AVA has fixed since), so 5 more cases flip now
65
+ and then.
62
66
  - AI Voice Connector CE's main branch fixed 4 of the 10 cases it failed in the baseline (a failed
63
67
  transfer no longer hangs up on the caller; compact headers, a bare `a=ssrc` and SIP over TCP
64
68
  are taken), and now declines a DTMF INFO with 501 where it left it unanswered. It still
@@ -101,7 +105,7 @@ defaults and valid names.
101
105
 
102
106
  - [Calibration](docs/calibration.md): error bars, clock steps, network profiles and their sources
103
107
  - [Conformance cases](docs/cases.md): what each case checks
104
- - [Targets, your own agent, the mock engine and the carrier](docs/targets.md)
108
+ - [Targets (and adding one), your own agent, the mock engine and the carrier](docs/targets.md)
105
109
  - [CI](docs/ci.md): pass or fail your agent's run against a policy
106
110
 
107
111
  ## Development
@@ -18,11 +18,12 @@ without a carrier in front, on a mock engine that holds the AI engine's share of
18
18
  | | [LiveKit](https://github.com/livekit/sip) | [AI Voice Connector CE](https://github.com/OpenSIPS/opensips-ai-voice-connector-ce) at b95f5af | Asterisk + [AVA](https://github.com/hkjarral/AVA-AI-Voice-Agent-for-Asterisk) |
19
19
  | --- | --- | --- | --- |
20
20
  | cases failed of 24 | 5, and 2 unsupported | 5, 3 unsupported and 1 inconclusive | 4, and 3 inconclusive |
21
- | turn latency, clean, p50 | 1833 ms | 1511 ms | 1588 ms |
21
+ | turn latency, clean, p50 | 1833 ms | 1511 ms | 1589 ms |
22
22
  | its own media path per turn, p50 | 335 ms | 9 ms | 88 ms |
23
23
  | stops for an interruption, clean, p50 | 306 ms | 24 ms | never |
24
24
 
25
- The counts take every profile, direct and through the carrier. Run direct only, as
25
+ The counts take every profile, direct and through the carrier, and AVA as it ships, over
26
+ AudioSocket. Run direct only, as
26
27
  `target NAME --mock` does by default, LiveKit fails 4: `late-offer` fails only through the
27
28
  carrier. All three pass the 3 cases for lost SIP messages, run direct on a clean network.
28
29
  Unsupported is an optional feature declined as its RFC allows, with the call unharmed:
@@ -32,7 +33,8 @@ the digit never reaches the engine: a failure.
32
33
 
33
34
  - LiveKit keeps sending audio to a held caller in every kind of hold, AI Voice Connector CE when
34
35
  held with `c=0.0.0.0`, and both answer a re-INVITE that changes codec with the old codec. AVA
35
- sends RTP only while it speaks, so whether its media resumes after a hold cannot be judged.
36
+ sends RTP only while it speaks, so whether its media resumes after a hold cannot be judged,
37
+ except over Asterisk's WebSocket channel, where Asterisk keeps sending to a held caller.
36
38
  - LiveKit's extra latency is its own media path: 326 ms per turn more than AI Voice Connector
37
39
  CE's, against 322 ms between their turn latencies.
38
40
  - LiveKit's RTP falls 20 ms behind its own timestamps 4 s into every call, so a receiver needs a
@@ -40,10 +42,12 @@ the digit never reaches the engine: a failure.
40
42
  - AI Voice Connector CE delivers the engine's speech sample for sample. LiveKit re-encodes it on
41
43
  the way, through its WebRTC room, and on a clean network stretches or cuts a pause by 20-40 ms
42
44
  in 6% of answers (10% in a second wave).
43
- - AVA, as its golden configuration ships, cuts the last 0.8 s of every answer, and it never
44
- stops for an interruption: it keeps the caller's audio from the engine while it speaks. Over
45
- AudioSocket under packet loss, the engine sometimes got silence instead of the caller, so 5 more
46
- cases fail now and then.
45
+ - AVA, as its golden configuration ships, cuts the last 0.6 s of speech from every answer, over
46
+ each of its three transports, and over Asterisk's WebSocket channel its pacer also breaks every
47
+ answer with 7-10 short silences. It never stops for an interruption: it keeps the caller's
48
+ audio from the engine while it speaks. Over AudioSocket under packet loss, the engine sometimes
49
+ got silence instead of the caller (a squelch AVA has fixed since), so 5 more cases flip now
50
+ and then.
47
51
  - AI Voice Connector CE's main branch fixed 4 of the 10 cases it failed in the baseline (a failed
48
52
  transfer no longer hangs up on the caller; compact headers, a bare `a=ssrc` and SIP over TCP
49
53
  are taken), and now declines a DTMF INFO with 501 where it left it unanswered. It still
@@ -86,7 +90,7 @@ defaults and valid names.
86
90
 
87
91
  - [Calibration](docs/calibration.md): error bars, clock steps, network profiles and their sources
88
92
  - [Conformance cases](docs/cases.md): what each case checks
89
- - [Targets, your own agent, the mock engine and the carrier](docs/targets.md)
93
+ - [Targets (and adding one), your own agent, the mock engine and the carrier](docs/targets.md)
90
94
  - [CI](docs/ci.md): pass or fail your agent's run against a policy
91
95
 
92
96
  ## Development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rtpbench"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Wire-level latency and conformance tests for voice agents over SIP/RTP"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -9,6 +9,7 @@ import secrets
9
9
  import shlex
10
10
  import shutil
11
11
  import subprocess
12
+ import tempfile
12
13
  import textwrap
13
14
  import time
14
15
  from collections.abc import Callable, Iterator, Sequence
@@ -196,6 +197,57 @@ def _image_tag() -> str:
196
197
  return "rtpbench:" + h.hexdigest()[:12]
197
198
 
198
199
 
200
+ def _git(path: Path, *args: str) -> str:
201
+ res = subprocess.run(["git", "-C", str(path), *args], capture_output=True, text=True)
202
+ if res.returncode:
203
+ raise ValueError(f"not a git checkout: {res.stderr.strip()}")
204
+ return res.stdout
205
+
206
+
207
+ def _aside(path: Path, out: Path | None) -> Callable[[str], bool]:
208
+ """Whether a path in the checkout `path`, as git gives it, is in the results directory
209
+ `out`: a run writes there, so it is no part of the target."""
210
+ try:
211
+ rel = out.resolve().relative_to(path.resolve()).as_posix() + "/" if out else None
212
+ except ValueError: # not inside the checkout
213
+ rel = None
214
+ return lambda name: rel is not None and (name + "/").startswith(rel)
215
+
216
+
217
+ def checkout(path: Path, out: Path | None = None) -> dict[str, Any]:
218
+ """The commit a git checkout is at, and whether it has changes on top (the results
219
+ directory `out` aside): what a target built from it (`--source`) records."""
220
+ commit = _git(path, "rev-parse", "HEAD").strip()
221
+ aside = _aside(path, out)
222
+ fields = iter(_git(path, "status", "--porcelain", "-z", "--untracked-files=all").split("\0"))
223
+ changed = []
224
+ for e in fields:
225
+ if e[3:]:
226
+ changed.append(e[3:])
227
+ if set(e[:2]) & {"R", "C"} and (origin := next(fields, "")): # a field of its own
228
+ changed.append(origin)
229
+ return {"commit": commit, "dirty": any(not aside(name) for name in changed)}
230
+
231
+
232
+ def snapshot(path: Path, out: Path | None, dest: Path) -> None:
233
+ """Copy into `dest` what a target builds from the checkout `path`: the files git tracks and
234
+ those it would (untracked, not ignored), as they are now; not .git, not the results
235
+ directory `out`, not __pycache__ (which _build_hash leaves out too)."""
236
+ aside = _aside(path, out)
237
+ names = _git(path, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
238
+ for name in dict.fromkeys(names.split("\0")):
239
+ src = path / name
240
+ if not name or aside(name) or "__pycache__" in name.split("/"):
241
+ continue
242
+ if not (src.exists() or src.is_symlink()): # deleted
243
+ continue
244
+ (dest / name).parent.mkdir(parents=True, exist_ok=True)
245
+ if src.is_dir() and not src.is_symlink(): # a submodule
246
+ shutil.copytree(src, dest / name, symlinks=True, ignore=shutil.ignore_patterns(".git"))
247
+ else:
248
+ shutil.copy2(src, dest / name, follow_symlinks=False)
249
+
250
+
199
251
  def _build_hash(build: dict[str, Any]) -> str:
200
252
  """A hash of a compose `build` section as `config` resolves it, with each local context's
201
253
  files (all of them: the build may copy any) in place of its path, so the same from any
@@ -223,7 +275,9 @@ class Stack:
223
275
  `network`, and with `ports` (first-last) the host publishes that range on the router. With
224
276
  `tls`, out/tls holds a test certificate for the stack's TLS servers, and ca.pem, what the
225
277
  caller trusts: `ca`, or with no `uri` the stack's test CA; else the caller trusts the
226
- system's certificates. With `srtp`, the caller offers SRTP with these suites."""
278
+ system's certificates. With `srtp`, the caller offers SRTP with these suites. With
279
+ `source`, a git checkout, the target builds from a copy of its files that `down` removes
280
+ (targets/<name>/source.yaml)."""
227
281
 
228
282
  def __init__(
229
283
  self,
@@ -237,6 +291,7 @@ class Stack:
237
291
  tls: bool = False,
238
292
  ca: Path | None = None,
239
293
  srtp: list[str] | None = None,
294
+ source: Path | None = None,
240
295
  ) -> None:
241
296
  self.project = f"rtpbench-{secrets.token_hex(3)}"
242
297
  self.env = os.environ | {
@@ -259,6 +314,15 @@ class Stack:
259
314
  if target and mock:
260
315
  self.files.append(ROOT / "mock" / "compose.yaml")
261
316
  self.env |= MOCK[target]
317
+ self.source: Path | None = None # the copy built from
318
+ self.checkout: dict[str, Any] = {} # what it was copied from, as it was then
319
+ if target and source:
320
+ self.files.append(ROOT / "targets" / target / "source.yaml")
321
+ self.checkout = checkout(source, out)
322
+ self.source = Path(tempfile.mkdtemp(prefix="rtpbench-source-"))
323
+ snapshot(source, out, self.source)
324
+ self.env["RTPBENCH_SOURCE"] = str(self.source)
325
+ self.env["RTPBENCH_SOURCE_COMMIT"] = self.checkout["commit"][:7]
262
326
  if carrier:
263
327
  self.files.append(ROOT / "carrier" / "compose.yaml")
264
328
  self.env["RTPBENCH_CARRIER_NEXT"] = carrier
@@ -385,6 +449,8 @@ class Stack:
385
449
 
386
450
  def down(self) -> None:
387
451
  self.compose("down", "-t", "0", "-v", "--remove-orphans", check=False)
452
+ if self.source:
453
+ shutil.rmtree(self.source, ignore_errors=True)
388
454
  # the test certificate's key is no use to judging; the CAs stay with the results
389
455
  (Path(self.env["RTPBENCH_OUT"]) / "tls" / "server.key").unlink(missing_ok=True)
390
456
 
@@ -452,9 +518,10 @@ def _host(cmd: str) -> str:
452
518
 
453
519
 
454
520
  def _commit() -> str:
455
- """Counted from a release (results' wave tags would otherwise name the commit); installed,
456
- the release itself: there is no checkout to describe."""
457
- if ROOT == PACKAGE:
521
+ """Counted from a release (results' wave tags would otherwise name the commit); without a
522
+ checkout to describe (installed, or the action's copy of a commit, which has no .git), the
523
+ version it declares: the image tag names the code."""
524
+ if not (ROOT / ".git").exists(): # not `git describe`: it would find any repository above
458
525
  from importlib.metadata import version
459
526
 
460
527
  return f"v{version('rtpbench')}"
@@ -809,6 +876,7 @@ def target(
809
876
  ca: Path | None = None,
810
877
  srtp: list[str] | None = None,
811
878
  tls: bool = False,
879
+ source: Path | None = None,
812
880
  *,
813
881
  progress: Progress,
814
882
  ) -> str:
@@ -823,7 +891,8 @@ def target(
823
891
  next few to it), else with `network` its router's address there; over TLS (a sips: URI or
824
892
  proxy, or transport=tls) it trusts the certificates in `ca`, else the system's; with `srtp`
825
893
  the caller offers SRTP with these suites. A compose target takes `tls` and `srtp` with
826
- `carrier` only, which takes them on the caller's leg."""
894
+ `carrier` only, which takes them on the caller's leg, and builds from the git checkout
895
+ `source` if given."""
827
896
  from rtpbench import cases as cs
828
897
  from rtpbench import realtime
829
898
  from rtpbench.caller import ADVERTISE_PORTS, parse_uri
@@ -875,7 +944,8 @@ def target(
875
944
  stack.env["RTPBENCH_CARRIER_TRANSPORT"] = transport(name).lower()
876
945
  else:
877
946
  proxy_tls = False
878
- stack = Stack(out, name, "target" if carrier else None, mock, tls=tls, srtp=srtp)
947
+ next_hop = "target" if carrier else None
948
+ stack = Stack(out, name, next_hop, mock, tls=tls, srtp=srtp, source=source)
879
949
  logs = None
880
950
  try:
881
951
  stack.up(progress.phase, profiles)
@@ -912,6 +982,8 @@ def target(
912
982
  router_ip = stack.sh("target", "getent hosts router").split()[0]
913
983
  stack.sh("target", f"ip route replace {caller_ip} via {router_ip}")
914
984
  conds["engine"] = realtime.conditions() if mock else _engine(settings)
985
+ if source:
986
+ conds["source"] = stack.checkout
915
987
  if mock and (own := {k: v for k, v in settings.items() if k not in MOCK[name]}):
916
988
  conds["settings"] = _engine(own) # the target's own, such as AVA's pacer
917
989
  uri, proxy = f"sip:{user}@{target_ip}", stack.entry("operator")
@@ -366,6 +366,8 @@ def _hold(dial: Dial, seed: int, offers: list[tuple[str, str | None]]) -> dict[s
366
366
  if s.remote_bye:
367
367
  break
368
368
  m.muted = direction == "inactive"
369
+ if m.muted: # the sender builds each packet a tick ahead: let the last one out first
370
+ s.wait(lambda: s.remote_bye, 2 * rtp.PTIME_NS / 1e9)
369
371
  s.reinvite(direction, conn=conn)
370
372
  s.wait(lambda: s.remote_bye, HOLD_S)
371
373
  s.hangup()
@@ -8,8 +8,8 @@ import textwrap
8
8
  from datetime import datetime
9
9
  from pathlib import Path
10
10
 
11
- from rtpbench import analyze, cases, pcap
12
- from rtpbench.bench import PROFILES, TARGETS, docker, is_uri, network_problem
11
+ from rtpbench import ROOT, analyze, cases, pcap
12
+ from rtpbench.bench import PROFILES, TARGETS, checkout, docker, is_uri, network_problem
13
13
  from rtpbench.caller import GAP_SECONDS, LEAD_SECONDS
14
14
  from rtpbench.srtp import SUITES
15
15
 
@@ -62,9 +62,16 @@ def _target_args(args: argparse.Namespace) -> str | None:
62
62
  return "--tls, --srtp: with a URI, or through the carrier (--carrier)"
63
63
  if args.lines and args.mock:
64
64
  return "--lines: not with --mock, which recognises only the bundled lines"
65
+ if args.source:
66
+ if not (ROOT / "targets" / args.name / "source.yaml").exists():
67
+ return f"--source: {args.name} does not build from a checkout"
68
+ try:
69
+ checkout(args.source)
70
+ except ValueError as e:
71
+ return f"--source {args.source}: {e}"
65
72
  return None
66
- if args.mock or args.tls:
67
- return "--mock, --tls: with a bundled target only (a URI gives TLS as sips:)"
73
+ if args.mock or args.tls or args.source:
74
+ return "--mock, --tls, --source: with a bundled target only (a URI gives TLS as sips:)"
68
75
  if args.carrier:
69
76
  from rtpbench.sip import transport
70
77
 
@@ -236,6 +243,13 @@ examples:
236
243
  p.add_argument("--seed", type=int, default=1, help=seed)
237
244
  p.add_argument("--carrier", action="store_true", help="call through the carrier (Docker)")
238
245
  p.add_argument("--mock", action="store_true", help="the mock engine instead of the AI engine")
246
+ p.add_argument(
247
+ "--source",
248
+ type=Path,
249
+ metavar="DIR",
250
+ help="a bundled target built from this git checkout instead of its pinned version: of"
251
+ " AI Voice Connector CE (aivc), AVA (ava) or livekit/sip (livekit)",
252
+ )
239
253
  p.add_argument(
240
254
  "--lines",
241
255
  type=Path,
@@ -507,6 +521,7 @@ example:
507
521
  args.ca,
508
522
  _suites(args.srtp),
509
523
  args.tls,
524
+ args.source,
510
525
  progress=progress,
511
526
  )
512
527
  print(summary)
@@ -124,9 +124,24 @@ class Lines:
124
124
  # --- WebSocket (RFC 6455, server side: text frames out, masked frames in) ---
125
125
 
126
126
 
127
- async def _handshake(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> str:
127
+ async def _handshake(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> str | None:
128
+ """The request line once the WebSocket is open, or None for a plain HTTP request."""
128
129
  head = (await reader.readuntil(b"\r\n\r\n")).decode(errors="replace").split("\r\n")
129
130
  headers = {k.strip().lower(): v.strip() for k, _, v in (h.partition(":") for h in head[1:])}
131
+ if "sec-websocket-key" not in headers:
132
+ # Some agents check their key against the model list before they connect.
133
+ method, _, rest = head[0].partition(" ")
134
+ path = rest.partition(" ")[0]
135
+ found = method == "GET" and path.split("?")[0].rstrip("/").endswith("/models")
136
+ body = b'{"object": "list", "data": []}' if found else b""
137
+ status = "200 OK" if found else "404 Not Found"
138
+ writer.write(
139
+ f"HTTP/1.1 {status}\r\nContent-Type: application/json\r\n"
140
+ f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode()
141
+ + body
142
+ )
143
+ await writer.drain()
144
+ return None
130
145
  key = headers["sec-websocket-key"].encode()
131
146
  accept = base64.b64encode(hashlib.sha1(key + _GUID).digest()).decode()
132
147
  writer.write(
@@ -532,7 +547,9 @@ def serve(out: Path, port: int = PORT, ready: Path | None = None) -> None:
532
547
  async def connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
533
548
  try:
534
549
  request = await _handshake(reader, writer)
535
- except asyncio.IncompleteReadError, KeyError, ConnectionError:
550
+ except asyncio.IncompleteReadError, ConnectionError:
551
+ request = None
552
+ if request is None:
536
553
  writer.close()
537
554
  return
538
555
  s = Session(writer, lines, log, steps)
@@ -935,7 +952,8 @@ def uplink(out: Path) -> str:
935
952
  continue
936
953
  st = analyze.stream(up, w.log["pt"])
937
954
  for turn in w.log["turns"]:
938
- for spans in (turn.get("prompt_speech"), turn["speech"]):
955
+ # a barge-in turn whose prompt got no reply has no interjection
956
+ for spans in (turn.get("prompt_speech"), turn.get("speech")):
939
957
  if not spans:
940
958
  continue
941
959
  a, b = st.index(w.log["seq0"], spans[0][0]), st.index(w.log["seq0"], spans[-1][1])
@@ -13,12 +13,12 @@ services:
13
13
  command: sleep infinity
14
14
 
15
15
  opensips:
16
- image: opensips/opensips:3.6@sha256:81a7fa3c7bdbceaadb7cb869ff7ac3f34efb2d95cd7fecb3b689d7b6b1eb0672
16
+ image: opensips/opensips:4.0@sha256:e1d8151b96c00d52c9bd8f102ebc9e7c90fc4e44fe8df117b56009bebbeff7a0
17
17
  network_mode: service:target
18
18
  depends_on: [target]
19
19
  volumes: ["./targets/aivc/opensips.cfg:/etc/opensips/opensips.cfg:ro"]
20
20
  healthcheck:
21
- test: opensips-cli -x mi sr_get_status core | grep -q running
21
+ test: opensips-cli -x mi status_report:get core | grep -q running
22
22
  interval: 1s
23
23
  retries: 20
24
24
 
@@ -54,7 +54,7 @@ services:
54
54
  - -c
55
55
  - >-
56
56
  import json, socket; s = socket.socket(2, 2); s.settimeout(1);
57
- s.sendto(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "subscribers_list",
57
+ s.sendto(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "evi:subscribers",
58
58
  "params": ["E_UA_SESSION"]}).encode(), ("127.0.0.1", 8080));
59
59
  assert json.loads(s.recv(65535))["result"]["Event"]["Subscribers"]
60
60
  interval: 1s
@@ -0,0 +1,9 @@
1
+ # `rtpbench target aivc --source DIR`: the connector built from the checkout DIR with upstream's
2
+ # own Dockerfile, as the project builds it, in place of the pinned commit. Merged over
3
+ # compose.yaml; the tag follows the checkout's commit and, as every build's, a hash of the build.
4
+ services:
5
+ engine:
6
+ build: !override
7
+ context: ${RTPBENCH_SOURCE:?}
8
+ dockerfile: docker/Dockerfile
9
+ image: ${RTPBENCH_REGISTRY:?}/rtpbench-aivc:${RTPBENCH_SOURCE_COMMIT:?}${RTPBENCH_BUILD_ENGINE:-}
@@ -0,0 +1,8 @@
1
+ # `rtpbench target ava --source DIR`: AVA built from the checkout DIR with upstream's own
2
+ # Dockerfile, as the project builds it, in place of the pinned commit. Merged over compose.yaml;
3
+ # the tag follows the checkout's commit and, as every build's, a hash of the build.
4
+ services:
5
+ engine:
6
+ build: !override
7
+ context: ${RTPBENCH_SOURCE:?}
8
+ image: ${RTPBENCH_REGISTRY:?}/rtpbench-ava:${RTPBENCH_SOURCE_COMMIT:?}${RTPBENCH_BUILD_ENGINE:-}
@@ -0,0 +1,10 @@
1
+ # `rtpbench target livekit --source DIR`: the SIP bridge built from a checkout DIR of
2
+ # livekit/sip with upstream's own Dockerfile, in place of its pinned image; the server and the
3
+ # Agents worker stay pinned. Merged over compose.yaml; the tag follows the checkout's commit and,
4
+ # as every build's, a hash of the build.
5
+ services:
6
+ sip:
7
+ build:
8
+ context: ${RTPBENCH_SOURCE:?}
9
+ dockerfile: build/sip/Dockerfile
10
+ image: ${RTPBENCH_REGISTRY:?}/rtpbench-livekit-sip:${RTPBENCH_SOURCE_COMMIT:?}${RTPBENCH_BUILD_SIP:-}
@@ -0,0 +1,131 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from rtpbench import ROOT, bench
10
+ from rtpbench.bench import TARGETS, Stack
11
+
12
+
13
+ @pytest.fixture
14
+ def root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
15
+ """A copy of the checkout's stack files and package, which bench reads instead."""
16
+ root = tmp_path / "root"
17
+ root.mkdir()
18
+ for name in ("compose.yaml", "Dockerfile"):
19
+ shutil.copyfile(ROOT / name, root / name)
20
+ for name in ("corpus", "uri", "mock", "carrier", "targets", "src"):
21
+ shutil.copytree(ROOT / name, root / name)
22
+ monkeypatch.setattr(bench, "ROOT", root)
23
+ monkeypatch.setattr(bench, "PACKAGE", root / "src" / "rtpbench")
24
+ for k in ("OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_KEY"):
25
+ monkeypatch.setenv(k, "-")
26
+ return root
27
+
28
+
29
+ def builds(root: Path, target: str | None) -> dict[str, str]:
30
+ return Stack(root / "out", target, carrier="agent").builds()
31
+
32
+
33
+ @pytest.mark.parametrize("target", [None, *TARGETS])
34
+ def test_compose_tags_each_built_image_with_the_hash_of_its_build(
35
+ root: Path, target: str | None
36
+ ) -> None:
37
+ stack = Stack(root / "out", target, carrier="agent")
38
+ built = stack.builds()
39
+ services = json.loads(stack.compose("config", "--format", "json"))["services"]
40
+ assert built == {name: s["image"] for name, s in services.items() if "build" in s}
41
+ assert built["router"] == f"{bench.REGISTRY}/{bench._image_tag()}"
42
+ for name, image in built.items():
43
+ if name != "router": # its own content hash
44
+ assert re.fullmatch(r"[^:]+:[^:]+-[0-9a-f]{12}", image), image
45
+
46
+
47
+ def test_editing_a_build_changes_its_image_s_tag_and_an_unrelated_file_none(root: Path) -> None:
48
+ before = builds(root, "ava")
49
+ agent = root / "targets" / "livekit" / "agent.py" # not in this stack
50
+ agent.write_text(agent.read_text() + "# edited\n")
51
+ assert builds(root, "ava") == before
52
+
53
+ compose = root / "targets" / "ava" / "compose.yaml"
54
+ old = "ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1" # the engine's dockerfile_inline
55
+ assert old in compose.read_text()
56
+ compose.write_text(compose.read_text().replace(old, "ENV PYTHONUNBUFFERED=1"))
57
+ after = builds(root, "ava")
58
+ assert after["engine"] != before["engine"]
59
+ assert after["engine"].startswith(before["engine"].rpartition("-")[0] + "-") # pin kept
60
+ for name in ("router", "carrier-media", "carrier-sip"):
61
+ assert after[name] == before[name]
62
+
63
+ constraints = root / "targets" / "ava" / "constraints.txt" # the engine's pins context
64
+ constraints.write_text(constraints.read_text() + "\n")
65
+ assert builds(root, "ava")["engine"] != after["engine"]
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ ("target", "service"), [("aivc", "engine"), ("ava", "engine"), ("livekit", "sip")]
70
+ )
71
+ def test_a_target_built_from_a_checkout_is_tagged_by_its_commit_and_files(
72
+ root: Path, tmp_path: Path, target: str, service: str
73
+ ) -> None:
74
+ src = tmp_path / "checkout"
75
+ src.mkdir()
76
+ (src / "main.py").write_text("print('hi')\n")
77
+ (src / ".gitignore").write_text(".venv/\n")
78
+ git = ["git", "-C", str(src), "-c", "user.name=t", "-c", "user.email=t@t"]
79
+ for cmd in (["init", "-q"], ["add", "."], ["commit", "-qm", "x"]):
80
+ subprocess.run([*git, *cmd], check=True)
81
+ out = src / "results" / "run" # a run's results inside the checkout: no part of it
82
+ out.mkdir(parents=True)
83
+
84
+ def files(stack: Stack) -> list[Path]:
85
+ return [f for f in (stack.source or Path()).rglob("*") if f.is_file()]
86
+
87
+ def tag() -> str:
88
+ stack = Stack(out, target, carrier="agent", source=src)
89
+ try:
90
+ copied = {f.relative_to(stack.source or "").as_posix() for f in files(stack)}
91
+ assert copied == {".gitignore", "main.py"} # not .git, .venv or the results
92
+ return stack.builds()[service]
93
+ finally:
94
+ shutil.rmtree(stack.source or "")
95
+
96
+ pinned = builds(root, target).get(service, "")
97
+ clean = tag()
98
+ commit = bench.checkout(src, out)
99
+ assert re.fullmatch(rf".+:{commit['commit'][:7]}-[0-9a-f]{{12}}", clean), clean
100
+ assert clean != pinned
101
+ (out / "caller.pcap").write_bytes(b"x")
102
+ (src / ".venv").mkdir()
103
+ (src / ".venv" / "lib.py").write_text("ignored\n")
104
+ assert tag() == clean
105
+ assert bench.checkout(src, out)["dirty"] is False
106
+ assert bench.checkout(src)["dirty"] is True # the results are untracked files to git
107
+
108
+ (src / "__pycache__").mkdir()
109
+ (src / "__pycache__" / "main.pyc").write_bytes(b"x")
110
+ subprocess.run([*git, "add", "-f", "__pycache__"], check=True) # tracked: still left out
111
+ assert tag() == clean
112
+ subprocess.run([*git, "rm", "-rq", "--cached", "__pycache__"], check=True)
113
+ shutil.rmtree(src / "__pycache__")
114
+ (out / "a.txt").write_text("a\n") # a rename within the results: two fields, both aside
115
+ subprocess.run([*git, "add", "-f", str(out / "a.txt")], check=True)
116
+ subprocess.run([*git, "commit", "-qm", "results"], check=True)
117
+ subprocess.run([*git, "mv", str(out / "a.txt"), str(out / "b.txt")], check=True)
118
+ assert bench.checkout(src, out)["dirty"] is False
119
+
120
+ (src / "main.py").write_text("print('changed')\n") # uncommitted: same commit, new files
121
+ assert bench.checkout(src, out)["dirty"] is True
122
+ assert tag() != clean
123
+ with pytest.raises(ValueError, match="not a git checkout"):
124
+ bench.checkout(tmp_path)
125
+
126
+
127
+ def test_a_copy_without_git_records_its_version_as_the_commit(root: Path) -> None:
128
+ from importlib.metadata import version
129
+
130
+ assert not (root / ".git").exists() # as the action unpacks a commit
131
+ assert bench._commit() == f"v{version('rtpbench')}"
@@ -82,6 +82,22 @@ def test_a_port_in_use_is_one_line_naming_the_option(tmp_path: Path) -> None:
82
82
  assert "\n" not in str(e.value)
83
83
 
84
84
 
85
+ def test_a_model_list_request_gets_an_empty_list(engine: tuple[int, Path]) -> None:
86
+ port, _ = engine
87
+
88
+ async def get(path: str) -> bytes:
89
+ reader, writer = await asyncio.open_connection("127.0.0.1", port)
90
+ writer.write(f"GET {path} HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer x\r\n\r\n".encode())
91
+ reply = await reader.read()
92
+ writer.close()
93
+ return reply
94
+
95
+ head, _, body = asyncio.run(get("/v1/models")).partition(b"\r\n\r\n")
96
+ assert head.startswith(b"HTTP/1.1 200")
97
+ assert json.loads(body) == {"object": "list", "data": []}
98
+ assert asyncio.run(get("/v1/realtime")).startswith(b"HTTP/1.1 404")
99
+
100
+
85
101
  def test_lines_are_told_apart_and_answered() -> None:
86
102
  lines = realtime.Lines()
87
103
  for name in ("transfer", "prompt-03", "turn-05", "interrupt-01"):