From eb4fd6dcd0faadbdac55018c38cbd7601bc7e8e7 Mon Sep 17 00:00:00 2001 From: Koper Date: Mon, 14 Aug 2023 19:50:25 +0700 Subject: [PATCH 01/13] Refactor based on NextJS 13 Routing / Dir Layout --- www/app/layout.js | 2 +- www/app/{utils.js => lib/random.js} | 12 ------------ www/app/lib/time.js | 11 +++++++++++ www/app/reach.png | Bin 85543 -> 0 bytes .../sentry-example-page/page.js} | 0 www/{public => app/styles}/button.css | 0 www/app/{ => styles}/globals.scss | 0 .../CustomRecordPlugin.js | 0 .../{components => transcripts}/dashboard.js | 0 www/app/{ => transcripts/new}/page.js | 15 +++++++++------ .../record.js => transcripts/recorder.js} | 2 +- .../useTranscript.js} | 0 .../webrtc.js => transcripts/useWebRTC.js} | 0 .../useWebSockets.js} | 0 www/pages/api/sentry-example-api.js | 5 ----- 15 files changed, 22 insertions(+), 25 deletions(-) rename www/app/{utils.js => lib/random.js} (58%) create mode 100644 www/app/lib/time.js delete mode 100644 www/app/reach.png rename www/{pages/sentry-example-page.js => app/sentry-example-page/page.js} (100%) rename www/{public => app/styles}/button.css (100%) rename www/app/{ => styles}/globals.scss (100%) rename www/app/{components => transcripts}/CustomRecordPlugin.js (100%) rename www/app/{components => transcripts}/dashboard.js (100%) rename www/app/{ => transcripts/new}/page.js (82%) rename www/app/{components/record.js => transcripts/recorder.js} (99%) rename www/app/{components/transcript.js => transcripts/useTranscript.js} (100%) rename www/app/{components/webrtc.js => transcripts/useWebRTC.js} (100%) rename www/app/{components/websocket.js => transcripts/useWebSockets.js} (100%) delete mode 100644 www/pages/api/sentry-example-api.js diff --git a/www/app/layout.js b/www/app/layout.js index 98f6e772..824a8e16 100644 --- a/www/app/layout.js +++ b/www/app/layout.js @@ -1,4 +1,4 @@ -import "./globals.scss"; +import "./styles/globals.scss"; import { Roboto } from "next/font/google"; import Head from "next/head"; diff --git a/www/app/utils.js b/www/app/lib/random.js similarity index 58% rename from www/app/utils.js rename to www/app/lib/random.js index 79e8ceae..37c4dee7 100644 --- a/www/app/utils.js +++ b/www/app/lib/random.js @@ -17,15 +17,3 @@ export function Mulberry32(seed) { return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } - -export const formatTime = (seconds) => { - let hours = Math.floor(seconds / 3600); - let minutes = Math.floor((seconds % 3600) / 60); - let secs = Math.floor(seconds % 60); - - let timeString = `${hours > 0 ? hours + ":" : ""}${minutes - .toString() - .padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; - - return timeString; -}; diff --git a/www/app/lib/time.js b/www/app/lib/time.js new file mode 100644 index 00000000..a6204ade --- /dev/null +++ b/www/app/lib/time.js @@ -0,0 +1,11 @@ +export const formatTime = (seconds) => { + let hours = Math.floor(seconds / 3600); + let minutes = Math.floor((seconds % 3600) / 60); + let secs = Math.floor(seconds % 60); + + let timeString = `${hours > 0 ? hours + ":" : ""}${minutes + .toString() + .padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + + return timeString; +}; diff --git a/www/app/reach.png b/www/app/reach.png deleted file mode 100644 index e0c07c7175dabb4fd7d7834f96339267925022fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85543 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 { const [stream, setStream] = useState(null); @@ -39,6 +39,9 @@ const App = () => { setStream(null); }} /> + +
+ { const [ddOptions, setDdOptions] = useState([]); diff --git a/www/app/components/transcript.js b/www/app/transcripts/useTranscript.js similarity index 100% rename from www/app/components/transcript.js rename to www/app/transcripts/useTranscript.js diff --git a/www/app/components/webrtc.js b/www/app/transcripts/useWebRTC.js similarity index 100% rename from www/app/components/webrtc.js rename to www/app/transcripts/useWebRTC.js diff --git a/www/app/components/websocket.js b/www/app/transcripts/useWebSockets.js similarity index 100% rename from www/app/components/websocket.js rename to www/app/transcripts/useWebSockets.js diff --git a/www/pages/api/sentry-example-api.js b/www/pages/api/sentry-example-api.js deleted file mode 100644 index ac07eec0..00000000 --- a/www/pages/api/sentry-example-api.js +++ /dev/null @@ -1,5 +0,0 @@ -// A faulty API route to test Sentry's error monitoring -export default function handler(_req, res) { - throw new Error("Sentry Example API Route Error"); - res.status(200).json({ name: "John Doe" }); -} From 93acea4ad9a5561ef6cfd1101645e9961e12a3ad Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 11 Aug 2023 19:14:49 +0200 Subject: [PATCH 02/13] server: add env.example Closes #95 --- server/env.example | 72 ++++++++++++++++++++++++++++++++++++ server/reflector/settings.py | 3 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 server/env.example diff --git a/server/env.example b/server/env.example new file mode 100644 index 00000000..13157721 --- /dev/null +++ b/server/env.example @@ -0,0 +1,72 @@ +# +# This file serve as an example of possible configuration +# All the settings are described here: reflector/settings.py +# + +## ======================================================= +## Sentry +## ======================================================= + +## Sentry DSN configuration +#SENTRY_DSN= + +## ======================================================= +## Transcription backend +## +## Check reflector/processors/audio_transcript_* for the +## full list of available transcription backend +## ======================================================= + +## Using local whisper (default) +#TRANSCRIPT_BACKEND=whisper +#WHISPER_MODEL_SIZE=tiny + +## Using serverless modal.com (require reflector-gpu-modal deployed) +#TRANSCRIPT_BACKEND=modal +#TRANSCRIPT_URL=https://xxxxx--reflector-transcriber-web.modal.run +#TRANSCRIPT_MODAL_API_KEY=xxxxx + +## Using serverless banana.dev (require reflector-gpu-banana deployed) +## XXX this service is buggy do not use at the moment +## XXX it also require the audio to be saved to S3 +#TRANSCRIPT_BACKEND=banana +#TRANSCRIPT_URL=https://reflector-gpu-banana-xxxxx.run.banana.dev +#TRANSCRIPT_BANANA_API_KEY=xxx +#TRANSCRIPT_BANANA_MODEL_KEY=xxx +#TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID=xxx +#TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY=xxx +#TRANSCRIPT_STORAGE_AWS_BUCKET_NAME="reflector-bucket/chunks" + +## ======================================================= +## LLM backend +## +## Check reflector/llm/* for the full list of available +## llm backend implementation +## ======================================================= + +## Use oobagooda (default) +#LLM_BACKEND=oobagooda +#LLM_URL=http://xxx:7860/api/generate/v1 + +## Using serverless modal.com (require reflector-gpu-modal deployed) +#LLM_BACKEND=modal +#LLM_URL=https://xxxxxx--reflector-llm-web.modal.run +#LLM_MODAL_API_KEY=xxx + +## Using serverless banana.dev (require reflector-gpu-banana deployed) +## XXX this service is buggy do not use at the moment +#LLM_BACKEND=banana +#LLM_URL=https://reflector-gpu-banana-xxxxx.run.banana.dev +#LLM_BANANA_API_KEY=xxxxx +#LLM_BANANA_MODEL_KEY=xxxxx + +## Using OpenAI +#LLM_BACKEND=openai +#LLM_OPENAI_KEY=xxx +#LLM_OPENAI_MODEL=gpt-3.5-turbo + +## Using GPT4ALL +#LLM_BACKEND=openai +#LLM_URL=http://localhost:4891/v1/completions +#LLM_OPENAI_MODEL="GPT4All Falcon" + diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 5d049191..6b84131c 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): AUDIO_BUFFER_SIZE: int = 256 * 960 # Audio Transcription - # backends: whisper, banana + # backends: whisper, banana, modal TRANSCRIPT_BACKEND: str = "whisper" TRANSCRIPT_URL: str | None = None TRANSCRIPT_TIMEOUT: int = 90 @@ -49,6 +49,7 @@ class Settings(BaseSettings): TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None # LLM + # available backend: openai, banana, modal, oobagooda LLM_BACKEND: str = "oobagooda" # LLM common configuration From 98375d5c2c5b2c8ee5d86498600e30aec4bb7e03 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 15 Aug 2023 09:50:13 +0200 Subject: [PATCH 03/13] ci: add manual deploy for server --- .github/workflows/deploy.yml | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..015a4e01 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: Deploy to Amazon ECS + +on: [deployment, workflow_dispatch] + +env: + # 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector + AWS_REGION: us-east-1 + ECR_REPOSITORY: reflector + +jobs: + deploy: + runs-on: ubuntu-latest + + permissions: + deployments: write + contents: read + + steps: + - uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@62f4f872db3836360b72999f4b87f1ff13310f3a + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v4 + with: + context: server + push: true + tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest From a21a726eb1f6bade93ae7cf9d2585ad68ee1a32c Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 15 Aug 2023 12:29:14 +0200 Subject: [PATCH 04/13] server: prevent storing audio for transcription unless wanted Closes #145 --- server/reflector/processors/audio_merge.py | 14 ++++----- .../processors/audio_transcript_modal.py | 4 +-- server/reflector/processors/types.py | 31 +++++++++++++++++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/reflector/processors/audio_merge.py b/server/reflector/processors/audio_merge.py index ac16676d..37734a53 100644 --- a/server/reflector/processors/audio_merge.py +++ b/server/reflector/processors/audio_merge.py @@ -1,6 +1,8 @@ from reflector.processors.base import Processor from reflector.processors.types import AudioFile -from pathlib import Path +from time import monotonic_ns +from uuid import uuid4 +import io import wave import av @@ -24,12 +26,9 @@ class AudioMergeProcessor(Processor): sample_width = frame.format.bytes # create audio file - from time import monotonic_ns - from uuid import uuid4 - uu = uuid4().hex - path = Path(f"audio_{monotonic_ns()}_{uu}.wav") - with wave.open(path.as_posix(), "wb") as wf: + fd = io.BytesIO() + with wave.open(fd, "wb") as wf: wf.setnchannels(channels) wf.setsampwidth(sample_width) wf.setframerate(sample_rate) @@ -38,7 +37,8 @@ class AudioMergeProcessor(Processor): # emit audio file audiofile = AudioFile( - path=path, + name=f"{monotonic_ns()}-{uu}.wav", + fd=fd, sample_rate=sample_rate, channels=channels, sample_width=sample_width, diff --git a/server/reflector/processors/audio_transcript_modal.py b/server/reflector/processors/audio_transcript_modal.py index 4d1dac2d..1ed727d6 100644 --- a/server/reflector/processors/audio_transcript_modal.py +++ b/server/reflector/processors/audio_transcript_modal.py @@ -48,9 +48,9 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor): async def _transcript(self, data: AudioFile): async with httpx.AsyncClient() as client: - self.logger.debug(f"Try to transcribe audio {data.path.name}") + self.logger.debug(f"Try to transcribe audio {data.name}") files = { - "file": (data.path.name, data.path.open("rb")), + "file": (data.name, data.fd), } response = await retry(client.post)( self.transcript_url, diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py index 6b193882..0c7c48d4 100644 --- a/server/reflector/processors/types.py +++ b/server/reflector/processors/types.py @@ -1,16 +1,41 @@ -from pydantic import BaseModel +from pydantic import BaseModel, PrivateAttr from pathlib import Path +import tempfile +import io class AudioFile(BaseModel): - path: Path + name: str sample_rate: int channels: int sample_width: int timestamp: float = 0.0 + _fd: io.BytesIO = PrivateAttr(None) + _path: Path = PrivateAttr(None) + + def __init__(self, fd, **kwargs): + super().__init__(**kwargs) + self._fd = fd + + @property + def fd(self): + self._fd.seek(0) + return self._fd + + @property + def path(self): + if self._path is None: + # write down to disk + filename = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + self._path = Path(filename) + with self._path.open("wb") as f: + f.write(self._fd.getbuffer()) + return self._path + def release(self): - self.path.unlink() + if self._path: + self._path.unlink() class Word(BaseModel): From 69b8b2fab892618eebba0194678e5fd2ec8c8294 Mon Sep 17 00:00:00 2001 From: Koper Date: Tue, 15 Aug 2023 22:20:21 +0700 Subject: [PATCH 05/13] Fix build error --- www/app/sentry-example-page/page.js | 87 ----------------------------- 1 file changed, 87 deletions(-) delete mode 100644 www/app/sentry-example-page/page.js diff --git a/www/app/sentry-example-page/page.js b/www/app/sentry-example-page/page.js deleted file mode 100644 index bcace78b..00000000 --- a/www/app/sentry-example-page/page.js +++ /dev/null @@ -1,87 +0,0 @@ -import Head from "next/head"; -import * as Sentry from "@sentry/nextjs"; - -export default function Home() { - return ( -
- - Sentry Onboarding - - - -
-

- - - -

- -

Get started by sending us a sample error:

- - -

- Next, look for the error on the{" "} - - Issues Page - - . -

-

- For more information, see{" "} - - https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -

-
-
- ); -} From 3c130f7b3e57c44307d980968678eabebeb7bb93 Mon Sep 17 00:00:00 2001 From: Koper Date: Tue, 15 Aug 2023 22:20:34 +0700 Subject: [PATCH 06/13] Redirect / to /transcripts/new --- www/app/page.js | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 www/app/page.js diff --git a/www/app/page.js b/www/app/page.js new file mode 100644 index 00000000..d2835cf1 --- /dev/null +++ b/www/app/page.js @@ -0,0 +1,4 @@ +import { redirect } from "next/navigation"; +export default async function Index({ params }) { + redirect("/transcripts/new"); +} From 857505124f1ff0bd01aea2c9271b0106b6ea9250 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 15 Aug 2023 16:31:42 +0200 Subject: [PATCH 07/13] server: implement data persistence with database Using databases + sqlite/postgresql depending of what you want. Use DATABASE_URL to configure Closes #70 --- server/.gitignore | 3 + server/poetry.lock | 240 +++++++++++++++++++++++++- server/pyproject.toml | 2 + server/reflector/app.py | 1 + server/reflector/db/__init__.py | 42 +++++ server/reflector/settings.py | 3 + server/reflector/views/transcripts.py | 110 ++++++++---- server/test.db | Bin 0 -> 20480 bytes 8 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 server/reflector/db/__init__.py create mode 100644 server/test.db diff --git a/server/.gitignore b/server/.gitignore index 6bf35f5e..7d66d6f0 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -175,3 +175,6 @@ test_samples/ .vscode/ artefacts/ audio_*.wav + +# ignore local database +reflector.sqlite3 diff --git a/server/poetry.lock b/server/poetry.lock index dc5cae28..9ad03bcf 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -274,6 +274,21 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "aiosqlite" +version = "0.19.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosqlite-0.19.0-py3-none-any.whl", hash = "sha256:edba222e03453e094a3ce605db1b970c4b3376264e56f32e2a4959f948d66a96"}, + {file = "aiosqlite-0.19.0.tar.gz", hash = "sha256:95ee77b91c8d2808bd08a59fbebf66270e9090c3d92ffbf260dc0db0b979577d"}, +] + +[package.extras] +dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"] +docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"] + [[package]] name = "annotated-types" version = "0.5.0" @@ -316,6 +331,59 @@ files = [ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] +[[package]] +name = "asyncpg" +version = "0.28.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "asyncpg-0.28.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a6d1b954d2b296292ddff4e0060f494bb4270d87fb3655dd23c5c6096d16d83"}, + {file = "asyncpg-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0740f836985fd2bd73dca42c50c6074d1d61376e134d7ad3ad7566c4f79f8184"}, + {file = "asyncpg-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e907cf620a819fab1737f2dd90c0f185e2a796f139ac7de6aa3212a8af96c050"}, + {file = "asyncpg-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86b339984d55e8202e0c4b252e9573e26e5afa05617ed02252544f7b3e6de3e9"}, + {file = "asyncpg-0.28.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c402745185414e4c204a02daca3d22d732b37359db4d2e705172324e2d94e85"}, + {file = "asyncpg-0.28.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c88eef5e096296626e9688f00ab627231f709d0e7e3fb84bb4413dff81d996d7"}, + {file = "asyncpg-0.28.0-cp310-cp310-win32.whl", hash = "sha256:90a7bae882a9e65a9e448fdad3e090c2609bb4637d2a9c90bfdcebbfc334bf89"}, + {file = "asyncpg-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:76aacdcd5e2e9999e83c8fbcb748208b60925cc714a578925adcb446d709016c"}, + {file = "asyncpg-0.28.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a0e08fe2c9b3618459caaef35979d45f4e4f8d4f79490c9fa3367251366af207"}, + {file = "asyncpg-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b24e521f6060ff5d35f761a623b0042c84b9c9b9fb82786aadca95a9cb4a893b"}, + {file = "asyncpg-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99417210461a41891c4ff301490a8713d1ca99b694fef05dabd7139f9d64bd6c"}, + {file = "asyncpg-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f029c5adf08c47b10bcdc857001bbef551ae51c57b3110964844a9d79ca0f267"}, + {file = "asyncpg-0.28.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ad1d6abf6c2f5152f46fff06b0e74f25800ce8ec6c80967f0bc789974de3c652"}, + {file = "asyncpg-0.28.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d7fa81ada2807bc50fea1dc741b26a4e99258825ba55913b0ddbf199a10d69d8"}, + {file = "asyncpg-0.28.0-cp311-cp311-win32.whl", hash = "sha256:f33c5685e97821533df3ada9384e7784bd1e7865d2b22f153f2e4bd4a083e102"}, + {file = "asyncpg-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e7337c98fb493079d686a4a6965e8bcb059b8e1b8ec42106322fc6c1c889bb0"}, + {file = "asyncpg-0.28.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1c56092465e718a9fdcc726cc3d9dcf3a692e4834031c9a9f871d92a75d20d48"}, + {file = "asyncpg-0.28.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4acd6830a7da0eb4426249d71353e8895b350daae2380cb26d11e0d4a01c5472"}, + {file = "asyncpg-0.28.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63861bb4a540fa033a56db3bb58b0c128c56fad5d24e6d0a8c37cb29b17c1c7d"}, + {file = "asyncpg-0.28.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a93a94ae777c70772073d0512f21c74ac82a8a49be3a1d982e3f259ab5f27307"}, + {file = "asyncpg-0.28.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d14681110e51a9bc9c065c4e7944e8139076a778e56d6f6a306a26e740ed86d2"}, + {file = "asyncpg-0.28.0-cp37-cp37m-win32.whl", hash = "sha256:8aec08e7310f9ab322925ae5c768532e1d78cfb6440f63c078b8392a38aa636a"}, + {file = "asyncpg-0.28.0-cp37-cp37m-win_amd64.whl", hash = "sha256:319f5fa1ab0432bc91fb39b3960b0d591e6b5c7844dafc92c79e3f1bff96abef"}, + {file = "asyncpg-0.28.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b337ededaabc91c26bf577bfcd19b5508d879c0ad009722be5bb0a9dd30b85a0"}, + {file = "asyncpg-0.28.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4d32b680a9b16d2957a0a3cc6b7fa39068baba8e6b728f2e0a148a67644578f4"}, + {file = "asyncpg-0.28.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f62f04cdf38441a70f279505ef3b4eadf64479b17e707c950515846a2df197"}, + {file = "asyncpg-0.28.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f20cac332c2576c79c2e8e6464791c1f1628416d1115935a34ddd7121bfc6a4"}, + {file = "asyncpg-0.28.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:59f9712ce01e146ff71d95d561fb68bd2d588a35a187116ef05028675462d5ed"}, + {file = "asyncpg-0.28.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fc9e9f9ff1aa0eddcc3247a180ac9e9b51a62311e988809ac6152e8fb8097756"}, + {file = "asyncpg-0.28.0-cp38-cp38-win32.whl", hash = "sha256:9e721dccd3838fcff66da98709ed884df1e30a95f6ba19f595a3706b4bc757e3"}, + {file = "asyncpg-0.28.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ba7d06a0bea539e0487234511d4adf81dc8762249858ed2a580534e1720db00"}, + {file = "asyncpg-0.28.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d009b08602b8b18edef3a731f2ce6d3f57d8dac2a0a4140367e194eabd3de457"}, + {file = "asyncpg-0.28.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ec46a58d81446d580fb21b376ec6baecab7288ce5a578943e2fc7ab73bf7eb39"}, + {file = "asyncpg-0.28.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b48ceed606cce9e64fd5480a9b0b9a95cea2b798bb95129687abd8599c8b019"}, + {file = "asyncpg-0.28.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8858f713810f4fe67876728680f42e93b7e7d5c7b61cf2118ef9153ec16b9423"}, + {file = "asyncpg-0.28.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5e18438a0730d1c0c1715016eacda6e9a505fc5aa931b37c97d928d44941b4bf"}, + {file = "asyncpg-0.28.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e9c433f6fcdd61c21a715ee9128a3ca48be8ac16fa07be69262f016bb0f4dbd2"}, + {file = "asyncpg-0.28.0-cp39-cp39-win32.whl", hash = "sha256:41e97248d9076bc8e4849da9e33e051be7ba37cd507cbd51dfe4b2d99c70e3dc"}, + {file = "asyncpg-0.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ed77f00c6aacfe9d79e9eff9e21729ce92a4b38e80ea99a58ed382f42ebd55b"}, + {file = "asyncpg-0.28.0.tar.gz", hash = "sha256:7252cdc3acb2f52feaa3664280d3bcd78a46bd6c10bfd681acfffefa1120e278"}, +] + +[package.extras] +docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=5.0,<6.0)", "uvloop (>=0.15.3)"] + [[package]] name = "attrs" version = "23.1.0" @@ -836,6 +904,32 @@ files = [ numpy = "*" pyyaml = ">=5.3,<7" +[[package]] +name = "databases" +version = "0.7.0" +description = "Async database support for Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "databases-0.7.0-py3-none-any.whl", hash = "sha256:cf5da4b8a3e3cd038c459529725ebb64931cbbb7a091102664f20ef8f6cefd0d"}, + {file = "databases-0.7.0.tar.gz", hash = "sha256:ea2d419d3d2eb80595b7ceb8f282056f080af62efe2fb9bcd83562f93ec4b674"}, +] + +[package.dependencies] +aiosqlite = {version = "*", optional = true, markers = "extra == \"aiosqlite\""} +asyncpg = {version = "*", optional = true, markers = "extra == \"asyncpg\""} +sqlalchemy = ">=1.4.42,<1.5" + +[package.extras] +aiomysql = ["aiomysql"] +aiopg = ["aiopg"] +aiosqlite = ["aiosqlite"] +asyncmy = ["asyncmy"] +asyncpg = ["asyncpg"] +mysql = ["aiomysql"] +postgresql = ["asyncpg"] +sqlite = ["aiosqlite"] + [[package]] name = "dnspython" version = "2.4.1" @@ -1139,6 +1233,79 @@ files = [ [package.extras] testing = ["pytest"] +[[package]] +name = "greenlet" +version = "2.0.2" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +files = [ + {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, + {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, + {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, + {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, + {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, + {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, + {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, + {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, + {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, + {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, + {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, + {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, + {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, + {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, + {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, + {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, + {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, + {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, + {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, + {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, + {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, + {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, + {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, + {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, + {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, + {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, + {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, + {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, + {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, + {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, + {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, + {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, + {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, + {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, + {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, + {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, + {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, + {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, + {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, + {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, +] + +[package.extras] +docs = ["Sphinx", "docutils (<0.18)"] +test = ["objgraph", "psutil"] + [[package]] name = "h11" version = "0.14.0" @@ -2429,6 +2596,77 @@ files = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] +[[package]] +name = "sqlalchemy" +version = "1.4.49" +description = "Database Abstraction Library" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "SQLAlchemy-1.4.49-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e126cf98b7fd38f1e33c64484406b78e937b1a280e078ef558b95bf5b6895f6"}, + {file = "SQLAlchemy-1.4.49-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:03db81b89fe7ef3857b4a00b63dedd632d6183d4ea5a31c5d8a92e000a41fc71"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:95b9df9afd680b7a3b13b38adf6e3a38995da5e162cc7524ef08e3be4e5ed3e1"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63e43bf3f668c11bb0444ce6e809c1227b8f067ca1068898f3008a273f52b09"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f835c050ebaa4e48b18403bed2c0fda986525896efd76c245bdd4db995e51a4c"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c21b172dfb22e0db303ff6419451f0cac891d2e911bb9fbf8003d717f1bcf91"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-win32.whl", hash = "sha256:5fb1ebdfc8373b5a291485757bd6431de8d7ed42c27439f543c81f6c8febd729"}, + {file = "SQLAlchemy-1.4.49-cp310-cp310-win_amd64.whl", hash = "sha256:f8a65990c9c490f4651b5c02abccc9f113a7f56fa482031ac8cb88b70bc8ccaa"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8923dfdf24d5aa8a3adb59723f54118dd4fe62cf59ed0d0d65d940579c1170a4"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9ab2c507a7a439f13ca4499db6d3f50423d1d65dc9b5ed897e70941d9e135b0"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5debe7d49b8acf1f3035317e63d9ec8d5e4d904c6e75a2a9246a119f5f2fdf3d"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-win32.whl", hash = "sha256:82b08e82da3756765c2e75f327b9bf6b0f043c9c3925fb95fb51e1567fa4ee87"}, + {file = "SQLAlchemy-1.4.49-cp311-cp311-win_amd64.whl", hash = "sha256:171e04eeb5d1c0d96a544caf982621a1711d078dbc5c96f11d6469169bd003f1"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:36e58f8c4fe43984384e3fbe6341ac99b6b4e083de2fe838f0fdb91cebe9e9cb"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b31e67ff419013f99ad6f8fc73ee19ea31585e1e9fe773744c0f3ce58c039c30"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c14b29d9e1529f99efd550cd04dbb6db6ba5d690abb96d52de2bff4ed518bc95"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c40f3470e084d31247aea228aa1c39bbc0904c2b9ccbf5d3cfa2ea2dac06f26d"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-win32.whl", hash = "sha256:706bfa02157b97c136547c406f263e4c6274a7b061b3eb9742915dd774bbc264"}, + {file = "SQLAlchemy-1.4.49-cp36-cp36m-win_amd64.whl", hash = "sha256:a7f7b5c07ae5c0cfd24c2db86071fb2a3d947da7bd487e359cc91e67ac1c6d2e"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:4afbbf5ef41ac18e02c8dc1f86c04b22b7a2125f2a030e25bbb4aff31abb224b"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24e300c0c2147484a002b175f4e1361f102e82c345bf263242f0449672a4bccf"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:201de072b818f8ad55c80d18d1a788729cccf9be6d9dc3b9d8613b053cd4836d"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653ed6817c710d0c95558232aba799307d14ae084cc9b1f4c389157ec50df5c"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-win32.whl", hash = "sha256:647e0b309cb4512b1f1b78471fdaf72921b6fa6e750b9f891e09c6e2f0e5326f"}, + {file = "SQLAlchemy-1.4.49-cp37-cp37m-win_amd64.whl", hash = "sha256:ab73ed1a05ff539afc4a7f8cf371764cdf79768ecb7d2ec691e3ff89abbc541e"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:37ce517c011560d68f1ffb28af65d7e06f873f191eb3a73af5671e9c3fada08a"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1878ce508edea4a879015ab5215546c444233881301e97ca16fe251e89f1c55"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0e8e608983e6f85d0852ca61f97e521b62e67969e6e640fe6c6b575d4db68557"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccf956da45290df6e809ea12c54c02ace7f8ff4d765d6d3dfb3655ee876ce58d"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-win32.whl", hash = "sha256:f167c8175ab908ce48bd6550679cc6ea20ae169379e73c7720a28f89e53aa532"}, + {file = "SQLAlchemy-1.4.49-cp38-cp38-win_amd64.whl", hash = "sha256:45806315aae81a0c202752558f0df52b42d11dd7ba0097bf71e253b4215f34f4"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b6d0c4b15d65087738a6e22e0ff461b407533ff65a73b818089efc8eb2b3e1de"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a843e34abfd4c797018fd8d00ffffa99fd5184c421f190b6ca99def4087689bd"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c890421651b45a681181301b3497e4d57c0d01dc001e10438a40e9a9c25ee77"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d26f280b8f0a8f497bc10573849ad6dc62e671d2468826e5c748d04ed9e670d5"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-win32.whl", hash = "sha256:ec2268de67f73b43320383947e74700e95c6770d0c68c4e615e9897e46296294"}, + {file = "SQLAlchemy-1.4.49-cp39-cp39-win_amd64.whl", hash = "sha256:bbdf16372859b8ed3f4d05f925a984771cd2abd18bd187042f24be4886c2a15f"}, + {file = "SQLAlchemy-1.4.49.tar.gz", hash = "sha256:06ff25cbae30c396c4b7737464f2a7fc37a67b7da409993b182b024cec80aed9"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\")"} + +[package.extras] +aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"] +mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql", "pymysql (<1)"] +sqlcipher = ["sqlcipher3-binary"] + [[package]] name = "stamina" version = "23.1.0" @@ -2996,4 +3234,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "c9924049dacf7310590416f096f5b20f6ed905d8a50edf5e8afcf2c28b70799f" +content-hash = "ea523f9b74581a7867097a6249d416d8836f4daaf33fde65ea343e4d3502c71c" diff --git a/server/pyproject.toml b/server/pyproject.toml index cdd510a0..e3e75843 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -23,6 +23,8 @@ fastapi = "^0.100.1" sentry-sdk = {extras = ["fastapi"], version = "^1.29.2"} httpx = "^0.24.1" fastapi-pagination = "^0.12.6" +databases = {extras = ["aiosqlite", "asyncpg"], version = "^0.7.0"} +sqlalchemy = "<1.5" [tool.poetry.group.dev.dependencies] diff --git a/server/reflector/app.py b/server/reflector/app.py index f83cc0df..8383bf32 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi_pagination import add_pagination from fastapi.routing import APIRoute +import reflector.db # noqa from reflector.views.rtc_offer import router as rtc_offer_router from reflector.views.transcripts import router as transcripts_router from reflector.events import subscribers_startup, subscribers_shutdown diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py new file mode 100644 index 00000000..3864b13a --- /dev/null +++ b/server/reflector/db/__init__.py @@ -0,0 +1,42 @@ +import databases +import sqlalchemy +from reflector.events import subscribers_startup, subscribers_shutdown +from reflector.settings import settings + + +database = databases.Database(settings.DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +transcripts = sqlalchemy.Table( + "transcript", + metadata, + sqlalchemy.Column("id", sqlalchemy.String, primary_key=True), + sqlalchemy.Column("name", sqlalchemy.String), + sqlalchemy.Column("status", sqlalchemy.String), + sqlalchemy.Column("locked", sqlalchemy.Boolean), + sqlalchemy.Column("duration", sqlalchemy.Integer), + sqlalchemy.Column("created_at", sqlalchemy.DateTime), + sqlalchemy.Column("summary", sqlalchemy.String, nullable=True), + sqlalchemy.Column("topics", sqlalchemy.JSON), + sqlalchemy.Column("events", sqlalchemy.JSON), + # with user attached, optional + sqlalchemy.Column("user_id", sqlalchemy.String), +) + +engine = sqlalchemy.create_engine( + settings.DATABASE_URL, connect_args={"check_same_thread": False} +) +metadata.create_all(engine) + + +async def database_connect(): + await database.connect() + + +async def database_disconnect(): + await database.disconnect() + + +subscribers_startup.append(database_connect) +subscribers_shutdown.append(database_disconnect) diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 6b84131c..e776875b 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -6,6 +6,9 @@ class Settings(BaseSettings): OPENMP_KMP_DUPLICATE_LIB_OK: bool = False + # Database + DATABASE_URL: str = "sqlite:///./reflector.sqlite3" + # Whisper WHISPER_MODEL_SIZE: str = "tiny" WHISPER_REAL_TIME_MODEL_SIZE: str = "tiny" diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index a6abefa6..124781fc 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -6,10 +6,11 @@ from fastapi import ( WebSocketDisconnect, ) from pydantic import BaseModel, Field -from uuid import UUID, uuid4 +from uuid import uuid4 from datetime import datetime from fastapi_pagination import Page, paginate from reflector.logger import logger +from reflector.db import database, transcripts from .rtc_offer import rtc_offer_base, RtcOffer, PipelineEvent from typing import Optional @@ -21,6 +22,10 @@ router = APIRouter() # ============================================================== +def generate_uuid4(): + return str(uuid4()) + + def generate_transcript_name(): now = datetime.utcnow() return f"Transcript {now.strftime('%Y-%m-%d %H:%M:%S')}" @@ -31,7 +36,7 @@ class TranscriptText(BaseModel): class TranscriptTopic(BaseModel): - id: UUID = Field(default_factory=uuid4) + id: str = Field(default_factory=generate_uuid4) title: str summary: str transcript: str @@ -48,7 +53,7 @@ class TranscriptEvent(BaseModel): class Transcript(BaseModel): - id: UUID = Field(default_factory=uuid4) + id: str = Field(default_factory=generate_uuid4) name: str = Field(default_factory=generate_transcript_name) status: str = "idle" locked: bool = False @@ -72,19 +77,37 @@ class Transcript(BaseModel): class TranscriptController: - transcripts: list[Transcript] = [] + async def get_all(self) -> list[Transcript]: + query = transcripts.select() + results = await database.fetch_all(query) + return results - def get_all(self) -> list[Transcript]: - return self.transcripts + async def get_by_id(self, transcript_id: str) -> Transcript | None: + query = transcripts.select().where(transcripts.c.id == transcript_id) + result = await database.fetch_one(query) + if not result: + return None + return Transcript(**result) - def get_by_id(self, transcript_id: UUID) -> Transcript | None: - return next((t for t in self.transcripts if t.id == transcript_id), None) + async def add(self, name: str): + transcript = Transcript(name=name) + query = transcripts.insert().values(**transcript.model_dump()) + await database.execute(query) + return transcript - def add(self, transcript: Transcript): - self.transcripts.append(transcript) + async def update(self, transcript: Transcript, values: dict): + query = ( + transcripts.update() + .where(transcripts.c.id == transcript.id) + .values(**values) + ) + await database.execute(query) + for key, value in values.items(): + setattr(transcript, key, value) - def remove(self, transcript: Transcript): - self.transcripts.remove(transcript) + async def remove_by_id(self, transcript_id: str) -> None: + query = transcripts.delete().where(transcripts.c.id == transcript_id) + await database.execute(query) transcripts_controller = TranscriptController() @@ -96,7 +119,7 @@ transcripts_controller = TranscriptController() class GetTranscript(BaseModel): - id: UUID + id: str name: str status: str locked: bool @@ -123,15 +146,12 @@ class DeletionStatus(BaseModel): @router.get("/transcripts", response_model=Page[GetTranscript]) async def transcripts_list(): - return paginate(transcripts_controller.get_all()) + return paginate(await transcripts_controller.get_all()) @router.post("/transcripts", response_model=GetTranscript) async def transcripts_create(info: CreateTranscript): - transcript = Transcript() - transcript.name = info.name - transcripts_controller.add(transcript) - return transcript + return await transcripts_controller.add(info.name) # ============================================================== @@ -140,36 +160,38 @@ async def transcripts_create(info: CreateTranscript): @router.get("/transcripts/{transcript_id}", response_model=GetTranscript) -async def transcript_get(transcript_id: UUID): - transcript = transcripts_controller.get_by_id(transcript_id) +async def transcript_get(transcript_id: str): + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") return transcript @router.patch("/transcripts/{transcript_id}", response_model=GetTranscript) -async def transcript_update(transcript_id: UUID, info: UpdateTranscript): - transcript = transcripts_controller.get_by_id(transcript_id) +async def transcript_update(transcript_id: str, info: UpdateTranscript): + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") + values = {} if info.name is not None: - transcript.name = info.name + values["name"] = info.name if info.locked is not None: - transcript.locked = info.locked + values["locked"] = info.locked + await transcripts_controller.update(transcript, values) return transcript @router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus) -async def transcript_delete(transcript_id: UUID): - transcript = transcripts_controller.get_by_id(transcript_id) +async def transcript_delete(transcript_id: str): + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - transcripts_controller.remove(transcript) + await transcripts_controller.remove_by_id(transcript.id) return DeletionStatus(status="ok") @router.get("/transcripts/{transcript_id}/audio") -async def transcript_get_audio(transcript_id: UUID): +async def transcript_get_audio(transcript_id: str): transcript = transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -179,7 +201,7 @@ async def transcript_get_audio(transcript_id: UUID): @router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic]) -async def transcript_get_topics(transcript_id: UUID): +async def transcript_get_topics(transcript_id: str): transcript = transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -187,7 +209,7 @@ async def transcript_get_topics(transcript_id: UUID): @router.get("/transcripts/{transcript_id}/events") -async def transcript_get_websocket_events(transcript_id: UUID): +async def transcript_get_websocket_events(transcript_id: str): pass @@ -200,20 +222,20 @@ class WebsocketManager: def __init__(self): self.active_connections = {} - async def connect(self, transcript_id: UUID, websocket: WebSocket): + async def connect(self, transcript_id: str, websocket: WebSocket): await websocket.accept() if transcript_id not in self.active_connections: self.active_connections[transcript_id] = [] self.active_connections[transcript_id].append(websocket) - def disconnect(self, transcript_id: UUID, websocket: WebSocket): + def disconnect(self, transcript_id: str, websocket: WebSocket): if transcript_id not in self.active_connections: return self.active_connections[transcript_id].remove(websocket) if not self.active_connections[transcript_id]: del self.active_connections[transcript_id] - async def send_json(self, transcript_id: UUID, message): + async def send_json(self, transcript_id: str, message): if transcript_id not in self.active_connections: return for connection in self.active_connections[transcript_id][:]: @@ -227,7 +249,7 @@ ws_manager = WebsocketManager() @router.websocket("/transcripts/{transcript_id}/events") -async def transcript_events_websocket(transcript_id: UUID, websocket: WebSocket): +async def transcript_events_websocket(transcript_id: str, websocket: WebSocket): transcript = transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -283,14 +305,28 @@ async def handle_rtc_event(event: PipelineEvent, args, data): resp = transcript.add_event(event=event, data=topic) transcript.upsert_topic(topic) + await transcripts_controller.update( + transcript, + { + "events": transcript.events, + "topics": transcript.topics, + }, + ) + elif event == PipelineEvent.FINAL_SUMMARY: final_summary = TranscriptFinalSummary(summary=data.summary) resp = transcript.add_event(event=event, data=final_summary) - transcript.summary = final_summary + await transcripts_controller.update( + transcript, + { + "events": transcript.events, + "summary": transcript.summary, + }, + ) elif event == PipelineEvent.STATUS: resp = transcript.add_event(event=event, data=data) - transcript.status = data.value + await transcripts_controller.update(transcript, {"status": transcript.status}) else: logger.warning(f"Unknown event: {event}") @@ -302,7 +338,7 @@ async def handle_rtc_event(event: PipelineEvent, args, data): @router.post("/transcripts/{transcript_id}/record/webrtc") async def transcript_record_webrtc( - transcript_id: UUID, params: RtcOffer, request: Request + transcript_id: str, params: RtcOffer, request: Request ): transcript = transcripts_controller.get_by_id(transcript_id) if not transcript: diff --git a/server/test.db b/server/test.db new file mode 100644 index 0000000000000000000000000000000000000000..974a6a14e9005e55574c4cea2c0b7f5b04ae13d6 GIT binary patch literal 20480 zcmeI2OKerWq?jB#3YHy&qm6oM5#9Cjr+J)e2Gcy)H83(RwCT z5Qs&qvhEHEv8<5T01}%bv8hrEfNb>Jt%FTZiK6pQb~2h-!H zk0$Z(?y;{)^=lWOj&M?EI9Y_6T1|D; zOAn{gL%wAKnLs9x31kA9Kqin0WCEE$CXfka0-3B#?gp-&p!VYw(l7ZvS8X5Bt}9pY(pzyVCtz_k*tK{JHaf z=X32pw7=W_%-ZkP-dVe_`kU4Jt7lezwGvj^Cx3o&y!`LwA207L{R1TOEfdHDGJ#Kn z!2U*OeE#g}>Q?LY=@xOE7gj0cyt9Z)rjgZ{BcoWbP6$Pq*^86uaCh(OLAVzwrVL?? zh!_&SN#!ObBdn;_{PvyOcitR!#^;Wtu~J@$AW>nhMw}^&v|Jkm;mN~wIIrmO@Z!hk5_HXt6HLRJ!tY>YYzLM0EnQYQ12sUW5Dtyeqa^&_7( zmW*QUkQJmr+(?UzGZay(gTyg<;$%{u?=k0`(d6;?d}n<2NS9mVuwf2GY6r93Nol`_1!DwS`&REwU)P6B<87_lC43QQ~oI>r;Pm3M`p zbE8-4Dr1CTW%ORGMbBvBA_k8G=Mi*+Lt1--xG+Rp5lj){oaj}uiff`v#OpN>M1V^i zJH(^`5QPC*R|KRKp;k*ve3fjo3xsSEGtxpDDof=TYRZ&Gp{yr}D+z8!Cy^mVL`*2g zA>bm!Vm{(0%8;*RBHub&L#zv{nKuQpOaTfKpq^t0sEE!LMU=c?rShz#sB{^o+?0r) zuOYUA1;P|~TxfW3fY@OikidjTLlsnEnQk)*q>?3s@-mBWovFLaioi5<3IfT6fOV=n zERluwCYhztbCN*JahHuS<3d#KK38)WdmRX2khvTKU?-vQw6};wE-Yu>hV-AlGwT^| z3OXW`LGF|kPS<3uaF{US5Wy7OHBf#=Q;9&@2ZABFjhQV=13_0jgNH`I}YbiW_GVJF2Or_za&*M&wa)E7BFxJ3H4n9W{pr1iv2nZWDE(k8m zVfVk4Q8~;p)3TCn*JM2un}MM;LVQGR8 z5v3prbxOC{!7G)S%iZ6}(Cr z8WpK3?_`ZKbT6uuFeo?)lmRTXH6@^`8MahP=lQ%6DP^G>m?zzGxh5-ARKP-^U=6e% z4gJ_@NDR&jD9C6q^(EyQIZ?@Cf^pd`$4fQYXn5gh(8zlWGaphJS?>tKm|7U0)F@nb z%h|GIBnZ5F)zubjvV{W*$y*&fKnPwy)p@v#Hf&kGsZ#Abvg#=7)2{r%^?Wjf<s zq^~5yCQHwA$*{=|^W|jNWF`4h`PL0~j4vj`CR@YxWY}a0_(C#lveP@844W+Jo-Y*} ztliEe!zO#Q=c>1U`ey8OGHkNzI+YBYY_A5%u*p8EpA4I9mwKg-4VFdSWY}c)(@BO+ zwmI!&*knJmmJFM0TUL`{ljX@uGHkL7IhhQbY&Vum9~ Date: Tue, 15 Aug 2023 17:09:36 +0200 Subject: [PATCH 08/13] server: fixes for tests --- server/reflector/views/transcripts.py | 38 ++++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 124781fc..f2a8425e 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -75,6 +75,12 @@ class Transcript(BaseModel): else: self.topics.append(topic) + def events_dump(self, mode="json"): + return [event.model_dump(mode=mode) for event in self.events] + + def topics_dump(self, mode="json"): + return [topic.model_dump(mode=mode) for topic in self.topics] + class TranscriptController: async def get_all(self) -> list[Transcript]: @@ -192,7 +198,7 @@ async def transcript_delete(transcript_id: str): @router.get("/transcripts/{transcript_id}/audio") async def transcript_get_audio(transcript_id: str): - transcript = transcripts_controller.get_by_id(transcript_id) + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -202,7 +208,7 @@ async def transcript_get_audio(transcript_id: str): @router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic]) async def transcript_get_topics(transcript_id: str): - transcript = transcripts_controller.get_by_id(transcript_id) + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") return transcript.topics @@ -250,7 +256,7 @@ ws_manager = WebsocketManager() @router.websocket("/transcripts/{transcript_id}/events") async def transcript_events_websocket(transcript_id: str, websocket: WebSocket): - transcript = transcripts_controller.get_by_id(transcript_id) + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -282,7 +288,7 @@ async def handle_rtc_event(event: PipelineEvent, args, data): # transcript from the database for each event. # print(f"Event: {event}", args, data) transcript_id = args - transcript = transcripts_controller.get_by_id(transcript_id) + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: return @@ -294,6 +300,12 @@ async def handle_rtc_event(event: PipelineEvent, args, data): # FIXME don't do copy if event == PipelineEvent.TRANSCRIPT: resp = transcript.add_event(event=event, data=TranscriptText(text=data.text)) + await transcripts_controller.update( + transcript, + { + "events": transcript.events_dump(), + }, + ) elif event == PipelineEvent.TOPIC: topic = TranscriptTopic( @@ -308,8 +320,8 @@ async def handle_rtc_event(event: PipelineEvent, args, data): await transcripts_controller.update( transcript, { - "events": transcript.events, - "topics": transcript.topics, + "events": transcript.events_dump(), + "topics": transcript.topics_dump(), }, ) @@ -319,14 +331,20 @@ async def handle_rtc_event(event: PipelineEvent, args, data): await transcripts_controller.update( transcript, { - "events": transcript.events, - "summary": transcript.summary, + "events": transcript.events_dump(), + "summary": final_summary.summary, }, ) elif event == PipelineEvent.STATUS: resp = transcript.add_event(event=event, data=data) - await transcripts_controller.update(transcript, {"status": transcript.status}) + await transcripts_controller.update( + transcript, + { + "events": transcript.events_dump(), + "status": data.value, + }, + ) else: logger.warning(f"Unknown event: {event}") @@ -340,7 +358,7 @@ async def handle_rtc_event(event: PipelineEvent, args, data): async def transcript_record_webrtc( transcript_id: str, params: RtcOffer, request: Request ): - transcript = transcripts_controller.get_by_id(transcript_id) + transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") From 290b552479843fdcaef87544e28d3376cb5ee01c Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 15 Aug 2023 17:11:01 +0200 Subject: [PATCH 09/13] server: update documentation --- server/env.example | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/env.example b/server/env.example index 13157721..11e0927b 100644 --- a/server/env.example +++ b/server/env.example @@ -4,11 +4,12 @@ # ## ======================================================= -## Sentry +## Database ## ======================================================= -## Sentry DSN configuration -#SENTRY_DSN= +#DATABASE_URL=sqlite://./reflector.db +#DATABASE_URL=postgresql://reflector:reflector@localhost:5432/reflector + ## ======================================================= ## Transcription backend @@ -70,3 +71,10 @@ #LLM_URL=http://localhost:4891/v1/completions #LLM_OPENAI_MODEL="GPT4All Falcon" +## ======================================================= +## Sentry +## ======================================================= + +## Sentry DSN configuration +#SENTRY_DSN= + From a809e5e734c10a3e303c3f3d6af80e57d7c6a679 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 15 Aug 2023 19:01:39 +0200 Subject: [PATCH 10/13] server: implement wav/mp3 audio download If set, will save audio transcription to disk. MP3 conversion is on-request, but cached to disk as well only if it is successfull. Closes #148 --- server/reflector/processors/__init__.py | 1 + .../reflector/processors/audio_file_writer.py | 35 ++++++++++ server/reflector/settings.py | 3 + server/reflector/views/rtc_offer.py | 16 ++++- server/reflector/views/transcripts.py | 69 ++++++++++++++++++- server/tests/test_transcripts_rtc_ws.py | 16 ++++- 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 server/reflector/processors/audio_file_writer.py diff --git a/server/reflector/processors/__init__.py b/server/reflector/processors/__init__.py index da890513..8a926f30 100644 --- a/server/reflector/processors/__init__.py +++ b/server/reflector/processors/__init__.py @@ -1,5 +1,6 @@ from .base import Processor, ThreadedProcessor, Pipeline # noqa: F401 from .types import AudioFile, Transcript, Word, TitleSummary, FinalSummary # noqa: F401 +from .audio_file_writer import AudioFileWriterProcessor # noqa: F401 from .audio_chunker import AudioChunkerProcessor # noqa: F401 from .audio_merge import AudioMergeProcessor # noqa: F401 from .audio_transcript import AudioTranscriptProcessor # noqa: F401 diff --git a/server/reflector/processors/audio_file_writer.py b/server/reflector/processors/audio_file_writer.py new file mode 100644 index 00000000..c597f81d --- /dev/null +++ b/server/reflector/processors/audio_file_writer.py @@ -0,0 +1,35 @@ +from reflector.processors.base import Processor +import av +import wave +from pathlib import Path + + +class AudioFileWriterProcessor(Processor): + """ + Write audio frames to a file. + """ + + INPUT_TYPE = av.AudioFrame + OUTPUT_TYPE = av.AudioFrame + + def __init__(self, path: Path | str): + super().__init__() + if isinstance(path, str): + path = Path(path) + self.path = path + self.fd = None + + async def _push(self, data: av.AudioFrame): + if not self.fd: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.fd = wave.open(self.path.as_posix(), "wb") + self.fd.setnchannels(len(data.layout.channels)) + self.fd.setsampwidth(data.format.bytes) + self.fd.setframerate(data.sample_rate) + self.fd.writeframes(data.to_ndarray().tobytes()) + await self.emit(data) + + async def _flush(self): + if self.fd: + self.fd.close() + self.fd = None diff --git a/server/reflector/settings.py b/server/reflector/settings.py index e776875b..0787b466 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -9,6 +9,9 @@ class Settings(BaseSettings): # Database DATABASE_URL: str = "sqlite:///./reflector.sqlite3" + # local data directory (audio for no) + DATA_DIR: str = "./data" + # Whisper WHISPER_MODEL_SIZE: str = "tiny" WHISPER_REAL_TIME_MODEL_SIZE: str = "tiny" diff --git a/server/reflector/views/rtc_offer.py b/server/reflector/views/rtc_offer.py index aef00580..c0944a82 100644 --- a/server/reflector/views/rtc_offer.py +++ b/server/reflector/views/rtc_offer.py @@ -7,12 +7,14 @@ from reflector.logger import logger from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack from json import loads, dumps from enum import StrEnum +from pathlib import Path import av from reflector.processors import ( Pipeline, AudioChunkerProcessor, AudioMergeProcessor, AudioTranscriptAutoProcessor, + AudioFileWriterProcessor, TranscriptLinerProcessor, TranscriptTopicDetectorProcessor, TranscriptFinalSummaryProcessor, @@ -64,7 +66,11 @@ class PipelineEvent(StrEnum): async def rtc_offer_base( - params: RtcOffer, request: Request, event_callback=None, event_callback_args=None + params: RtcOffer, + request: Request, + event_callback=None, + event_callback_args=None, + audio_filename: Path | None = None, ): # build an rtc session offer = RTCSessionDescription(sdp=params.sdp, type=params.type) @@ -151,14 +157,18 @@ async def rtc_offer_base( # create a context for the whole rtc transaction # add a customised logger to the context - ctx.pipeline = Pipeline( + processors = [] + if audio_filename is not None: + processors += [AudioFileWriterProcessor(path=audio_filename)] + processors += [ AudioChunkerProcessor(), AudioMergeProcessor(), AudioTranscriptAutoProcessor.as_threaded(callback=on_transcript), TranscriptLinerProcessor(), TranscriptTopicDetectorProcessor.as_threaded(callback=on_topic), TranscriptFinalSummaryProcessor.as_threaded(callback=on_final_summary), - ) + ] + ctx.pipeline = Pipeline(*processors) # FIXME: warmup is not working well yet # await ctx.pipeline.warmup() diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index f2a8425e..6f952938 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -5,14 +5,20 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) +from fastapi.responses import FileResponse +from starlette.concurrency import run_in_threadpool from pydantic import BaseModel, Field from uuid import uuid4 from datetime import datetime from fastapi_pagination import Page, paginate from reflector.logger import logger from reflector.db import database, transcripts +from reflector.settings import settings from .rtc_offer import rtc_offer_base, RtcOffer, PipelineEvent from typing import Optional +from pathlib import Path +from tempfile import NamedTemporaryFile +import av router = APIRouter() @@ -81,6 +87,44 @@ class Transcript(BaseModel): def topics_dump(self, mode="json"): return [topic.model_dump(mode=mode) for topic in self.topics] + def convert_audio_to_mp3(self): + fn = self.audio_mp3_filename + if fn.exists(): + return + + logger.info(f"Converting audio to mp3: {self.audio_filename}") + inp = av.open(self.audio_filename.as_posix(), "r") + + # create temporary file for mp3 + with NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + out = av.open(tmp.name, "w") + stream = out.add_stream("mp3") + for frame in inp.decode(audio=0): + frame.pts = None + for packet in stream.encode(frame): + out.mux(packet) + for packet in stream.encode(None): + out.mux(packet) + out.close() + + # move temporary file to final location + Path(tmp.name).rename(fn) + + def unlink(self): + self.data_path.unlink(missing_ok=True) + + @property + def data_path(self): + return Path(settings.DATA_DIR) / self.id + + @property + def audio_filename(self): + return self.data_path / "audio.wav" + + @property + def audio_mp3_filename(self): + return self.data_path / "audio.mp3" + class TranscriptController: async def get_all(self) -> list[Transcript]: @@ -112,6 +156,10 @@ class TranscriptController: setattr(transcript, key, value) async def remove_by_id(self, transcript_id: str) -> None: + transcript = await self.get_by_id(transcript_id) + if not transcript: + return + transcript.unlink() query = transcripts.delete().where(transcripts.c.id == transcript_id) await database.execute(query) @@ -202,8 +250,24 @@ async def transcript_get_audio(transcript_id: str): if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - # TODO: Implement audio generation - return HTTPException(status_code=500, detail="Not implemented") + if not transcript.audio_filename.exists(): + raise HTTPException(status_code=404, detail="Audio not found") + + return FileResponse(transcript.audio_filename, media_type="audio/wav") + + +@router.get("/transcripts/{transcript_id}/audio/mp3") +async def transcript_get_audio_mp3(transcript_id: str): + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + raise HTTPException(status_code=404, detail="Transcript not found") + + if not transcript.audio_filename.exists(): + raise HTTPException(status_code=404, detail="Audio not found") + + await run_in_threadpool(transcript.convert_audio_to_mp3) + + return FileResponse(transcript.audio_mp3_filename, media_type="audio/mp3") @router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic]) @@ -371,4 +435,5 @@ async def transcript_record_webrtc( request, event_callback=handle_rtc_event, event_callback_args=transcript_id, + audio_filename=transcript.audio_filename, ) diff --git a/server/tests/test_transcripts_rtc_ws.py b/server/tests/test_transcripts_rtc_ws.py index 70ee209b..f38728c2 100644 --- a/server/tests/test_transcripts_rtc_ws.py +++ b/server/tests/test_transcripts_rtc_ws.py @@ -70,11 +70,15 @@ async def dummy_llm(): @pytest.mark.asyncio -async def test_transcript_rtc_and_websocket(dummy_transcript, dummy_llm): +async def test_transcript_rtc_and_websocket(tmpdir, dummy_transcript, dummy_llm): # goal: start the server, exchange RTC, receive websocket events # because of that, we need to start the server in a thread # to be able to connect with aiortc + from reflector.settings import settings + + settings.DATA_DIR = Path(tmpdir) + # start server host = "127.0.0.1" port = 1255 @@ -188,3 +192,13 @@ async def test_transcript_rtc_and_websocket(dummy_transcript, dummy_llm): resp = await ac.get(f"/transcripts/{tid}") assert resp.status_code == 200 assert resp.json()["status"] == "ended" + + # check that audio is available + resp = await ac.get(f"/transcripts/{tid}/audio") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "audio/wav" + + # check that audio/mp3 is available + resp = await ac.get(f"/transcripts/{tid}/audio/mp3") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "audio/mp3" From 0b2fb6ee85f7a3272e1c403ad908ddb34e556b87 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 16 Aug 2023 09:53:39 +0200 Subject: [PATCH 11/13] server: remove non-apified server --- server/reflector/models.py | 211 --------------- server/reflector/server.py | 381 ---------------------------- server/reflector/views/rtc_offer.py | 11 +- server/tests/test_basic_rtc.py | 63 ----- 4 files changed, 9 insertions(+), 657 deletions(-) delete mode 100644 server/reflector/models.py delete mode 100644 server/reflector/server.py delete mode 100644 server/tests/test_basic_rtc.py diff --git a/server/reflector/models.py b/server/reflector/models.py deleted file mode 100644 index d1aaaa1e..00000000 --- a/server/reflector/models.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Collection of data classes for streamlining and rigidly structuring -the input and output parameters of functions -""" - -import datetime -from dataclasses import dataclass -from typing import List -from sortedcontainers import SortedDict - -import av - - -@dataclass -class TitleSummaryInput: - """ - Data class for the input to generate title and summaries. - The outcome will be used to send query to the LLM for processing. - """ - - input_text = str - transcribed_time = float - prompt = str - data = dict - - def __init__(self, transcribed_time, input_text=""): - self.input_text = input_text - self.transcribed_time = transcribed_time - self.prompt = f""" - ### Human: - Create a JSON object as response.The JSON object must have 2 fields: - i) title and ii) summary.For the title field,generate a short title - for the given text. For the summary field, summarize the given text - in three sentences. - - {self.input_text} - - ### Assistant: - """ - self.data = {"prompt": self.prompt} - self.headers = {"Content-Type": "application/json"} - - -@dataclass -class IncrementalResult: - """ - Data class for the result of generating one title and summaries. - Defines how a single "topic" looks like. - """ - - title = str - description = str - transcript = str - timestamp = str - - def __init__(self, title, desc, transcript, timestamp): - self.title = title - self.description = desc - self.transcript = transcript - self.timestamp = timestamp - - -@dataclass -class TitleSummaryOutput: - """ - Data class for the result of all generated titles and summaries. - The result will be sent back to the client - """ - - cmd = str - topics = List[IncrementalResult] - - def __init__(self, inc_responses): - self.topics = inc_responses - self.cmd = "UPDATE_TOPICS" - - def get_result(self) -> dict: - """ - Return the result dict for displaying the transcription - :return: - """ - return {"cmd": self.cmd, "topics": self.topics} - - -@dataclass -class ParseLLMResult: - """ - Data class to parse the result returned by the LLM while generating title - and summaries. The result will be sent back to the client. - """ - - title = str - description = str - transcript = str - timestamp = str - - def __init__(self, param: TitleSummaryInput, output: dict): - self.title = output["title"] - self.transcript = param.input_text - self.description = output.pop("summary") - self.timestamp = str(datetime.timedelta(seconds=round(param.transcribed_time))) - - def get_result(self) -> dict: - """ - Return the result dict after parsing the response from LLM - :return: - """ - return { - "title": self.title, - "description": self.description, - "transcript": self.transcript, - "timestamp": self.timestamp, - } - - -@dataclass -class TranscriptionInput: - """ - Data class to define the input to the transcription function - AudioFrames -> input - """ - - frames = List[av.audio.frame.AudioFrame] - - def __init__(self, frames): - self.frames = frames - - -@dataclass -class TranscriptionOutput: - """ - Dataclass to define the result of the transcription function. - The result will be sent back to the client - """ - - cmd = str - result_text = str - - def __init__(self, result_text): - self.cmd = "SHOW_TRANSCRIPTION" - self.result_text = result_text - - def get_result(self) -> dict: - """ - Return the result dict for displaying the transcription - :return: - """ - return {"cmd": self.cmd, "text": self.result_text} - - -@dataclass -class FinalSummaryResult: - """ - Dataclass to define the result of the final summary function. - The result will be sent back to the client. - """ - - cmd = str - final_summary = str - duration = str - - def __init__(self, final_summary, time): - self.duration = str(datetime.timedelta(seconds=round(time))) - self.final_summary = final_summary - self.cmd = "DISPLAY_FINAL_SUMMARY" - - def get_result(self) -> dict: - """ - Return the result dict for displaying the final summary - :return: - """ - return { - "cmd": self.cmd, - "duration": self.duration, - "summary": self.final_summary, - } - - -class BlackListedMessages: - """ - Class to hold the blacklisted messages. These messages should be filtered - out and not sent back to the client as part of the transcription. - """ - - messages = [ - " Thank you.", - " See you next time!", - " Thank you for watching!", - " Bye!", - " And that's what I'm talking about.", - ] - - -@dataclass -class TranscriptionContext: - transcription_text: str - last_transcribed_time: float - incremental_responses: List[IncrementalResult] - sorted_transcripts: dict - data_channel: None # FIXME - logger: None - status: str - - def __init__(self, logger): - self.transcription_text = "" - self.last_transcribed_time = 0.0 - self.incremental_responses = [] - self.data_channel = None - self.sorted_transcripts = SortedDict() - self.status = "idle" - self.logger = logger diff --git a/server/reflector/server.py b/server/reflector/server.py deleted file mode 100644 index 8e28b583..00000000 --- a/server/reflector/server.py +++ /dev/null @@ -1,381 +0,0 @@ -import argparse -import asyncio -import datetime -import json -import os -import wave -import uuid -from concurrent.futures import ThreadPoolExecutor -from typing import NoReturn, Union - -import aiohttp_cors -import av -import requests -from aiohttp import web -from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription -from aiortc.contrib.media import MediaRelay -from faster_whisper import WhisperModel - -from reflector.models import ( - BlackListedMessages, - FinalSummaryResult, - ParseLLMResult, - TitleSummaryInput, - TitleSummaryOutput, - TranscriptionInput, - TranscriptionOutput, - TranscriptionContext, -) -from reflector.logger import logger -from reflector.utils.run_utils import run_in_executor -from reflector.settings import settings - -# WebRTC components -pcs = set() -relay = MediaRelay() -executor = ThreadPoolExecutor() - -# Transcription model -model = WhisperModel("tiny", device="cpu", compute_type="float32", num_workers=12) - -# LLM -LLM_URL = settings.LLM_URL -if not LLM_URL: - assert settings.LLM_BACKEND == "oobagooda" - LLM_URL = f"http://{settings.LLM_HOST}:{settings.LLM_PORT}/api/v1/generate" -logger.info(f"Using LLM [{settings.LLM_BACKEND}]: {LLM_URL}") - - -def parse_llm_output( - param: TitleSummaryInput, response: requests.Response -) -> Union[None, ParseLLMResult]: - """ - Function to parse the LLM response - :param param: - :param response: - :return: - """ - try: - output = json.loads(response.json()["results"][0]["text"]) - return ParseLLMResult(param, output) - except Exception: - logger.exception("Exception while parsing LLM output") - return None - - -def get_title_and_summary( - ctx: TranscriptionContext, param: TitleSummaryInput -) -> Union[None, TitleSummaryOutput]: - """ - From the input provided (transcript), query the LLM to generate - topics and summaries - :param param: - :return: - """ - logger.info("Generating title and summary") - - # TODO : Handle unexpected output formats from the model - try: - response = requests.post(LLM_URL, headers=param.headers, json=param.data) - output = parse_llm_output(param, response) - if output: - result = output.get_result() - ctx.incremental_responses.append(result) - return TitleSummaryOutput(ctx.incremental_responses) - except Exception: - logger.exception("Exception while generating title and summary") - return None - - -def channel_send(channel, message: str) -> NoReturn: - """ - Send text messages via the data channel - :param channel: - :param message: - :return: - """ - if channel: - channel.send(message) - - -def channel_send_increment( - channel, param: Union[FinalSummaryResult, TitleSummaryOutput] -) -> NoReturn: - """ - Send the incremental topics and summaries via the data channel - :param channel: - :param param: - :return: - """ - if channel and param: - message = param.get_result() - channel.send(json.dumps(message)) - - -def channel_send_transcript(ctx: TranscriptionContext) -> NoReturn: - """ - Send the transcription result via the data channel - :param channel: - :return: - """ - if not ctx.data_channel: - return - try: - least_time = next(iter(ctx.sorted_transcripts)) - message = ctx.sorted_transcripts[least_time].get_result() - if message: - del ctx.sorted_transcripts[least_time] - if message["text"] not in BlackListedMessages.messages: - ctx.data_channel.send(json.dumps(message)) - # Due to exceptions if one of the earlier batches can't return - # a transcript, we don't want to be stuck waiting for the result - # With the threshold size of 3, we pop the first(lost) element - else: - if len(ctx.sorted_transcripts) >= 3: - del ctx.sorted_transcripts[least_time] - except Exception: - logger.exception("Exception while sending transcript") - - -def get_transcription( - ctx: TranscriptionContext, input_frames: TranscriptionInput -) -> Union[None, TranscriptionOutput]: - """ - From the collected audio frames create transcription by inferring from - the chosen transcription model - :param input_frames: - :return: - """ - ctx.logger.info("Transcribing..") - ctx.sorted_transcripts[input_frames.frames[0].time] = None - - # TODO: Find cleaner way, watch "no transcription" issue below - # Passing IO objects instead of temporary files throws an error - # Passing ndarray (type casted with float) does not give any - # transcription. Refer issue, - # https://github.com/guillaumekln/faster-whisper/issues/369 - audio_file = "test" + str(datetime.datetime.now()) - wf = wave.open(audio_file, "wb") - wf.setnchannels(settings.AUDIO_CHANNELS) - wf.setframerate(settings.AUDIO_SAMPLING_RATE) - wf.setsampwidth(settings.AUDIO_SAMPLING_WIDTH) - - for frame in input_frames.frames: - wf.writeframes(b"".join(frame.to_ndarray())) - wf.close() - - result_text = "" - - try: - segments, _ = model.transcribe( - audio_file, - language="en", - beam_size=5, - vad_filter=True, - vad_parameters={"min_silence_duration_ms": 500}, - ) - os.remove(audio_file) - segments = list(segments) - result_text = "" - duration = 0.0 - for segment in segments: - result_text += segment.text - start_time = segment.start - end_time = segment.end - if not segment.start: - start_time = 0.0 - if not segment.end: - end_time = 5.5 - duration += end_time - start_time - - ctx.last_transcribed_time += duration - ctx.transcription_text += result_text - - except Exception: - logger.exception("Exception while transcribing") - - result = TranscriptionOutput(result_text) - ctx.sorted_transcripts[input_frames.frames[0].time] = result - return result - - -def get_final_summary_response(ctx: TranscriptionContext) -> FinalSummaryResult: - """ - Collate the incremental summaries generated so far and return as the final - summary - :return: - """ - final_summary = "" - - # Collate inc summaries - for topic in ctx.incremental_responses: - final_summary += topic["description"] - - response = FinalSummaryResult(final_summary, ctx.last_transcribed_time) - - with open( - "./artefacts/meeting_titles_and_summaries.txt", "a", encoding="utf-8" - ) as file: - file.write(json.dumps(ctx.incremental_responses)) - - return response - - -class AudioStreamTrack(MediaStreamTrack): - """ - An audio stream track. - """ - - kind = "audio" - - def __init__(self, ctx: TranscriptionContext, track): - super().__init__() - self.ctx = ctx - self.track = track - self.audio_buffer = av.AudioFifo() - - async def recv(self) -> av.audio.frame.AudioFrame: - ctx = self.ctx - frame = await self.track.recv() - self.audio_buffer.write(frame) - - if local_frames := self.audio_buffer.read_many( - settings.AUDIO_BUFFER_SIZE, partial=False - ): - whisper_result = run_in_executor( - get_transcription, - ctx, - TranscriptionInput(local_frames), - executor=executor, - ) - whisper_result.add_done_callback( - lambda f: channel_send_transcript(ctx) if f.result() else None - ) - - if len(ctx.transcription_text) > 25: - llm_input_text = ctx.transcription_text - ctx.transcription_text = "" - param = TitleSummaryInput( - input_text=llm_input_text, transcribed_time=ctx.last_transcribed_time - ) - llm_result = run_in_executor( - get_title_and_summary, ctx, param, executor=executor - ) - llm_result.add_done_callback( - lambda f: channel_send_increment(ctx.data_channel, llm_result.result()) - if f.result() - else None - ) - return frame - - -async def offer(request: requests.Request) -> web.Response: - """ - Establish the WebRTC connection with the client - :param request: - :return: - """ - params = await request.json() - offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) - - # client identification - peername = request.transport.get_extra_info("peername") - if peername is not None: - clientid = f"{peername[0]}:{peername[1]}" - else: - clientid = uuid.uuid4() - - # create a context for the whole rtc transaction - # add a customised logger to the context - ctx = TranscriptionContext(logger=logger.bind(client=clientid)) - - # handle RTC peer connection - pc = RTCPeerConnection() - pcs.add(pc) - - @pc.on("datachannel") - def on_datachannel(channel) -> NoReturn: - ctx.data_channel = channel - ctx.logger = ctx.logger.bind(channel=channel.label) - ctx.logger.info("Channel created by remote party") - - @channel.on("message") - def on_message(message: str) -> NoReturn: - ctx.logger.info(f"Message: {message}") - if json.loads(message)["cmd"] == "STOP": - # Placeholder final summary - response = get_final_summary_response() - channel_send_increment(channel, response) - # To-do Add code to stop connection from server side here - # But have to handshake with client once - - if isinstance(message, str) and message.startswith("ping"): - channel_send(channel, "pong" + message[4:]) - - @pc.on("connectionstatechange") - async def on_connectionstatechange() -> NoReturn: - ctx.logger.info(f"Connection state changed: {pc.connectionState}") - if pc.connectionState == "failed": - await pc.close() - pcs.discard(pc) - - @pc.on("track") - def on_track(track) -> NoReturn: - ctx.logger.info(f"Track {track.kind} received") - pc.addTrack(AudioStreamTrack(ctx, relay.subscribe(track))) - - await pc.setRemoteDescription(offer) - - answer = await pc.createAnswer() - await pc.setLocalDescription(answer) - return web.Response( - content_type="application/json", - text=json.dumps( - {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type} - ), - ) - - -async def on_shutdown(application: web.Application) -> NoReturn: - """ - On shutdown, the coroutines that shutdown client connections are - executed - :param application: - :return: - """ - coroutines = [pc.close() for pc in pcs] - await asyncio.gather(*coroutines) - pcs.clear() - - -def create_app() -> web.Application: - """ - Create the web application - """ - app = web.Application() - cors = aiohttp_cors.setup( - app, - defaults={ - "*": aiohttp_cors.ResourceOptions( - allow_credentials=True, expose_headers="*", allow_headers="*" - ) - }, - ) - - offer_resource = cors.add(app.router.add_resource("/offer")) - cors.add(offer_resource.add_route("POST", offer)) - app.on_shutdown.append(on_shutdown) - return app - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="WebRTC based server for Reflector") - parser.add_argument( - "--host", default="0.0.0.0", help="Server host IP (def: 0.0.0.0)" - ) - parser.add_argument( - "--port", type=int, default=1250, help="Server port (def: 1250)" - ) - args = parser.parse_args() - app = create_app() - web.run_app(app, access_log=None, host=args.host, port=args.port) diff --git a/server/reflector/views/rtc_offer.py b/server/reflector/views/rtc_offer.py index c0944a82..f28eb021 100644 --- a/server/reflector/views/rtc_offer.py +++ b/server/reflector/views/rtc_offer.py @@ -2,7 +2,6 @@ import asyncio from fastapi import Request, APIRouter from reflector.events import subscribers_shutdown from pydantic import BaseModel -from reflector.models import TranscriptionContext from reflector.logger import logger from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack from json import loads, dumps @@ -27,6 +26,15 @@ sessions = [] router = APIRouter() +class TranscriptionContext(object): + def __init__(self, logger): + self.logger = logger + self.pipeline = None + self.data_channel = None + self.status = "idle" + self.topics = [] + + class AudioStreamTrack(MediaStreamTrack): """ An audio stream track. @@ -79,7 +87,6 @@ async def rtc_offer_base( peername = request.client clientid = f"{peername[0]}:{peername[1]}" ctx = TranscriptionContext(logger=logger.bind(client=clientid)) - ctx.topics = [] async def update_status(status: str): changed = ctx.status != status diff --git a/server/tests/test_basic_rtc.py b/server/tests/test_basic_rtc.py deleted file mode 100644 index 93f33648..00000000 --- a/server/tests/test_basic_rtc.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from unittest.mock import patch - - -@pytest.mark.asyncio -async def test_basic_rtc_server(aiohttp_server, event_loop): - # goal is to start the server, and send rtc audio to it - # validate the events received - import argparse - import json - from pathlib import Path - from reflector.server import create_app - from reflector.stream_client import StreamClient - from reflector.models import TitleSummaryOutput - from aiortc.contrib.signaling import add_signaling_arguments, create_signaling - - # customize settings to have a mock LLM server - with patch("reflector.server.get_title_and_summary") as mock_llm: - # any response from mock_llm will be test topic - mock_llm.return_value = TitleSummaryOutput(["topic_test"]) - - # create the server - app = create_app() - server = await aiohttp_server(app) - url = f"http://{server.host}:{server.port}/offer" - - # create signaling - parser = argparse.ArgumentParser() - add_signaling_arguments(parser) - args = parser.parse_args(["-s", "tcp-socket"]) - signaling = create_signaling(args) - - # create the client - path = Path(__file__).parent / "records" / "test_mathieu_hello.wav" - client = StreamClient(signaling, url=url, play_from=path.as_posix()) - await client.start() - - # we just want the first transcription - # and topic update messages - - marks = { - "SHOW_TRANSCRIPTION": False, - "UPDATE_TOPICS": False, - } - - async for rawmsg in client.get_reader(): - msg = json.loads(rawmsg) - cmd = msg["cmd"] - if cmd == "SHOW_TRANSCRIPTION": - assert "text" in msg - assert "want to share my incredible experience" in msg["text"] - elif cmd == "UPDATE_TOPICS": - assert "topics" in msg - assert "topic_test" in msg["topics"] - marks[cmd] = True - - # break if we have all the events we need - if all(marks.values()): - break - - # stop the server - await server.close() - await client.stop() From 2f0e9a51f73b6dbf6c87eaab614bd305dfa44741 Mon Sep 17 00:00:00 2001 From: Gokul Mohanarangan Date: Wed, 16 Aug 2023 13:28:23 +0530 Subject: [PATCH 12/13] integrate reflector-gpu-modal repo --- server/gpu/modal/README.md | 92 ++++++++++++ server/gpu/modal/reflector_llm.py | 170 +++++++++++++++++++++ server/gpu/modal/reflector_transcriber.py | 173 ++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 server/gpu/modal/README.md create mode 100644 server/gpu/modal/reflector_llm.py create mode 100644 server/gpu/modal/reflector_transcriber.py diff --git a/server/gpu/modal/README.md b/server/gpu/modal/README.md new file mode 100644 index 00000000..9491a00c --- /dev/null +++ b/server/gpu/modal/README.md @@ -0,0 +1,92 @@ +# Reflector GPU implementation - Transcription and LLM + +This repository hold an API for the GPU implementation of the Reflector API service, +and use [Modal.com](https://modal.com) + +- `reflector_llm.py` - LLM API +- `reflector_transcriber.py` - Transcription API + +## Modal.com deployment + +Create a modal secret, and name it `reflector-gpu`. +It should contain an `REFLECTOR_APIKEY` environment variable with a value. + +The deployment is done using [Modal.com](https://modal.com) service. + +``` +$ modal deploy reflector_transcriber.py +... +└── 🔨 Created web => https://xxxx--reflector-transcriber-web.modal.run + +$ modal deploy reflector_llm.py +... +└── 🔨 Created web => https://xxxx--reflector-llm-web.modal.run +``` + +Then in your reflector api configuration `.env`, you can set theses keys: + +``` +TRANSCRIPT_BACKEND=modal +TRANSCRIPT_URL=https://xxxx--reflector-transcriber-web.modal.run +TRANSCRIPT_MODAL_API_KEY=REFLECTOR_APIKEY + +LLM_BACKEND=modal +LLM_URL=https://xxxx--reflector-llm-web.modal.run +LLM_MODAL_API_KEY=REFLECTOR_APIKEY +``` + +## API + +Authentication must be passed with the `Authorization` header, using the `bearer` scheme. + +``` +Authorization: bearer +``` + +### Warmup (both) + +`POST /warmup` + +**response** +``` +{ + "status": "ok" +} +``` + +### LLM + +`POST /llm` + +**request** +``` +{ + "prompt": "xxx" +} +``` + +**response** +``` +{ + "text": "xxx completed" +} +``` + +### Transcription + +`POST /transcribe` + +**request** (multipart/form-data) + +- `file` - audio file +- `language` - language code (e.g. `en`) + +**response** +``` +{ + "text": "xxx", + "words": [ + {"text": "xxx", "start": 0.0, "end": 1.0} + ] +} +``` diff --git a/server/gpu/modal/reflector_llm.py b/server/gpu/modal/reflector_llm.py new file mode 100644 index 00000000..bf6f4cf5 --- /dev/null +++ b/server/gpu/modal/reflector_llm.py @@ -0,0 +1,170 @@ +""" +Reflector GPU backend - LLM +=========================== + +""" + +import os +from modal import Image, method, Stub, asgi_app, Secret + + +# LLM +LLM_MODEL: str = "lmsys/vicuna-13b-v1.5" +LLM_LOW_CPU_MEM_USAGE: bool = False +LLM_TORCH_DTYPE: str = "bfloat16" +LLM_MAX_NEW_TOKENS: int = 300 + +IMAGE_MODEL_DIR = "/model" + +stub = Stub(name="reflector-llm") + + +def download_llm(): + from huggingface_hub import snapshot_download + + print("Downloading LLM model") + snapshot_download(LLM_MODEL, local_dir=IMAGE_MODEL_DIR) + print("LLM model downloaded") + + +def migrate_cache_llm(): + """ + XXX The cache for model files in Transformers v4.22.0 has been updated. + Migrating your old cache. This is a one-time only operation. You can + interrupt this and resume the migration later on by calling + `transformers.utils.move_cache()`. + """ + from transformers.utils.hub import move_cache + + print("Moving LLM cache") + move_cache() + print("LLM cache moved") + + +llm_image = ( + Image.debian_slim(python_version="3.10.8") + .apt_install("git") + .pip_install( + "transformers", + "torch", + "sentencepiece", + "protobuf", + "einops==0.6.1", + "hf-transfer~=0.1", + "huggingface_hub==0.16.4", + ) + .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"}) + .run_function(download_llm) + .run_function(migrate_cache_llm) +) + + +@stub.cls( + gpu="A100", + timeout=60 * 5, + container_idle_timeout=60 * 5, + concurrency_limit=2, + image=llm_image, +) +class LLM: + def __enter__(self): + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + from transformers.generation import GenerationConfig + + print("Instance llm model") + model = AutoModelForCausalLM.from_pretrained( + IMAGE_MODEL_DIR, + torch_dtype=getattr(torch, LLM_TORCH_DTYPE), + low_cpu_mem_usage=LLM_LOW_CPU_MEM_USAGE, + ) + + # generation configuration + print("Instance llm generation config") + model.config.max_new_tokens = LLM_MAX_NEW_TOKENS + gen_cfg = GenerationConfig.from_model_config(model.config) + gen_cfg.max_new_tokens = LLM_MAX_NEW_TOKENS + + # load tokenizer + print("Instance llm tokenizer") + tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL) + + # move model to gpu + print("Move llm model to GPU") + model = model.cuda() + + print("Warmup llm done") + self.model = model + self.tokenizer = tokenizer + self.gen_cfg = gen_cfg + + def __exit__(self, *args): + print("Exit llm") + + @method() + def warmup(self): + print("Warmup ok") + return {"status": "ok"} + + @method() + def generate(self, prompt: str): + print(f"Generate {prompt=}") + # tokenize prompt + input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to( + self.model.device + ) + output = self.model.generate(input_ids, generation_config=self.gen_cfg) + + # decode output + response = self.tokenizer.decode(output[0].cpu(), skip_special_tokens=True) + print(f"Generated {response=}") + return {"text": response} + + +# ------------------------------------------------------------------- +# Web API +# ------------------------------------------------------------------- + + +@stub.function( + container_idle_timeout=60 * 10, + timeout=60 * 5, + secrets=[ + Secret.from_name("reflector-gpu"), + ], +) +@asgi_app() +def web(): + from fastapi import FastAPI, HTTPException, status, Depends + from fastapi.security import OAuth2PasswordBearer + from pydantic import BaseModel + + llmstub = LLM() + + app = FastAPI() + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + def apikey_auth(apikey: str = Depends(oauth2_scheme)): + if apikey != os.environ["REFLECTOR_GPU_APIKEY"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + class LLMRequest(BaseModel): + prompt: str + + @app.post("/llm", dependencies=[Depends(apikey_auth)]) + async def llm( + req: LLMRequest, + ): + func = llmstub.generate.spawn(prompt=req.prompt) + result = func.get() + return result + + @app.post("/warmup", dependencies=[Depends(apikey_auth)]) + async def warmup(): + return llmstub.warmup.spawn().get() + + return app diff --git a/server/gpu/modal/reflector_transcriber.py b/server/gpu/modal/reflector_transcriber.py new file mode 100644 index 00000000..631233cc --- /dev/null +++ b/server/gpu/modal/reflector_transcriber.py @@ -0,0 +1,173 @@ +""" +Reflector GPU backend - transcriber +=================================== +""" + +import tempfile +import os +from modal import Image, method, Stub, asgi_app, Secret +from pydantic import BaseModel + + +# Whisper +WHISPER_MODEL: str = "large-v2" +WHISPER_COMPUTE_TYPE: str = "float16" +WHISPER_NUM_WORKERS: int = 1 +WHISPER_CACHE_DIR: str = "/cache/whisper" + +stub = Stub(name="reflector-transcriber") + + +def download_whisper(): + from faster_whisper.utils import download_model + + download_model(WHISPER_MODEL, local_files_only=False) + + +whisper_image = ( + Image.debian_slim(python_version="3.10.8") + .apt_install("git") + .pip_install( + "faster-whisper", + "requests", + "torch", + ) + .run_function(download_whisper) + .env( + { + "LD_LIBRARY_PATH": ( + "/usr/local/lib/python3.10/site-packages/nvidia/cudnn/lib/:" + "/opt/conda/lib/python3.10/site-packages/nvidia/cublas/lib/" + ) + } + ) +) + + +@stub.cls( + gpu="A10G", + container_idle_timeout=60, + image=whisper_image, +) +class Whisper: + def __enter__(self): + import torch + import faster_whisper + + self.use_gpu = torch.cuda.is_available() + device = "cuda" if self.use_gpu else "cpu" + self.model = faster_whisper.WhisperModel( + WHISPER_MODEL, + device=device, + compute_type=WHISPER_COMPUTE_TYPE, + num_workers=WHISPER_NUM_WORKERS, + ) + + @method() + def warmup(self): + return {"status": "ok"} + + @method() + def transcribe_segment( + self, + audio_data: str, + audio_suffix: str, + timestamp: float = 0, + language: str = "en", + ): + with tempfile.NamedTemporaryFile("wb+", suffix=f".{audio_suffix}") as fp: + fp.write(audio_data) + + segments, _ = self.model.transcribe( + fp.name, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + transcript = "" + words = [] + if segments: + segments = list(segments) + + for segment in segments: + transcript += segment.text + for word in segment.words: + words.append( + { + "text": word.word, + "start": round(timestamp + word.start, 3), + "end": round(timestamp + word.end, 3), + } + ) + return { + "text": transcript, + "words": words, + } + + +# ------------------------------------------------------------------- +# Web API +# ------------------------------------------------------------------- + + +@stub.function( + container_idle_timeout=60, + timeout=60, + secrets=[ + Secret.from_name("reflector-gpu"), + ], +) +@asgi_app() +def web(): + from fastapi import FastAPI, UploadFile, Form, Depends, HTTPException, status + from fastapi.security import OAuth2PasswordBearer + from typing_extensions import Annotated + + transcriberstub = Whisper() + + app = FastAPI() + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + def apikey_auth(apikey: str = Depends(oauth2_scheme)): + if apikey != os.environ["REFLECTOR_GPU_APIKEY"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + class TranscriptionRequest(BaseModel): + timestamp: float = 0 + language: str = "en" + + class TranscriptResponse(BaseModel): + result: str + + @app.post("/transcribe", dependencies=[Depends(apikey_auth)]) + async def transcribe( + file: UploadFile, + timestamp: Annotated[float, Form()] = 0, + language: Annotated[str, Form()] = "en", + ): + audio_data = await file.read() + audio_suffix = file.filename.split(".")[-1] + assert audio_suffix in ["wav", "mp3", "ogg", "flac"] + + func = transcriberstub.transcribe_segment.spawn( + audio_data=audio_data, + audio_suffix=audio_suffix, + language=language, + timestamp=timestamp, + ) + result = func.get() + return result + + @app.post("/warmup", dependencies=[Depends(apikey_auth)]) + async def warmup(): + return transcriberstub.warmup.spawn().get() + + return app From 33ab54a626d7ff8520b699d79d3ae5383d6e255f Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 16 Aug 2023 10:40:40 +0200 Subject: [PATCH 13/13] server: replace wave module with pyav directly Closes #87 --- .../reflector/processors/audio_file_writer.py | 26 +++++++++++-------- server/reflector/processors/audio_merge.py | 18 ++++++++----- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/server/reflector/processors/audio_file_writer.py b/server/reflector/processors/audio_file_writer.py index c597f81d..d67db65e 100644 --- a/server/reflector/processors/audio_file_writer.py +++ b/server/reflector/processors/audio_file_writer.py @@ -1,6 +1,5 @@ from reflector.processors.base import Processor import av -import wave from pathlib import Path @@ -17,19 +16,24 @@ class AudioFileWriterProcessor(Processor): if isinstance(path, str): path = Path(path) self.path = path - self.fd = None + self.out_container = None + self.out_stream = None async def _push(self, data: av.AudioFrame): - if not self.fd: + if not self.out_container: self.path.parent.mkdir(parents=True, exist_ok=True) - self.fd = wave.open(self.path.as_posix(), "wb") - self.fd.setnchannels(len(data.layout.channels)) - self.fd.setsampwidth(data.format.bytes) - self.fd.setframerate(data.sample_rate) - self.fd.writeframes(data.to_ndarray().tobytes()) + self.out_container = av.open(self.path.as_posix(), "w", format="wav") + self.out_stream = self.out_container.add_stream( + "pcm_s16le", rate=data.sample_rate + ) + for packet in self.out_stream.encode(data): + self.out_container.mux(packet) await self.emit(data) async def _flush(self): - if self.fd: - self.fd.close() - self.fd = None + if self.out_container: + for packet in self.out_stream.encode(None): + self.out_container.mux(packet) + self.out_container.close() + self.out_container = None + self.out_stream = None diff --git a/server/reflector/processors/audio_merge.py b/server/reflector/processors/audio_merge.py index 37734a53..34c1741e 100644 --- a/server/reflector/processors/audio_merge.py +++ b/server/reflector/processors/audio_merge.py @@ -3,7 +3,6 @@ from reflector.processors.types import AudioFile from time import monotonic_ns from uuid import uuid4 import io -import wave import av @@ -28,12 +27,16 @@ class AudioMergeProcessor(Processor): # create audio file uu = uuid4().hex fd = io.BytesIO() - with wave.open(fd, "wb") as wf: - wf.setnchannels(channels) - wf.setsampwidth(sample_width) - wf.setframerate(sample_rate) - for frame in data: - wf.writeframes(frame.to_ndarray().tobytes()) + + out_container = av.open(fd, "w", format="wav") + out_stream = out_container.add_stream("pcm_s16le", rate=sample_rate) + for frame in data: + for packet in out_stream.encode(frame): + out_container.mux(packet) + for packet in out_stream.encode(None): + out_container.mux(packet) + out_container.close() + fd.seek(0) # emit audio file audiofile = AudioFile( @@ -44,4 +47,5 @@ class AudioMergeProcessor(Processor): sample_width=sample_width, timestamp=data[0].pts * data[0].time_base, ) + await self.emit(audiofile)