From b6eadc8e570b3011c63cddc4bc3739031938181f Mon Sep 17 00:00:00 2001 From: Fedir Zadniprovskyi Date: Mon, 17 Feb 2025 14:04:49 -0800 Subject: [PATCH 1/4] deps: add aiortc --- pyproject.toml | 1 + uv.lock | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 27a4de7..aab6762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "aiostream>=0.6.4", "cachetools>=5.5.1", "httpx-ws>=0.7.1", + "aiortc>=1.10.1", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 0a07366..4ce82f9 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/19/5af6804c4cc0fed83f47bff6e413a98a36618e7d40185cd36e69737f3b0e/aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107", size = 15727 }, ] +[[package]] +name = "aioice" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/b6/e2b0e48ccb5b04fe29265e93f14a0915f416e359c897ae87d570566c430b/aioice-0.9.0.tar.gz", hash = "sha256:fc2401b1c4b6e19372eaaeaa28fd1bd9cbf6b0e412e48625297c53b495eebd1e", size = 40324 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/35/d21e48d3ba25d32aba5d142d54c4491376c659dd74d052a30dd25198007b/aioice-0.9.0-py3-none-any.whl", hash = "sha256:b609597a3a5a611e0004ff04772e16aceb881d51c25c0afc4ceac05d5e50024e", size = 24177 }, +] + +[[package]] +name = "aiortc" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aioice" }, + { name = "av" }, + { name = "cffi" }, + { name = "cryptography" }, + { name = "google-crc32c" }, + { name = "pyee" }, + { name = "pylibsrtp" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/f8/408e092748521889c9d33dddcef920afd9891cf6db4615ba6b6bfe114ff8/aiortc-1.10.1.tar.gz", hash = "sha256:64926ad86bde20c1a4dacb7c3a164e57b522606b70febe261fada4acf79641b5", size = 1179406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/6b/74547a30d1ddcc81f905ef4ff7fcc2c89b7482cb2045688f2aaa4fa918aa/aiortc-1.10.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3bef536f38394b518aefae9dbf9cdd08f39e4c425f316f9692f0d8dc724810bd", size = 1218457 }, + { url = "https://files.pythonhosted.org/packages/46/92/b4ccf39cd18e366ace2a11dc7d98ed55967b4b325707386b5788149db15e/aiortc-1.10.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8842c02e38513d9432ef22982572833487bb015f23348fa10a690616dbf55143", size = 898855 }, + { url = "https://files.pythonhosted.org/packages/a4/e9/2676de48b493787d8b03129713e6bb2dfbacca2a565090f2a89cbad71f96/aiortc-1.10.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:954a420de01c0bf6b07a0c58b662029b1c4204ddbd8f5c4162bbdebd43f882b1", size = 1750403 }, + { url = "https://files.pythonhosted.org/packages/c3/9d/ab6d09183cdaf5df060923d9bd5c9ed5fb1802661d9401dba35f3c85a57b/aiortc-1.10.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7c0d46fb30307a9d7deb4b7d66f0b0e73b77a7221b063fb6dc78821a5d2aa1e", size = 1867886 }, + { url = "https://files.pythonhosted.org/packages/c2/71/0b5666e6b965dbd9a7f331aa827a6c3ab3eb4d582fefb686a7f4227b7954/aiortc-1.10.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89582f6923046f79f15d9045f432bc78191eacc95f6bed18714e86ec935188d9", size = 1893709 }, + { url = "https://files.pythonhosted.org/packages/9d/0a/8c0c78fad79ef595a0ed6e2ab413900e6bd0eac65fc5c31c9d8736bff909/aiortc-1.10.1-cp39-abi3-win32.whl", hash = "sha256:d1cbe87f740b33ffaa8e905f21092773e74916be338b64b81c8b79af4c3847eb", size = 923265 }, + { url = "https://files.pythonhosted.org/packages/73/12/a27dd588a4988021da88cb4d338d8ee65ac097afc14e9193ab0be4a48790/aiortc-1.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c9a5a0b23f8a77540068faec8837fa0a65b0396c20f09116bdb874b75e0b6abe", size = 1009488 }, +] + [[package]] name = "aiostream" version = "0.6.4" @@ -281,6 +319,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 }, ] +[[package]] +name = "cryptography" +version = "44.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022 }, + { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 }, + { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 }, + { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 }, + { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 }, + { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 }, + { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 }, + { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 }, + { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 }, + { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882 }, + { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989 }, + { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714 }, + { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 }, + { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 }, + { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 }, + { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 }, + { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 }, + { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 }, + { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 }, + { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 }, + { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371 }, + { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303 }, +] + [[package]] name = "csvw" version = "3.5.1" @@ -519,6 +592,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, ] +[[package]] +name = "google-crc32c" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/72/c3298da1a3773102359c5a78f20dae8925f5ea876e37354415f68594a6fb/google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc", size = 14472 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/41/65a91657d6a8123c6c12f9aac72127b6ac76dda9e2ba1834026a842eb77c/google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d", size = 30268 }, + { url = "https://files.pythonhosted.org/packages/59/d0/ee743a267c7d5c4bb8bd865f7d4c039505f1c8a4b439df047fdc17be9769/google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b", size = 30113 }, + { url = "https://files.pythonhosted.org/packages/25/53/e5e449c368dd26ade5fb2bb209e046d4309ed0623be65b13f0ce026cb520/google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00", size = 32995 }, + { url = "https://files.pythonhosted.org/packages/52/12/9bf6042d5b0ac8c25afed562fb78e51b0641474097e4139e858b45de40a5/google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3", size = 32614 }, + { url = "https://files.pythonhosted.org/packages/76/29/fc20f5ec36eac1eea0d0b2de4118c774c5f59c513f2a8630d4db6991f3e0/google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760", size = 33445 }, +] + [[package]] name = "googleapis-common-protos" version = "1.65.0" @@ -746,6 +832,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314 }, +] + [[package]] name = "importlib-metadata" version = "8.4.0" @@ -1935,6 +2030,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 }, ] +[[package]] +name = "pyee" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/8fb6e653597b2b67ef552ed49b438d5398ba3b85a9453f8ada0fd77d455c/pyee-12.1.1.tar.gz", hash = "sha256:bbc33c09e2ff827f74191e3e5bbc6be7da02f627b7ec30d86f5ce1a6fb2424a3", size = 30915 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/68/7e150cba9eeffdeb3c5cecdb6896d70c8edd46ce41c0491e12fb2b2256ff/pyee-12.1.1-py3-none-any.whl", hash = "sha256:18a19c650556bb6b32b406d7f017c8f513aceed1ef7ca618fb65de7bd2d347ef", size = 15527 }, +] + [[package]] name = "pygments" version = "2.18.0" @@ -1950,6 +2057,24 @@ version = "2.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5d/ab/34ec41718af73c00119d0351b7a2531d2ebddb51833a36448fc7b862be60/pylatexenc-2.10.tar.gz", hash = "sha256:3dd8fd84eb46dc30bee1e23eaab8d8fb5a7f507347b23e5f38ad9675c84f40d3", size = 162597 } +[[package]] +name = "pylibsrtp" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/49/1c5101ecfeda540699e0754dddfc91c401fbf736ebe99d66e59fe3dad2ba/pylibsrtp-0.11.0.tar.gz", hash = "sha256:5a8d19b1448baebde5ae3cedfa51f10e8ada3d9d99f43046ced0ecf1c105b8ec", size = 10786 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/95/65650bf56e1080beb5f7c963a0bb11a6ee7599bfd89b33ff4525d2b5824b/pylibsrtp-0.11.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36c6b33347d47c889b7dd465c6ae1f44d7705d00436ca613fd2a8f5dd401b104", size = 1727506 }, + { url = "https://files.pythonhosted.org/packages/4e/b0/f12c489ea8716e74343559abc5d0dfb94d66bcfe1924d64d58424a50f496/pylibsrtp-0.11.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cf18b80f9513484a70e55136ece6ec80e7d21c03cc69abbb428e4f2745ca3cee", size = 2058008 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6040cd6da6f82f3aa1763c8c45f7fcfdfe08db5560c73f5e1deb4c36c2bb/pylibsrtp-0.11.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81bbe0cd777979f7fc45c85f0c619c9cbe709faffbf91675d9dcce560734b353", size = 2566705 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/fd313ac3a23e9c45493131d9fa3463770289e59bb8422c6c6877ab3add40/pylibsrtp-0.11.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78fcdfe63925ea9a5017884c31fe9687b9b8b9f7d9beb7e25e3be47aa6ece495", size = 2168163 }, + { url = "https://files.pythonhosted.org/packages/f9/b3/ae0bac50cc0cca4b8c14de8063ba410ed3edd82c71a2315f284c9be7d679/pylibsrtp-0.11.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1909f7e781a7675d5c92cbad9e7ed3642e626e2bea5834243e423976e5420ac3", size = 2224343 }, + { url = "https://files.pythonhosted.org/packages/51/c4/650c2cecd5810f84adc89f3a94a28ea02d7ac8eaf3ee718a629c6f8ebf09/pylibsrtp-0.11.0-cp39-abi3-win32.whl", hash = "sha256:15123cecd377248747c95de9305ac314f3bcccdae46022bb4b9d60a552a26a10", size = 1156330 }, + { url = "https://files.pythonhosted.org/packages/fe/78/724307095b95c937e54c48133be3e85779cebea770f7536be555217b31f2/pylibsrtp-0.11.0-cp39-abi3-win_amd64.whl", hash = "sha256:bea2fb98029d19de516538b13c4827b6474d6f85d9ea50fae349e9671b946f7a", size = 1486448 }, +] + [[package]] name = "pymdown-extensions" version = "10.11.1" @@ -4378,6 +4503,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/5a/c9620198d192abc6e8e694301507a3e6c8c76f4d3aac3bac24414f807c95/pyobjc_framework_WebKit-10.3.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:c8e3334978a1bd927ea14ed73f56d6741561a69d31d082d2b23d1b48446917c8", size = 44697 }, ] +[[package]] +name = "pyopenssl" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/26/e25b4a374b4639e0c235527bbe31c0524f26eda701d79456a7e1877f4cc5/pyopenssl-25.0.0.tar.gz", hash = "sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16", size = 179573 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d7/eb76863d2060dcbe7c7e6cccfd95ac02ea0b9acc37745a0d99ff6457aefb/pyOpenSSL-25.0.0-py3-none-any.whl", hash = "sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90", size = 56453 }, +] + [[package]] name = "pyparsing" version = "3.2.1" @@ -4822,6 +4960,7 @@ name = "speaches" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiortc" }, { name = "aiostream" }, { name = "cachetools" }, { name = "ctranslate2" }, @@ -4889,6 +5028,7 @@ ui = [ [package.metadata] requires-dist = [ + { name = "aiortc", specifier = ">=1.10.1" }, { name = "aiostream", specifier = ">=0.6.4" }, { name = "anyio", marker = "extra == 'dev'", specifier = ">=4.4.0" }, { name = "basedpyright", marker = "extra == 'dev'", specifier = ">=1.26.0" }, From d59455141483b00708631a0fcd2ec1fcf1b277bf Mon Sep 17 00:00:00 2001 From: Fedir Zadniprovskyi Date: Mon, 17 Feb 2025 14:04:50 -0800 Subject: [PATCH 2/4] feat/realtime: add webrtc support --- src/speaches/main.py | 4 + .../realtime/rtc/audio_stream_track.py | 184 +++++++++++++ src/speaches/routers/realtime/rtc.py | 249 ++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 src/speaches/realtime/rtc/audio_stream_track.py create mode 100644 src/speaches/routers/realtime/rtc.py diff --git a/src/speaches/main.py b/src/speaches/main.py index fa71487..50f86a5 100644 --- a/src/speaches/main.py +++ b/src/speaches/main.py @@ -19,6 +19,9 @@ from speaches.routers.models import ( router as models_router, ) +from speaches.routers.realtime.rtc import ( + router as realtime_rtc_router, +) from speaches.routers.realtime.ws import ( router as realtime_ws_router, ) @@ -66,6 +69,7 @@ def create_app() -> FastAPI: app.include_router(stt_router) app.include_router(models_router) app.include_router(misc_router) + app.include_router(realtime_rtc_router) app.include_router(realtime_ws_router) app.include_router(speech_router) app.include_router(vad_router) diff --git a/src/speaches/realtime/rtc/audio_stream_track.py b/src/speaches/realtime/rtc/audio_stream_track.py new file mode 100644 index 0000000..38c5f29 --- /dev/null +++ b/src/speaches/realtime/rtc/audio_stream_track.py @@ -0,0 +1,184 @@ +import asyncio +import base64 +import io +import logging + +from aiortc import MediaStreamTrack +from av.audio.frame import AudioFrame +import numpy as np +from openai.types.beta.realtime import ResponseAudioDeltaEvent +from opentelemetry import trace + +from speaches.audio import audio_samples_from_file +from speaches.realtime.context import SessionContext +from speaches.realtime.input_audio_buffer_event_router import resample_audio_data + +logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) + + +class AudioStreamTrack(MediaStreamTrack): + kind = "audio" + + def __init__(self, ctx: SessionContext) -> None: + super().__init__() + self.ctx = ctx + # self.q = ctx.pubsub.subscribe() + self.frame_queue = asyncio.Queue() # Queue for AudioFrames + self._timestamp = 0 + self._sample_rate = 48000 + self._frame_duration = 0.01 # in seconds + self._samples_per_frame = int(self._sample_rate * self._frame_duration) + self._running = True + + # Start the frame processing task + self._process_task = asyncio.create_task(self._audio_frame_generator()) + + async def recv(self) -> AudioFrame: + """Receive the next audio frame.""" + if not self._running: + raise MediaStreamError("Track has ended") # noqa: EM101 + + try: + frame = await self.frame_queue.get() + await asyncio.sleep( + 0.005 + ) # NOTE: I believe some delay is neccessary to prevent buffers from being dropped. + except asyncio.CancelledError as e: + raise MediaStreamError("Track has ended") from e # noqa: EM101 + else: + return frame + + async def _audio_frame_generator(self) -> None: + """Process incoming numpy arrays and split them into AudioFrames.""" + try: + async for event in self.ctx.pubsub.subscribe_to("response.audio.delta"): + assert isinstance(event, ResponseAudioDeltaEvent) + + if not self._running: + return + + # copied from `input_audio_buffer.append` handler + audio_array = audio_samples_from_file(io.BytesIO(base64.b64decode(event.delta))) + audio_array = resample_audio_data(audio_array, 24000, 48000) + + # Convert to int16 if not already + if audio_array.dtype != np.int16: + audio_array = (audio_array * 32767).astype(np.int16) + + # Split the array into frame-sized chunks + frames = self._split_into_frames(audio_array) + + # Create AudioFrames and add them to the frame queue + logger.info(f"Received audio: {len(audio_array)} samples") + logger.info(f"Split into {len(frames)} frames") + for frame_data in frames: + frame = self._create_frame(frame_data) + self.frame_queue.put_nowait(frame) + + except asyncio.CancelledError: + logger.warning("Audio frame generator task cancelled") + + def _split_into_frames(self, audio_array: np.ndarray) -> list[np.ndarray]: + # Ensure the array is 1D + if len(audio_array.shape) > 1: + audio_array = audio_array.flatten() + + # Calculate number of complete frames + n_frames = len(audio_array) // self._samples_per_frame + + frames = [] + for i in range(n_frames): + start = i * self._samples_per_frame + end = start + self._samples_per_frame + frame = audio_array[start:end] + frames.append(frame) + + remaining = len(audio_array) % self._samples_per_frame + if remaining > 0: + logger.info(f"Processing remaining {remaining} samples") + last_frame = audio_array[-remaining:] + padded_frame = np.pad(last_frame, (0, self._samples_per_frame - remaining), "constant", constant_values=0) + logger.info(f"Padded frame range: {padded_frame.min()}, {padded_frame.max()}") + frames.append(padded_frame) + + return frames + + def _create_frame(self, frame_data: np.ndarray) -> AudioFrame: + """Create an AudioFrame from numpy array data. + + Args: + frame_data: Numpy array containing exactly samples_per_frame samples + + Returns: + AudioFrame object + + """ + frame = AudioFrame( + format="s16", + layout="mono", + samples=self._samples_per_frame, + ) + frame.sample_rate = self._sample_rate + + # Convert numpy array to bytes and update frame + frame.planes[0].update(frame_data.tobytes()) + + # Set timestamp + frame.pts = self._timestamp + self._timestamp += self._samples_per_frame + + return frame + + def stop(self) -> None: + """Stop the audio track and cleanup.""" + self._running = False + if hasattr(self, "_process_task"): + self._process_task.cancel() + super().stop() + + +class ToneAudioStreamTrack(MediaStreamTrack): + kind = "audio" + + def __init__(self) -> None: + super().__init__() + self._timestamp = 0 + self._sample_rate = 24000 + self._samples_per_frame = self._sample_rate // 100 # 10ms + self._running = True + self._frequency = 440 # 440 Hz tone + + async def recv(self) -> AudioFrame: + if not self._running: + raise MediaStreamError("Track has ended") # noqa: EM101 + + # Generate sine wave for this frame + t = np.linspace(0, self._samples_per_frame / self._sample_rate, self._samples_per_frame) + samples = np.sin(2 * np.pi * self._frequency * t) + samples = (samples * 32767).astype(np.int16) + + # Create frame + frame = AudioFrame( + format="s16", + layout="mono", + samples=self._samples_per_frame, + ) + frame.sample_rate = self._sample_rate + frame.pts = self._timestamp + frame.planes[0].update(samples.tobytes()) + + self._timestamp += self._samples_per_frame + + # Sleep for frame duration + await asyncio.sleep(0.01) # 10ms + + return frame + + def stop(self) -> None: + self._running = False + super().stop() + + +class MediaStreamError(Exception): + pass diff --git a/src/speaches/routers/realtime/rtc.py b/src/speaches/routers/realtime/rtc.py new file mode 100644 index 0000000..c1db618 --- /dev/null +++ b/src/speaches/routers/realtime/rtc.py @@ -0,0 +1,249 @@ +import asyncio +import base64 +import json +import logging +from pathlib import Path +import time + +from aiortc import RTCDataChannel, RTCPeerConnection, RTCSessionDescription +from aiortc.rtcrtpreceiver import RemoteStreamTrack +from aiortc.sdp import SessionDescription +from av.audio.frame import AudioFrame +from av.audio.resampler import AudioResampler +from fastapi import ( + APIRouter, + Request, + Response, +) +import numpy as np +from openai import AsyncOpenAI +from openai.types.beta.realtime.error_event import Error +from pydantic import ValidationError + +from speaches.dependencies import ( + ConfigDependency, + TranscriptionClientDependency, +) +from speaches.realtime.context import SessionContext +from speaches.realtime.conversation_event_router import event_router as conversation_event_router +from speaches.realtime.event_router import EventRouter +from speaches.realtime.input_audio_buffer_event_router import ( + event_router as input_audio_buffer_event_router, +) +from speaches.realtime.response_event_router import event_router as response_event_router +from speaches.realtime.rtc.audio_stream_track import AudioStreamTrack +from speaches.realtime.session import create_session_object_configuration +from speaches.realtime.session_event_router import event_router as session_event_router +from speaches.routers.realtime.ws import event_listener +from speaches.types.realtime import ( + SERVER_EVENT_TYPES, + ErrorEvent, + InputAudioBufferAppendEvent, + SessionCreatedEvent, + client_event_type_adapter, + server_event_type_adapter, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + +event_router = EventRouter() +event_router.include_router(conversation_event_router) +event_router.include_router(input_audio_buffer_event_router) +event_router.include_router(response_event_router) +event_router.include_router(session_event_router) + +# TODO: limit session duration +# TODO: faster session initialization with web rtc + +# https://stackoverflow.com/questions/77560930/cant-create-audio-frame-with-from-nd-array + +rtc_tasks: set[asyncio.Task[None]] = set() +pcs = set() + + +async def rtc_datachannel_sender(ctx: SessionContext, channel: RTCDataChannel) -> None: + logger.info("Sender task started") + q = ctx.pubsub.subscribe() + try: + while True: + # logger.debug("Waiting for event") + event = await q.get() + if event.type not in SERVER_EVENT_TYPES: + continue + server_event = server_event_type_adapter.validate_python(event) + if server_event.type == "response.audio.delta": + logger.debug("Skipping response.audio.delta event") + continue + logger.debug(f"Sending {event.type} event") + channel.send(server_event.model_dump_json()) + logger.info(f"Sent {event.type} event") + # try: + # except fastapi.WebSocketDisconnect: + # logger.info("Failed to send message due to disconnect") + # break + except BaseException: + logger.exception("Sender task failed") + ctx.pubsub.subscribers.remove(q) + raise + + +def message_handler(ctx: SessionContext, message: str) -> None: + logger.info(f"Message received: {message}") + try: + event = client_event_type_adapter.validate_json(message) + except ValidationError as e: + ctx.pubsub.publish_nowait(ErrorEvent(error=Error(type="invalid_request_error", message=str(e)))) + logger.exception(f"Received an invalid client event: {message}") + return + + logger.debug(f"Received {event.type} event") + ctx.pubsub.publish_nowait(event) + # asyncio.create_task(event_router.dispatch(ctx, event)) + + +async def audio_receiver(ctx: SessionContext, track: RemoteStreamTrack) -> None: + # NOTE: IMPORTANT! 24Khz because that's what the `input_audio_buffer.append` handler expects + desired_sample_rate = 24000 + min_buffer_duration_ms = 200 + buffer_size = int(desired_sample_rate * min_buffer_duration_ms / 1000) + + # Initialize buffer to store audio data + buffer = np.array([], dtype=np.int16) + + while True: + frames = await track.recv() + # ensure that the received frames are of expected format + assert isinstance(frames, AudioFrame) + assert frames.sample_rate == 48000 + assert frames.layout.name == "stereo" + assert frames.format.name == "s16" + + resampler = AudioResampler(format="s16", layout="mono", rate=desired_sample_rate) + frames = resampler.resample(frames) + + # Accumulate audio data + for frame in frames: + arr = frame.to_ndarray() + buffer = np.append(buffer, arr.flatten()) # Flatten and append to buffer + + # When buffer reaches or exceeds target size, emit event + while len(buffer) >= buffer_size: + # Take BUFFER_SIZE samples + output_chunk = buffer[:buffer_size] + # Keep remaining samples in buffer + buffer = buffer[buffer_size:] + + # Convert to bytes and emit event + audio_bytes = output_chunk.tobytes() + assert len(audio_bytes) == len(output_chunk) * 2, "Audio sample width is not 2 bytes" + ctx.pubsub.publish_nowait( + InputAudioBufferAppendEvent( + type="input_audio_buffer.append", + audio=base64.b64encode(audio_bytes).decode(), + ) + ) + + +def datachannel_handler(ctx: SessionContext, channel: RTCDataChannel) -> None: + logger.info(f"Data channel created: {channel}") + channel.send(SessionCreatedEvent(session=ctx.session).model_dump_json()) + + rtc_tasks.add(asyncio.create_task(rtc_datachannel_sender(ctx, channel))) + + channel.on("message")(lambda message: message_handler(ctx, message)) + + +def iceconnectionstatechange_handler(ctx: SessionContext, pc: RTCPeerConnection) -> None: + logger.info(f"ICE connection state changed to {pc.iceConnectionState}") + if pc.iceConnectionState in ["failed", "closed"]: + pcs.discard(pc) + logger.info("Peer connection closed") + + with Path(f"sessions/{ctx.session.id}.json").open("w") as f: + logger.info(f"Dumping events to file {ctx.session.id}") + f.write(json.dumps([m.model_dump() for m in ctx.pubsub.events], indent=2)) + + +def track_handler(ctx: SessionContext, track: RemoteStreamTrack) -> None: + logger.info(f"Track received: kind={track.kind}") + if track.kind == "audio": + # Start a task to log audio data + rtc_tasks.add(asyncio.create_task(audio_receiver(ctx, track))) + track.on("ended")(lambda: logger.info(f"Track ended: kind={track.kind}")) + + +@router.post("/v1/realtime") +async def realtime_webrtc( + request: Request, + config: ConfigDependency, + transcription_client: TranscriptionClientDependency, +) -> Response: + completion_client = AsyncOpenAI( + base_url=f"http://{config.host}:{config.port}/v1", api_key=config.api_key + ).chat.completions + ctx = SessionContext( + transcription_client=transcription_client, + completion_client=completion_client, + session=create_session_object_configuration("gpt-4o-mini"), # FIXME + ) + + # TODO: handle both application/sdp and application/json + sdp = (await request.body()).decode("utf-8") + # session_description = SessionDescription.parse(sdp) + # for media in session_description.media: + # logger.info(f"offer media: {media}") + offer = RTCSessionDescription(sdp=sdp, type="offer") + logger.info(f"Received offer: {offer.sdp[:5]}") + + # Create a new RTCPeerConnection + # configuration = RTCConfiguration( + # iceServers=[RTCIceServer(urls="stun:stun.l.google.com:19302")], + # ) + pc = RTCPeerConnection() + pcs.add(pc) + + pc.on("datachannel", lambda channel: datachannel_handler(ctx, channel)) + pc.on("iceconnectionstatechange", lambda: iceconnectionstatechange_handler(ctx, pc)) + pc.on("track", lambda track: track_handler(ctx, track)) + pc.on( + "icegatheringstatechange", + lambda: logger.info(f"ICE gathering state changed to {pc.iceGatheringState}"), + ) + pc.on( + "icecandidate", + lambda *args, **kwargs: logger.info(f"ICE candidate: {args}, {kwargs}. {pc.iceGatheringState}"), + ) + + logger.info("Created peer connection") + + # NOTE: is relay needed? + audio_track = AudioStreamTrack(ctx) + pc.addTrack(audio_track) + + # Set the remote description and create an answer + await pc.setRemoteDescription(offer) + answer = await pc.createAnswer() + assert answer is not None + answer_session_description = SessionDescription.parse(answer.sdp) + + # remove all codecs except opus. This **should** ensure that we only receive opus audio + for media in answer_session_description.media: + if media.kind != "audio": + continue + logger.info(f"Codec before: {media.rtp.codecs}") + media.rtp.codecs = [codec for codec in media.rtp.codecs if codec.name == "opus"] + logger.info(f"Codec after: {media.rtp.codecs}") + + # logger.info(f"Created answer: {answer_session_description}") + start = time.perf_counter() + await pc.setLocalDescription( + answer + # RTCSessionDescription(str(answer_session_description), type="answer") + ) # NOTE: this takes ~5 secondd: could be relevant https://github.com/aiortc/aiortc/issues/1183 + logger.info(f"Set local description in {time.perf_counter() - start:.3f} seconds") + + rtc_tasks.add(asyncio.create_task(event_listener(ctx))) + + return Response(content=pc.localDescription.sdp, media_type="text/plain charset=utf-8") From f5c05f7d691f4744ec89901849a7e781eabec1c2 Mon Sep 17 00:00:00 2001 From: Fedir Zadniprovskyi Date: Mon, 17 Feb 2025 14:08:19 -0800 Subject: [PATCH 3/4] fix/realtime: status not being passed in when creating ConversationItem --- src/speaches/realtime/response_event_router.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/speaches/realtime/response_event_router.py b/src/speaches/realtime/response_event_router.py index 7c477b1..1f6d920 100644 --- a/src/speaches/realtime/response_event_router.py +++ b/src/speaches/realtime/response_event_router.py @@ -101,6 +101,8 @@ def add_output_item[T: ServerConversationItem](self, item: T) -> Generator[T, No self.response.output.append(item) self.pubsub.publish_nowait(ResponseOutputItemAddedEvent(response_id=self.id, item=item)) yield item + assert item.status != "incomplete", item + item.status = "completed" self.pubsub.publish_nowait(ResponseOutputItemDoneEvent(response_id=self.id, item=item)) self.pubsub.publish_nowait(ResponseDoneEvent(response=self.response)) @@ -118,7 +120,7 @@ def add_item_content[T: ConversationItemContentText | ConversationItemContentAud ) async def conversation_item_message_text_handler(self, chunk_stream: aiostream.Stream[ChatCompletionChunk]) -> None: - with self.add_output_item(ConversationItemMessage(role="assistant", content=[])) as item: + with self.add_output_item(ConversationItemMessage(role="assistant", status="incomplete", content=[])) as item: self.conversation.create_item(item) with self.add_item_content(item, ConversationItemContentText(text="")) as content: @@ -139,7 +141,7 @@ async def conversation_item_message_text_handler(self, chunk_stream: aiostream.S async def conversation_item_message_audio_handler( self, chunk_stream: aiostream.Stream[ChatCompletionChunk] ) -> None: - with self.add_output_item(ConversationItemMessage(role="assistant", content=[])) as item: + with self.add_output_item(ConversationItemMessage(role="assistant", status="incomplete", content=[])) as item: self.conversation.create_item(item) with self.add_item_content(item, ConversationItemContentAudio(audio="", transcript="")) as content: @@ -190,7 +192,10 @@ async def conversation_item_function_call_handler( and tool_call.function.arguments is not None ), chunk item = ConversationItemFunctionCall( - call_id=tool_call.id, name=tool_call.function.name, arguments=tool_call.function.arguments + status="incomplete", + call_id=tool_call.id, + name=tool_call.function.name, + arguments=tool_call.function.arguments, ) assert item.call_id is not None and item.arguments is not None and item.name is not None, item @@ -225,7 +230,7 @@ async def generate_response(self) -> None: try: completion_params = create_completion_params( self.model, - list(items_to_chat_messages(self.conversation.items)), + list(items_to_chat_messages(self.configuration.input)), self.configuration, ) chunk_stream = await self.completion_client.create(**completion_params) From 80afccbedafe0b0a98356065c0cf18673ed45073 Mon Sep 17 00:00:00 2001 From: Fedir Zadniprovskyi Date: Mon, 17 Feb 2025 14:04:51 -0800 Subject: [PATCH 4/4] chore: disable noisy module --- src/speaches/logger.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/speaches/logger.py b/src/speaches/logger.py index 36b45a1..77f29ef 100644 --- a/src/speaches/logger.py +++ b/src/speaches/logger.py @@ -41,6 +41,10 @@ def setup_logger(log_level: str) -> None: "level": "INFO", "handlers": ["stdout"], }, + "aiortc.rtcrtpreceiver": { + "level": "INFO", + "handlers": ["stdout"], + }, "numba.core": { "level": "INFO", "handlers": ["stdout"],