Merge branch 'main' of github.com:Monadical-SAS/reflector into feat-sharing

This commit is contained in:
Sara
2023-11-21 12:11:58 +01:00
47 changed files with 1163 additions and 614 deletions

View File

@@ -3,7 +3,7 @@ name: Deploy to Amazon ECS
on: [workflow_dispatch] on: [workflow_dispatch]
env: env:
# 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector # 950402358378.dkr.ecr.us-east-1.amazonaws.com/reflector
AWS_REGION: us-east-1 AWS_REGION: us-east-1
ECR_REPOSITORY: reflector ECR_REPOSITORY: reflector

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
server/.env server/.env
.env .env
server/exportdanswer server/exportdanswer
.vercel
.env*.local

View File

@@ -51,17 +51,6 @@
#TRANSLATE_URL=https://xxxxx--reflector-translator-web.modal.run #TRANSLATE_URL=https://xxxxx--reflector-translator-web.modal.run
#TRANSCRIPT_MODAL_API_KEY=xxxxx #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 ## LLM backend
## ##
@@ -78,13 +67,6 @@
#LLM_URL=https://xxxxxx--reflector-llm-web.modal.run #LLM_URL=https://xxxxxx--reflector-llm-web.modal.run
#LLM_MODAL_API_KEY=xxx #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 ## Using OpenAI
#LLM_BACKEND=openai #LLM_BACKEND=openai
#LLM_OPENAI_KEY=xxx #LLM_OPENAI_KEY=xxx

View File

@@ -81,7 +81,8 @@ class LLM:
LLM_MODEL, LLM_MODEL,
torch_dtype=getattr(torch, LLM_TORCH_DTYPE), torch_dtype=getattr(torch, LLM_TORCH_DTYPE),
low_cpu_mem_usage=LLM_LOW_CPU_MEM_USAGE, low_cpu_mem_usage=LLM_LOW_CPU_MEM_USAGE,
cache_dir=IMAGE_MODEL_DIR cache_dir=IMAGE_MODEL_DIR,
local_files_only=True
) )
# JSONFormer doesn't yet support generation configs # JSONFormer doesn't yet support generation configs
@@ -96,7 +97,8 @@ class LLM:
print("Instance llm tokenizer") print("Instance llm tokenizer")
tokenizer = AutoTokenizer.from_pretrained( tokenizer = AutoTokenizer.from_pretrained(
LLM_MODEL, LLM_MODEL,
cache_dir=IMAGE_MODEL_DIR cache_dir=IMAGE_MODEL_DIR,
local_files_only=True
) )
# move model to gpu # move model to gpu

View File

@@ -17,7 +17,7 @@ LLM_LOW_CPU_MEM_USAGE: bool = True
LLM_TORCH_DTYPE: str = "bfloat16" LLM_TORCH_DTYPE: str = "bfloat16"
LLM_MAX_NEW_TOKENS: int = 300 LLM_MAX_NEW_TOKENS: int = 300
IMAGE_MODEL_DIR = "/root/llm_models" IMAGE_MODEL_DIR = "/root/llm_models/zephyr"
stub = Stub(name="reflector-llm-zephyr") stub = Stub(name="reflector-llm-zephyr")
@@ -81,7 +81,8 @@ class LLM:
LLM_MODEL, LLM_MODEL,
torch_dtype=getattr(torch, LLM_TORCH_DTYPE), torch_dtype=getattr(torch, LLM_TORCH_DTYPE),
low_cpu_mem_usage=LLM_LOW_CPU_MEM_USAGE, low_cpu_mem_usage=LLM_LOW_CPU_MEM_USAGE,
cache_dir=IMAGE_MODEL_DIR cache_dir=IMAGE_MODEL_DIR,
local_files_only=True
) )
# JSONFormer doesn't yet support generation configs # JSONFormer doesn't yet support generation configs
@@ -96,7 +97,8 @@ class LLM:
print("Instance llm tokenizer") print("Instance llm tokenizer")
tokenizer = AutoTokenizer.from_pretrained( tokenizer = AutoTokenizer.from_pretrained(
LLM_MODEL, LLM_MODEL,
cache_dir=IMAGE_MODEL_DIR cache_dir=IMAGE_MODEL_DIR,
local_files_only=True
) )
gen_cfg.pad_token_id = tokenizer.eos_token_id gen_cfg.pad_token_id = tokenizer.eos_token_id
gen_cfg.eos_token_id = tokenizer.eos_token_id gen_cfg.eos_token_id = tokenizer.eos_token_id

View File

@@ -95,7 +95,8 @@ class Transcriber:
device=self.device, device=self.device,
compute_type=WHISPER_COMPUTE_TYPE, compute_type=WHISPER_COMPUTE_TYPE,
num_workers=WHISPER_NUM_WORKERS, num_workers=WHISPER_NUM_WORKERS,
download_root=WHISPER_MODEL_DIR download_root=WHISPER_MODEL_DIR,
local_files_only=True
) )
@method() @method()

View File

@@ -0,0 +1,64 @@
"""fix duration
Revision ID: 4814901632bc
Revises: 38a927dcb099
Create Date: 2023-11-10 18:12:17.886522
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
from sqlalchemy import select
# revision identifiers, used by Alembic.
revision: str = "4814901632bc"
down_revision: Union[str, None] = "38a927dcb099"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# for all the transcripts, calculate the duration from the mp3
# and update the duration column
from pathlib import Path
from reflector.settings import settings
import av
bind = op.get_bind()
transcript = table(
"transcript", column("id", sa.String), column("duration", sa.Float)
)
# select only the one with duration = 0
results = bind.execute(
select([transcript.c.id, transcript.c.duration]).where(
transcript.c.duration == 0
)
)
data_dir = Path(settings.DATA_DIR)
for row in results:
audio_path = data_dir / row["id"] / "audio.mp3"
if not audio_path.exists():
continue
try:
print(f"Processing {audio_path}")
container = av.open(audio_path.as_posix())
print(container.duration)
duration = round(float(container.duration / av.time_base), 2)
print(f"Duration: {duration}")
bind.execute(
transcript.update()
.where(transcript.c.id == row["id"])
.values(duration=duration)
)
except Exception as e:
print(f"Failed to process {audio_path}: {e}")
def downgrade() -> None:
pass

78
server/poetry.lock generated
View File

@@ -2557,6 +2557,82 @@ typing-extensions = "*"
[package.extras] [package.extras]
dev = ["black", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "pytest", "pytest-asyncio", "pytest-trio", "toml", "tox", "trio", "trio", "trio-typing", "twine", "twisted", "validate-pyproject[all]"] dev = ["black", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "pytest", "pytest-asyncio", "pytest-trio", "toml", "tox", "trio", "trio", "trio-typing", "twine", "twisted", "validate-pyproject[all]"]
[[package]]
name = "pyinstrument"
version = "4.6.1"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73476e4bc6e467ac1b2c3c0dd1f0b71c9061d4de14626676adfdfbb14aa342b4"},
{file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4d1da8efd974cf9df52ee03edaee2d3875105ddd00de35aa542760f7c612bdf7"},
{file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507be1ee2f2b0c9fba74d622a272640dd6d1b0c9ec3388b2cdeb97ad1e77125f"},
{file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cee6de08eb45754ef4f602ce52b640d1c535d934a6a8733a974daa095def37"},
{file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7873e8cec92321251fdf894a72b3c78f4c5c20afdd1fef0baf9042ec843bb04"},
{file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a242f6cac40bc83e1f3002b6b53681846dfba007f366971db0bf21e02dbb1903"},
{file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:97c9660cdb4bd2a43cf4f3ab52cffd22f3ac9a748d913b750178fb34e5e39e64"},
{file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e304cd0723e2b18ada5e63c187abf6d777949454c734f5974d64a0865859f0f4"},
{file = "pyinstrument-4.6.1-cp310-cp310-win32.whl", hash = "sha256:cee21a2d78187dd8a80f72f5d0f1ddb767b2d9800f8bb4d94b6d11f217c22cdb"},
{file = "pyinstrument-4.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:2000712f71d693fed2f8a1c1638d37b7919124f367b37976d07128d49f1445eb"},
{file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a366c6f3dfb11f1739bdc1dee75a01c1563ad0bf4047071e5e77598087df457f"},
{file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6be327be65d934796558aa9cb0f75ce62ebd207d49ad1854610c97b0579ad47"},
{file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e160d9c5d20d3e4ef82269e4e8b246ff09bdf37af5fb8cb8ccca97936d95ad6"},
{file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffbf56605ef21c2fcb60de2fa74ff81f417d8be0c5002a407e414d6ef6dee43"},
{file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c92cc4924596d6e8f30a16182bbe90893b1572d847ae12652f72b34a9a17c24a"},
{file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f4b48a94d938cae981f6948d9ec603bab2087b178d2095d042d5a48aabaecaab"},
{file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7a386392275bdef4a1849712dc5b74f0023483fca14ef93d0ca27d453548982"},
{file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:871b131b83e9b1122f2325061c68ed1e861eebcb568c934d2fb193652f077f77"},
{file = "pyinstrument-4.6.1-cp311-cp311-win32.whl", hash = "sha256:8d8515156dd91f5652d13b5fcc87e634f8fe1c07b68d1d0840348cdd50bf5ace"},
{file = "pyinstrument-4.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb868fbe089036e9f32525a249f4c78b8dc46967612393f204b8234f439c9cc4"},
{file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a18cd234cce4f230f1733807f17a134e64a1f1acabf74a14d27f583cf2b183df"},
{file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:574cfca69150be4ce4461fb224712fbc0722a49b0dc02fa204d02807adf6b5a0"},
{file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e02cf505e932eb8ccf561b7527550a67ec14fcae1fe0e25319b09c9c166e914"},
{file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832fb2acef9d53701c1ab546564c45fb70a8770c816374f8dd11420d399103c9"},
{file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13cb57e9607545623ebe462345b3d0c4caee0125d2d02267043ece8aca8f4ea0"},
{file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9be89e7419bcfe8dd6abb0d959d6d9c439c613a4a873514c43d16b48dae697c9"},
{file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:476785cfbc44e8e1b1ad447398aa3deae81a8df4d37eb2d8bbb0c404eff979cd"},
{file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e9cebd90128a3d2fee36d3ccb665c1b9dce75261061b2046203e45c4a8012d54"},
{file = "pyinstrument-4.6.1-cp312-cp312-win32.whl", hash = "sha256:1d0b76683df2ad5c40eff73607dc5c13828c92fbca36aff1ddf869a3c5a55fa6"},
{file = "pyinstrument-4.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:c4b7af1d9d6a523cfbfedebcb69202242d5bd0cb89c4e094cc73d5d6e38279bd"},
{file = "pyinstrument-4.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79ae152f8c6a680a188fb3be5e0f360ac05db5bbf410169a6c40851dfaebcce9"},
{file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07cad2745964c174c65aa75f1bf68a4394d1b4d28f33894837cfd315d1e836f0"},
{file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb81f66f7f94045d723069cf317453d42375de9ff3c69089cf6466b078ac1db4"},
{file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab30ae75969da99e9a529e21ff497c18fdf958e822753db4ae7ed1e67094040"},
{file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f36cb5b644762fb3c86289324bbef17e95f91cd710603ac19444a47f638e8e96"},
{file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8b45075d9dbbc977dbc7007fb22bb0054c6990fbe91bf48dd80c0b96c6307ba7"},
{file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:475ac31477f6302e092463896d6a2055f3e6abcd293bad16ff94fc9185308a88"},
{file = "pyinstrument-4.6.1-cp37-cp37m-win32.whl", hash = "sha256:29172ab3d8609fdf821c3f2562dc61e14f1a8ff5306607c32ca743582d3a760e"},
{file = "pyinstrument-4.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:bd176f297c99035127b264369d2bb97a65255f65f8d4e843836baf55ebb3cee4"},
{file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:23e9b4526978432e9999021da9a545992cf2ac3df5ee82db7beb6908fc4c978c"},
{file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2dbcaccc9f456ef95557ec501caeb292119c24446d768cb4fb43578b0f3d572c"},
{file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2097f63c66c2bc9678c826b9ff0c25acde3ed455590d9dcac21220673fe74fbf"},
{file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:205ac2e76bd65d61b9611a9ce03d5f6393e34ec5b41dd38808f25d54e6b3e067"},
{file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f414ddf1161976a40fc0a333000e6a4ad612719eac0b8c9bb73f47153187148"},
{file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65e62ebfa2cd8fb57eda90006f4505ac4c70da00fc2f05b6d8337d776ea76d41"},
{file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d96309df4df10be7b4885797c5f69bb3a89414680ebaec0722d8156fde5268c3"},
{file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f3d1ad3bc8ebb4db925afa706aa865c4bfb40d52509f143491ac0df2440ee5d2"},
{file = "pyinstrument-4.6.1-cp38-cp38-win32.whl", hash = "sha256:dc37cb988c8854eb42bda2e438aaf553536566657d157c4473cc8aad5692a779"},
{file = "pyinstrument-4.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:2cd4ce750c34a0318fc2d6c727cc255e9658d12a5cf3f2d0473f1c27157bdaeb"},
{file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ca95b21f022e995e062b371d1f42d901452bcbedd2c02f036de677119503355"},
{file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ac1e1d7e1f1b64054c4eb04eb4869a7a5eef2261440e73943cc1b1bc3c828c18"},
{file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0711845e953fce6ab781221aacffa2a66dbc3289f8343e5babd7b2ea34da6c90"},
{file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7d28582017de35cb64eb4e4fa603e753095108ca03745f5d17295970ee631f"},
{file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be57db08bd366a37db3aa3a6187941ee21196e8b14975db337ddc7d1490649d"},
{file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9a0ac0f56860398d2628ce389826ce83fb3a557d0c9a2351e8a2eac6eb869983"},
{file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a9045186ff13bc826fef16be53736a85029aae3c6adfe52e666cad00d7ca623b"},
{file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6c4c56b6eab9004e92ad8a48bb54913fdd71fc8a748ae42a27b9e26041646f8b"},
{file = "pyinstrument-4.6.1-cp39-cp39-win32.whl", hash = "sha256:37e989c44b51839d0c97466fa2b623638b9470d56d79e329f359f0e8fa6d83db"},
{file = "pyinstrument-4.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:5494c5a84fee4309d7d973366ca6b8b9f8ba1d6b254e93b7c506264ef74f2cef"},
{file = "pyinstrument-4.6.1.tar.gz", hash = "sha256:f4731b27121350f5a983d358d2272fe3df2f538aed058f57217eef7801a89288"},
]
[package.extras]
bin = ["click", "nox"]
docs = ["furo (==2021.6.18b36)", "myst-parser (==0.15.1)", "sphinx (==4.2.0)", "sphinxcontrib-programoutput (==0.17)"]
examples = ["django", "numpy"]
test = ["flaky", "greenlet (>=3.0.0a1)", "ipython", "pytest", "pytest-asyncio (==0.12.0)", "sphinx-autobuild (==2021.3.14)", "trio"]
types = ["typing-extensions"]
[[package]] [[package]]
name = "pylibsrtp" name = "pylibsrtp"
version = "0.8.0" version = "0.8.0"
@@ -4143,4 +4219,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "cfefbd402bde7585caa42c1a889be0496d956e285bb05db9e1e7ae5e485e91fe" content-hash = "91d85539f5093abad70e34aa4d533272d6a2e2bbdb539c7968fe79c28b50d01a"

View File

@@ -41,6 +41,7 @@ python-jose = {extras = ["cryptography"], version = "^3.3.0"}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^23.7.0" black = "^23.7.0"
stamina = "^23.1.0" stamina = "^23.1.0"
pyinstrument = "^4.6.1"
[tool.poetry.group.tests.dependencies] [tool.poetry.group.tests.dependencies]

View File

@@ -41,7 +41,6 @@ if settings.SENTRY_DSN:
else: else:
logger.info("Sentry disabled") logger.info("Sentry disabled")
# build app # build app
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.add_middleware( app.add_middleware(
@@ -102,6 +101,23 @@ def use_route_names_as_operation_ids(app: FastAPI) -> None:
use_route_names_as_operation_ids(app) use_route_names_as_operation_ids(app)
if settings.PROFILING:
from fastapi import Request
from fastapi.responses import HTMLResponse
from pyinstrument import Profiler
@app.middleware("http")
async def profile_request(request: Request, call_next):
profiling = request.query_params.get("profile", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
else:
return await call_next(request)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -11,7 +11,6 @@ from pydantic import BaseModel, Field
from reflector.db import database, metadata from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings from reflector.settings import settings
from reflector.utils.audio_waveform import get_audio_waveform
transcripts = sqlalchemy.Table( transcripts = sqlalchemy.Table(
"transcript", "transcript",
@@ -86,6 +85,14 @@ class TranscriptFinalTitle(BaseModel):
title: str title: str
class TranscriptDuration(BaseModel):
duration: float
class TranscriptWaveform(BaseModel):
waveform: list[float]
class TranscriptEvent(BaseModel): class TranscriptEvent(BaseModel):
event: str event: str
data: dict data: dict
@@ -126,22 +133,6 @@ class Transcript(BaseModel):
def topics_dump(self, mode="json"): def topics_dump(self, mode="json"):
return [topic.model_dump(mode=mode) for topic in self.topics] return [topic.model_dump(mode=mode) for topic in self.topics]
def convert_audio_to_waveform(self, segments_count=256):
fn = self.audio_waveform_filename
if fn.exists():
return
waveform = get_audio_waveform(
path=self.audio_mp3_filename, segments_count=segments_count
)
try:
with open(fn, "w") as fd:
json.dump(waveform, fd)
except Exception:
# remove file if anything happen during the write
fn.unlink(missing_ok=True)
raise
return waveform
def unlink(self): def unlink(self):
self.data_path.unlink(missing_ok=True) self.data_path.unlink(missing_ok=True)

View File

@@ -1,54 +0,0 @@
import httpx
from reflector.llm.base import LLM
from reflector.settings import settings
from reflector.utils.retry import retry
class BananaLLM(LLM):
def __init__(self):
super().__init__()
self.timeout = settings.LLM_TIMEOUT
self.headers = {
"X-Banana-API-Key": settings.LLM_BANANA_API_KEY,
"X-Banana-Model-Key": settings.LLM_BANANA_MODEL_KEY,
}
async def _generate(
self, prompt: str, gen_schema: dict | None, gen_cfg: dict | None, **kwargs
):
json_payload = {"prompt": prompt}
if gen_schema:
json_payload["gen_schema"] = gen_schema
if gen_cfg:
json_payload["gen_cfg"] = gen_cfg
async with httpx.AsyncClient() as client:
response = await retry(client.post)(
settings.LLM_URL,
headers=self.headers,
json=json_payload,
timeout=self.timeout,
retry_timeout=300, # as per their sdk
)
response.raise_for_status()
text = response.json()["text"]
return text
LLM.register("banana", BananaLLM)
if __name__ == "__main__":
from reflector.logger import logger
async def main():
llm = BananaLLM()
prompt = llm.create_prompt(
instruct="Complete the following task",
text="Tell me a joke about programming.",
)
result = await llm.generate(prompt=prompt, logger=logger)
print(result)
import asyncio
asyncio.run(main())

View File

@@ -21,11 +21,13 @@ from pydantic import BaseModel
from reflector.app import app from reflector.app import app
from reflector.db.transcripts import ( from reflector.db.transcripts import (
Transcript, Transcript,
TranscriptDuration,
TranscriptFinalLongSummary, TranscriptFinalLongSummary,
TranscriptFinalShortSummary, TranscriptFinalShortSummary,
TranscriptFinalTitle, TranscriptFinalTitle,
TranscriptText, TranscriptText,
TranscriptTopic, TranscriptTopic,
TranscriptWaveform,
transcripts_controller, transcripts_controller,
) )
from reflector.logger import logger from reflector.logger import logger
@@ -45,6 +47,7 @@ from reflector.processors import (
TranscriptTopicDetectorProcessor, TranscriptTopicDetectorProcessor,
TranscriptTranslatorProcessor, TranscriptTranslatorProcessor,
) )
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.processors.types import AudioDiarizationInput from reflector.processors.types import AudioDiarizationInput
from reflector.processors.types import ( from reflector.processors.types import (
TitleSummaryWithId as TitleSummaryWithIdProcessorType, TitleSummaryWithId as TitleSummaryWithIdProcessorType,
@@ -230,6 +233,33 @@ class PipelineMainBase(PipelineRunner):
data=final_short_summary, data=final_short_summary,
) )
@broadcast_to_sockets
async def on_duration(self, data):
async with self.transaction():
duration = TranscriptDuration(duration=data)
transcript = await self.get_transcript()
await transcripts_controller.update(
transcript,
{
"duration": duration.duration,
},
)
return await transcripts_controller.append_event(
transcript=transcript, event="DURATION", data=duration
)
@broadcast_to_sockets
async def on_waveform(self, data):
async with self.transaction():
waveform = TranscriptWaveform(waveform=data)
transcript = await self.get_transcript()
return await transcripts_controller.append_event(
transcript=transcript, event="WAVEFORM", data=waveform
)
class PipelineMainLive(PipelineMainBase): class PipelineMainLive(PipelineMainBase):
audio_filename: Path | None = None audio_filename: Path | None = None
@@ -243,7 +273,10 @@ class PipelineMainLive(PipelineMainBase):
transcript = await self.get_transcript() transcript = await self.get_transcript()
processors = [ processors = [
AudioFileWriterProcessor(path=transcript.audio_mp3_filename), AudioFileWriterProcessor(
path=transcript.audio_mp3_filename,
on_duration=self.on_duration,
),
AudioChunkerProcessor(), AudioChunkerProcessor(),
AudioMergeProcessor(), AudioMergeProcessor(),
AudioTranscriptAutoProcessor.as_threaded(), AudioTranscriptAutoProcessor.as_threaded(),
@@ -253,6 +286,11 @@ class PipelineMainLive(PipelineMainBase):
BroadcastProcessor( BroadcastProcessor(
processors=[ processors=[
TranscriptFinalTitleProcessor.as_threaded(callback=self.on_title), TranscriptFinalTitleProcessor.as_threaded(callback=self.on_title),
AudioWaveformProcessor.as_threaded(
audio_path=transcript.audio_mp3_filename,
waveform_path=transcript.audio_waveform_filename,
on_waveform=self.on_waveform,
),
] ]
), ),
] ]
@@ -285,8 +323,13 @@ class PipelineMainDiarization(PipelineMainBase):
# create a context for the whole rtc transaction # create a context for the whole rtc transaction
# add a customised logger to the context # add a customised logger to the context
self.prepare() self.prepare()
processors = [ processors = []
AudioDiarizationAutoProcessor(callback=self.on_topic), if settings.DIARIZATION_ENABLED:
processors += [
AudioDiarizationAutoProcessor(callback=self.on_topic),
]
processors += [
BroadcastProcessor( BroadcastProcessor(
processors=[ processors=[
TranscriptFinalLongSummaryProcessor.as_threaded( TranscriptFinalLongSummaryProcessor.as_threaded(

View File

@@ -12,8 +12,8 @@ class AudioFileWriterProcessor(Processor):
INPUT_TYPE = av.AudioFrame INPUT_TYPE = av.AudioFrame
OUTPUT_TYPE = av.AudioFrame OUTPUT_TYPE = av.AudioFrame
def __init__(self, path: Path | str): def __init__(self, path: Path | str, **kwargs):
super().__init__() super().__init__(**kwargs)
if isinstance(path, str): if isinstance(path, str):
path = Path(path) path = Path(path)
if path.suffix not in (".mp3", ".wav"): if path.suffix not in (".mp3", ".wav"):
@@ -21,6 +21,7 @@ class AudioFileWriterProcessor(Processor):
self.path = path self.path = path
self.out_container = None self.out_container = None
self.out_stream = None self.out_stream = None
self.last_packet = None
async def _push(self, data: av.AudioFrame): async def _push(self, data: av.AudioFrame):
if not self.out_container: if not self.out_container:
@@ -40,12 +41,30 @@ class AudioFileWriterProcessor(Processor):
raise ValueError("Only mp3 and wav files are supported") raise ValueError("Only mp3 and wav files are supported")
for packet in self.out_stream.encode(data): for packet in self.out_stream.encode(data):
self.out_container.mux(packet) self.out_container.mux(packet)
self.last_packet = packet
await self.emit(data) await self.emit(data)
async def _flush(self): async def _flush(self):
if self.out_container: if self.out_container:
for packet in self.out_stream.encode(): for packet in self.out_stream.encode():
self.out_container.mux(packet) self.out_container.mux(packet)
self.last_packet = packet
try:
if self.last_packet is not None:
duration = round(
float(
(self.last_packet.pts * self.last_packet.duration)
* self.last_packet.time_base
),
2,
)
except Exception:
self.logger.exception("Failed to get duration")
duration = 0
self.out_container.close() self.out_container.close()
self.out_container = None self.out_container = None
self.out_stream = None self.out_stream = None
if duration > 0:
await self.emit(duration, name="duration")

View File

@@ -1,86 +0,0 @@
"""
Implementation using the GPU service from banana.
API will be a POST request to TRANSCRIPT_URL:
```json
{
"audio_url": "https://...",
"audio_ext": "wav",
"timestamp": 123.456
"language": "en"
}
```
"""
from pathlib import Path
import httpx
from reflector.processors.audio_transcript import AudioTranscriptProcessor
from reflector.processors.audio_transcript_auto import AudioTranscriptAutoProcessor
from reflector.processors.types import AudioFile, Transcript, Word
from reflector.settings import settings
from reflector.storage import Storage
from reflector.utils.retry import retry
class AudioTranscriptBananaProcessor(AudioTranscriptProcessor):
def __init__(self, banana_api_key: str, banana_model_key: str):
super().__init__()
self.transcript_url = settings.TRANSCRIPT_URL
self.timeout = settings.TRANSCRIPT_TIMEOUT
self.storage = Storage.get_instance(
settings.TRANSCRIPT_STORAGE_BACKEND, "TRANSCRIPT_STORAGE_"
)
self.headers = {
"X-Banana-API-Key": banana_api_key,
"X-Banana-Model-Key": banana_model_key,
}
async def _transcript(self, data: AudioFile):
async with httpx.AsyncClient() as client:
print(f"Uploading audio {data.path.name} to S3")
url = await self._upload_file(data.path)
print(f"Try to transcribe audio {data.path.name}")
request_data = {
"audio_url": url,
"audio_ext": data.path.suffix[1:],
"timestamp": float(round(data.timestamp, 2)),
}
response = await retry(client.post)(
self.transcript_url,
json=request_data,
headers=self.headers,
timeout=self.timeout,
)
print(f"Transcript response: {response.status_code} {response.content}")
response.raise_for_status()
result = response.json()
transcript = Transcript(
text=result["text"],
words=[
Word(text=word["text"], start=word["start"], end=word["end"])
for word in result["words"]
],
)
# remove audio file from S3
await self._delete_file(data.path)
return transcript
@retry
async def _upload_file(self, path: Path) -> str:
upload_result = await self.storage.put_file(path.name, open(path, "rb"))
return upload_result.url
@retry
async def _delete_file(self, path: Path):
await self.storage.delete_file(path.name)
return True
AudioTranscriptAutoProcessor.register("banana", AudioTranscriptBananaProcessor)

View File

@@ -0,0 +1,36 @@
import json
from pathlib import Path
from reflector.processors.base import Processor
from reflector.processors.types import TitleSummary
from reflector.utils.audio_waveform import get_audio_waveform
class AudioWaveformProcessor(Processor):
"""
Write the waveform for the final audio
"""
INPUT_TYPE = TitleSummary
def __init__(self, audio_path: Path | str, waveform_path: str, **kwargs):
super().__init__(**kwargs)
if isinstance(audio_path, str):
audio_path = Path(audio_path)
if audio_path.suffix not in (".mp3", ".wav"):
raise ValueError("Only mp3 and wav files are supported")
self.audio_path = audio_path
self.waveform_path = waveform_path
async def _flush(self):
self.waveform_path.parent.mkdir(parents=True, exist_ok=True)
self.logger.info("Waveform Processing Started")
waveform = get_audio_waveform(path=self.audio_path, segments_count=255)
with open(self.waveform_path, "w") as fd:
json.dump(waveform, fd)
self.logger.info("Waveform Processing Finished")
await self.emit(waveform, name="waveform")
async def _push(_self, _data):
return

View File

@@ -14,7 +14,42 @@ class PipelineEvent(BaseModel):
data: Any data: Any
class Processor: class Emitter:
def __init__(self, **kwargs):
self._callbacks = {}
# register callbacks from kwargs (on_*)
for key, value in kwargs.items():
if key.startswith("on_"):
self.on(value, name=key[3:])
def on(self, callback, name="default"):
"""
Register a callback to be called when data is emitted
"""
# ensure callback is asynchronous
if not asyncio.iscoroutinefunction(callback):
raise ValueError("Callback must be a coroutine function")
if name not in self._callbacks:
self._callbacks[name] = []
self._callbacks[name].append(callback)
def off(self, callback, name="default"):
"""
Unregister a callback to be called when data is emitted
"""
if name not in self._callbacks:
return
self._callbacks[name].remove(callback)
async def emit(self, data, name="default"):
if name not in self._callbacks:
return
for callback in self._callbacks[name]:
await callback(data)
class Processor(Emitter):
INPUT_TYPE: type = None INPUT_TYPE: type = None
OUTPUT_TYPE: type = None OUTPUT_TYPE: type = None
@@ -59,7 +94,8 @@ class Processor:
["processor"], ["processor"],
) )
def __init__(self, callback=None, custom_logger=None): def __init__(self, callback=None, custom_logger=None, **kwargs):
super().__init__(**kwargs)
self.name = name = self.__class__.__name__ self.name = name = self.__class__.__name__
self.m_processor = self.m_processor.labels(name) self.m_processor = self.m_processor.labels(name)
self.m_processor_call = self.m_processor_call.labels(name) self.m_processor_call = self.m_processor_call.labels(name)
@@ -70,9 +106,11 @@ class Processor:
self.m_processor_flush_success = self.m_processor_flush_success.labels(name) self.m_processor_flush_success = self.m_processor_flush_success.labels(name)
self.m_processor_flush_failure = self.m_processor_flush_failure.labels(name) self.m_processor_flush_failure = self.m_processor_flush_failure.labels(name)
self._processors = [] self._processors = []
self._callbacks = []
# register callbacks
if callback: if callback:
self.on(callback) self.on(callback)
self.uid = uuid4().hex self.uid = uuid4().hex
self.flushed = False self.flushed = False
self.logger = (custom_logger or logger).bind(processor=self.__class__.__name__) self.logger = (custom_logger or logger).bind(processor=self.__class__.__name__)
@@ -100,21 +138,6 @@ class Processor:
""" """
self._processors.remove(processor) self._processors.remove(processor)
def on(self, callback):
"""
Register a callback to be called when data is emitted
"""
# ensure callback is asynchronous
if not asyncio.iscoroutinefunction(callback):
raise ValueError("Callback must be a coroutine function")
self._callbacks.append(callback)
def off(self, callback):
"""
Unregister a callback to be called when data is emitted
"""
self._callbacks.remove(callback)
def get_pref(self, key: str, default: Any = None): def get_pref(self, key: str, default: Any = None):
""" """
Get a preference from the pipeline prefs Get a preference from the pipeline prefs
@@ -123,15 +146,16 @@ class Processor:
return self.pipeline.get_pref(key, default) return self.pipeline.get_pref(key, default)
return default return default
async def emit(self, data): async def emit(self, data, name="default"):
if self.pipeline: if name == "default":
await self.pipeline.emit( if self.pipeline:
PipelineEvent(processor=self.name, uid=self.uid, data=data) await self.pipeline.emit(
) PipelineEvent(processor=self.name, uid=self.uid, data=data)
for callback in self._callbacks: )
await callback(data) await super().emit(data, name=name)
for processor in self._processors: if name == "default":
await processor.push(data) for processor in self._processors:
await processor.push(data)
async def push(self, data): async def push(self, data):
""" """
@@ -254,11 +278,11 @@ class ThreadedProcessor(Processor):
def disconnect(self, processor: Processor): def disconnect(self, processor: Processor):
self.processor.disconnect(processor) self.processor.disconnect(processor)
def on(self, callback): def on(self, callback, name="default"):
self.processor.on(callback) self.processor.on(callback, name=name)
def off(self, callback): def off(self, callback, name="default"):
self.processor.off(callback) self.processor.off(callback, name=name)
def describe(self, level=0): def describe(self, level=0):
super().describe(level) super().describe(level)
@@ -305,13 +329,13 @@ class BroadcastProcessor(Processor):
for processor in self.processors: for processor in self.processors:
processor.disconnect(processor) processor.disconnect(processor)
def on(self, callback): def on(self, callback, name="default"):
for processor in self.processors: for processor in self.processors:
processor.on(callback) processor.on(callback, name=name)
def off(self, callback): def off(self, callback, name="default"):
for processor in self.processors: for processor in self.processors:
processor.off(callback) processor.off(callback, name=name)
def describe(self, level=0): def describe(self, level=0):
super().describe(level) super().describe(level)

View File

@@ -16,6 +16,7 @@ class TranscriptTranslatorProcessor(Processor):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.transcript = None
self.translate_url = settings.TRANSLATE_URL self.translate_url = settings.TRANSLATE_URL
self.timeout = settings.TRANSLATE_TIMEOUT self.timeout = settings.TRANSLATE_TIMEOUT
self.headers = {"Authorization": f"Bearer {settings.LLM_MODAL_API_KEY}"} self.headers = {"Authorization": f"Bearer {settings.LLM_MODAL_API_KEY}"}

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from profanityfilter import ProfanityFilter from profanityfilter import ProfanityFilter
from pydantic import BaseModel, PrivateAttr from pydantic import BaseModel, PrivateAttr
from reflector.redis_cache import redis_cache
PUNC_RE = re.compile(r"[.;:?!…]") PUNC_RE = re.compile(r"[.;:?!…]")
@@ -68,10 +69,14 @@ class Transcript(BaseModel):
# Uncensored text # Uncensored text
return "".join([word.text for word in self.words]) return "".join([word.text for word in self.words])
@redis_cache(prefix="profanity", duration=3600 * 24 * 7)
def _get_censored_text(self, text: str):
return profanity_filter.censor(text).strip()
@property @property
def text(self): def text(self):
# Censored text # Censored text
return profanity_filter.censor(self.raw_text).strip() return self._get_censored_text(self.raw_text)
@property @property
def human_timestamp(self): def human_timestamp(self):

View File

@@ -0,0 +1,50 @@
import functools
import json
import redis
from reflector.settings import settings
redis_clients = {}
def get_redis_client(db=0):
"""
Get a Redis client for the specified database.
"""
if db not in redis_clients:
redis_clients[db] = redis.StrictRedis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=db,
)
return redis_clients[db]
def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argidx=1):
"""
Cache the result of a function in Redis.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Check if the first argument is a string
if len(args) < (argidx + 1) or not isinstance(args[argidx], str):
return func(*args, **kwargs)
# Compute the cache key based on the arguments and prefix
cache_key = prefix + ":" + args[argidx]
redis_client = get_redis_client(db=db)
cached_result = redis_client.get(cache_key)
if cached_result:
return json.loads(cached_result.decode("utf-8"))
# If the result is not cached, call the original function
result = func(*args, **kwargs)
redis_client.setex(cache_key, duration, json.dumps(result))
return result
return wrapper
return decorator

View File

@@ -41,7 +41,7 @@ class Settings(BaseSettings):
AUDIO_BUFFER_SIZE: int = 256 * 960 AUDIO_BUFFER_SIZE: int = 256 * 960
# Audio Transcription # Audio Transcription
# backends: whisper, banana, modal # backends: whisper, modal
TRANSCRIPT_BACKEND: str = "whisper" TRANSCRIPT_BACKEND: str = "whisper"
TRANSCRIPT_URL: str | None = None TRANSCRIPT_URL: str | None = None
TRANSCRIPT_TIMEOUT: int = 90 TRANSCRIPT_TIMEOUT: int = 90
@@ -50,10 +50,6 @@ class Settings(BaseSettings):
TRANSLATE_URL: str | None = None TRANSLATE_URL: str | None = None
TRANSLATE_TIMEOUT: int = 90 TRANSLATE_TIMEOUT: int = 90
# Audio transcription banana.dev configuration
TRANSCRIPT_BANANA_API_KEY: str | None = None
TRANSCRIPT_BANANA_MODEL_KEY: str | None = None
# Audio transcription modal.com configuration # Audio transcription modal.com configuration
TRANSCRIPT_MODAL_API_KEY: str | None = None TRANSCRIPT_MODAL_API_KEY: str | None = None
@@ -61,13 +57,16 @@ class Settings(BaseSettings):
TRANSCRIPT_STORAGE_BACKEND: str = "aws" TRANSCRIPT_STORAGE_BACKEND: str = "aws"
# Storage configuration for AWS # Storage configuration for AWS
TRANSCRIPT_STORAGE_AWS_BUCKET_NAME: str = "reflector-bucket/chunks" TRANSCRIPT_STORAGE_AWS_BUCKET_NAME: str = "reflector-bucket"
TRANSCRIPT_STORAGE_AWS_REGION: str = "us-east-1" TRANSCRIPT_STORAGE_AWS_REGION: str = "us-east-1"
TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
# Transcript MP3 storage
TRANSCRIPT_MP3_STORAGE_BACKEND: str = "aws"
# LLM # LLM
# available backend: openai, banana, modal, oobabooga # available backend: openai, modal, oobabooga
LLM_BACKEND: str = "oobabooga" LLM_BACKEND: str = "oobabooga"
# LLM common configuration # LLM common configuration
@@ -82,14 +81,11 @@ class Settings(BaseSettings):
LLM_TEMPERATURE: float = 0.7 LLM_TEMPERATURE: float = 0.7
ZEPHYR_LLM_URL: str | None = None ZEPHYR_LLM_URL: str | None = None
# LLM Banana configuration
LLM_BANANA_API_KEY: str | None = None
LLM_BANANA_MODEL_KEY: str | None = None
# LLM Modal configuration # LLM Modal configuration
LLM_MODAL_API_KEY: str | None = None LLM_MODAL_API_KEY: str | None = None
# Diarization # Diarization
DIARIZATION_ENABLED: bool = True
DIARIZATION_BACKEND: str = "modal" DIARIZATION_BACKEND: str = "modal"
DIARIZATION_URL: str | None = None DIARIZATION_URL: str | None = None
@@ -124,6 +120,7 @@ class Settings(BaseSettings):
# Redis # Redis
REDIS_HOST: str = "localhost" REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379 REDIS_PORT: int = 6379
REDIS_CACHE_DB: int = 2
# Secret key # Secret key
SECRET_KEY: str = "changeme-f02f86fd8b3e4fd892c6043e5a298e21" SECRET_KEY: str = "changeme-f02f86fd8b3e4fd892c6043e5a298e21"
@@ -131,5 +128,8 @@ class Settings(BaseSettings):
# Current hosting/domain # Current hosting/domain
BASE_URL: str = "http://localhost:1250" BASE_URL: str = "http://localhost:1250"
# Profiling
PROFILING: bool = False
settings = Settings() settings = Settings()

View File

@@ -1,7 +1,7 @@
import os import os
from typing import BinaryIO from typing import BinaryIO
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -57,6 +57,9 @@ def range_requests_response(
), ),
} }
if request.method == "HEAD":
return Response(headers=headers)
if content_disposition: if content_disposition:
headers["Content-Disposition"] = content_disposition headers["Content-Disposition"] = content_disposition

View File

@@ -23,7 +23,6 @@ from reflector.db.transcripts import (
from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.settings import settings from reflector.settings import settings
from reflector.ws_manager import get_ws_manager from reflector.ws_manager import get_ws_manager
from starlette.concurrency import run_in_threadpool
from ._range_requests_response import range_requests_response from ._range_requests_response import range_requests_response
from .rtc_offer import RtcOffer, rtc_offer_base from .rtc_offer import RtcOffer, rtc_offer_base
@@ -53,7 +52,7 @@ class GetTranscript(BaseModel):
name: str name: str
status: str status: str
locked: bool locked: bool
duration: int duration: float
title: str | None title: str | None
short_summary: str | None short_summary: str | None
long_summary: str | None long_summary: str | None
@@ -222,6 +221,7 @@ async def transcript_delete(
@router.get("/transcripts/{transcript_id}/audio/mp3") @router.get("/transcripts/{transcript_id}/audio/mp3")
@router.head("/transcripts/{transcript_id}/audio/mp3")
async def transcript_get_audio_mp3( async def transcript_get_audio_mp3(
request: Request, request: Request,
transcript_id: str, transcript_id: str,
@@ -272,8 +272,6 @@ async def transcript_get_audio_waveform(
if not transcript.audio_mp3_filename.exists(): if not transcript.audio_mp3_filename.exists():
raise HTTPException(status_code=500, detail="Audio not found") raise HTTPException(status_code=500, detail="Audio not found")
await run_in_threadpool(transcript.convert_audio_to_waveform)
return transcript.audio_waveform return transcript.audio_waveform

View File

@@ -46,6 +46,34 @@ async def test_transcript_audio_download(fake_transcript, url_suffix, content_ty
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["content-type"] == content_type assert response.headers["content-type"] == content_type
# test get 404
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.get(f"/transcripts/{fake_transcript.id}XXX/audio{url_suffix}")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url_suffix,content_type",
[
["/mp3", "audio/mpeg"],
],
)
async def test_transcript_audio_download_head(
fake_transcript, url_suffix, content_type
):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
assert response.headers["content-type"] == content_type
# test head 404
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.head(f"/transcripts/{fake_transcript.id}XXX/audio{url_suffix}")
assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -90,15 +118,3 @@ async def test_transcript_audio_download_range_with_seek(
assert response.status_code == 206 assert response.status_code == 206
assert response.headers["content-type"] == content_type assert response.headers["content-type"] == content_type
assert response.headers["content-range"].startswith("bytes 100-") assert response.headers["content-range"].startswith("bytes 100-")
@pytest.mark.asyncio
async def test_transcript_audio_download_waveform(fake_transcript):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.get(f"/transcripts/{fake_transcript.id}/audio/waveform")
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
assert isinstance(response.json()["data"], list)
assert len(response.json()["data"]) >= 255

View File

@@ -182,6 +182,16 @@ async def test_transcript_rtc_and_websocket(
ev = events[eventnames.index("FINAL_TITLE")] ev = events[eventnames.index("FINAL_TITLE")]
assert ev["data"]["title"] == "LLM TITLE" assert ev["data"]["title"] == "LLM TITLE"
assert "WAVEFORM" in eventnames
ev = events[eventnames.index("WAVEFORM")]
assert isinstance(ev["data"]["waveform"], list)
assert len(ev["data"]["waveform"]) >= 250
waveform_resp = await ac.get(f"/transcripts/{tid}/audio/waveform")
assert waveform_resp.status_code == 200
assert waveform_resp.headers["content-type"] == "application/json"
assert isinstance(waveform_resp.json()["data"], list)
assert len(waveform_resp.json()["data"]) >= 250
# check status order # check status order
statuses = [e["data"]["value"] for e in events if e["event"] == "STATUS"] statuses = [e["data"]["value"] for e in events if e["event"] == "STATUS"]
assert statuses.index("recording") < statuses.index("processing") assert statuses.index("recording") < statuses.index("processing")
@@ -191,10 +201,14 @@ async def test_transcript_rtc_and_websocket(
assert events[-1]["event"] == "STATUS" assert events[-1]["event"] == "STATUS"
assert events[-1]["data"]["value"] == "ended" assert events[-1]["data"]["value"] == "ended"
# check on the latest response that the audio duration is > 0
assert resp.json()["duration"] > 0
assert "DURATION" in eventnames
# check that audio/mp3 is available # check that audio/mp3 is available
resp = await ac.get(f"/transcripts/{tid}/audio/mp3") audio_resp = await ac.get(f"/transcripts/{tid}/audio/mp3")
assert resp.status_code == 200 assert audio_resp.status_code == 200
assert resp.headers["Content-Type"] == "audio/mpeg" assert audio_resp.headers["Content-Type"] == "audio/mpeg"
@pytest.mark.usefixtures("celery_session_app") @pytest.mark.usefixtures("celery_session_app")

View File

@@ -3,7 +3,8 @@ import React, { createContext, useContext, useState } from "react";
interface ErrorContextProps { interface ErrorContextProps {
error: Error | null; error: Error | null;
setError: React.Dispatch<React.SetStateAction<Error | null>>; humanMessage?: string;
setError: (error: Error, humanMessage?: string) => void;
} }
const ErrorContext = createContext<ErrorContextProps | undefined>(undefined); const ErrorContext = createContext<ErrorContextProps | undefined>(undefined);
@@ -22,9 +23,16 @@ interface ErrorProviderProps {
export const ErrorProvider: React.FC<ErrorProviderProps> = ({ children }) => { export const ErrorProvider: React.FC<ErrorProviderProps> = ({ children }) => {
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [humanMessage, setHumanMessage] = useState<string | undefined>();
const declareError = (error, humanMessage?) => {
setError(error);
setHumanMessage(humanMessage);
};
return ( return (
<ErrorContext.Provider value={{ error, setError }}> <ErrorContext.Provider
value={{ error, setError: declareError, humanMessage }}
>
{children} {children}
</ErrorContext.Provider> </ErrorContext.Provider>
); );

View File

@@ -4,29 +4,51 @@ import { useEffect, useState } from "react";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
const ErrorMessage: React.FC = () => { const ErrorMessage: React.FC = () => {
const { error, setError } = useError(); const { error, setError, humanMessage } = useError();
const [isVisible, setIsVisible] = useState<boolean>(false); const [isVisible, setIsVisible] = useState<boolean>(false);
// Setup Shortcuts
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
switch (event.key) {
case "^":
throw new Error("Unhandled Exception thrown by '^' shortcut");
case "$":
setError(
new Error("Unhandled Exception thrown by '$' shortcut"),
"You did this to yourself",
);
}
};
document.addEventListener("keydown", handleKeyPress);
return () => document.removeEventListener("keydown", handleKeyPress);
}, []);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setIsVisible(true); if (humanMessage) {
Sentry.captureException(error); setIsVisible(true);
console.error("Error", error.message, error); Sentry.captureException(Error(humanMessage, { cause: error }));
} else {
Sentry.captureException(error);
}
console.error("Error", error);
} }
}, [error]); }, [error]);
if (!isVisible || !error) return null; if (!isVisible || !humanMessage) return null;
return ( return (
<button <button
onClick={() => { onClick={() => {
setIsVisible(false); setIsVisible(false);
setError(null);
}} }}
className="max-w-xs z-50 fixed bottom-5 right-5 md:bottom-10 md:right-10 border-solid bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded transition-opacity duration-300 ease-out opacity-100 hover:opacity-80 focus-visible:opacity-80 cursor-pointer transform hover:scale-105 focus-visible:scale-105" className="max-w-xs z-50 fixed bottom-5 right-5 md:bottom-10 md:right-10 border-solid bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded transition-opacity duration-300 ease-out opacity-100 hover:opacity-80 focus-visible:opacity-80 cursor-pointer transform hover:scale-105 focus-visible:scale-105"
role="alert" role="alert"
> >
<span className="block sm:inline">{error?.message}</span> <span className="block sm:inline">{humanMessage}</span>
</button> </button>
); );
}; };

View File

@@ -40,7 +40,7 @@ export default function Pagination(props: PaginationProps) {
return ( return (
<div className="flex justify-center space-x-4 my-4"> <div className="flex justify-center space-x-4 my-4">
<button <button
className={`w-10 h-10 rounded-full p-2 border border-gray-300 rounded-full disabled:bg-white ${ className={`w-10 h-10 rounded-full p-2 border border-gray-300 disabled:bg-white ${
canGoPrevious ? "text-gray-500" : "text-gray-300" canGoPrevious ? "text-gray-500" : "text-gray-300"
}`} }`}
onClick={() => handlePageChange(page - 1)} onClick={() => handlePageChange(page - 1)}
@@ -52,7 +52,7 @@ export default function Pagination(props: PaginationProps) {
{pageNumbers.map((pageNumber) => ( {pageNumbers.map((pageNumber) => (
<button <button
key={pageNumber} key={pageNumber}
className={`w-10 h-10 rounded-full p-2 border rounded-full ${ className={`w-10 h-10 rounded-full p-2 border ${
page === pageNumber ? "border-gray-600" : "border-gray-300" page === pageNumber ? "border-gray-600" : "border-gray-300"
} rounded`} } rounded`}
onClick={() => handlePageChange(pageNumber)} onClick={() => handlePageChange(pageNumber)}
@@ -62,7 +62,7 @@ export default function Pagination(props: PaginationProps) {
))} ))}
<button <button
className={`w-10 h-10 rounded-full p-2 border border-gray-300 rounded-full disabled:bg-white ${ className={`w-10 h-10 rounded-full p-2 border border-gray-300 disabled:bg-white ${
canGoNext ? "text-gray-500" : "text-gray-300" canGoNext ? "text-gray-500" : "text-gray-300"
}`} }`}
onClick={() => handlePageChange(page + 1)} onClick={() => handlePageChange(page + 1)}

View File

@@ -5,14 +5,15 @@ import useTopics from "../useTopics";
import useWaveform from "../useWaveform"; import useWaveform from "../useWaveform";
import useMp3 from "../useMp3"; import useMp3 from "../useMp3";
import { TopicList } from "../topicList"; import { TopicList } from "../topicList";
import Recorder from "../recorder";
import { Topic } from "../webSocketTypes"; import { Topic } from "../webSocketTypes";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import "../../../styles/button.css"; import "../../../styles/button.css";
import FinalSummary from "../finalSummary"; import FinalSummary from "../finalSummary";
import ShareLink from "../shareLink"; import ShareLink from "../shareLink";
import QRCode from "react-qr-code"; import QRCode from "react-qr-code";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
import WaveformLoading from "../waveformLoading";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -29,9 +30,9 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const topics = useTopics(protectedPath, transcriptId); const topics = useTopics(protectedPath, transcriptId);
const waveform = useWaveform(protectedPath, transcriptId); const waveform = useWaveform(protectedPath, transcriptId);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const mp3 = useMp3(protectedPath, transcriptId); const mp3 = useMp3(transcriptId);
if (transcript?.error /** || topics?.error || waveform?.error **/) { if (transcript?.error || topics?.error) {
return ( return (
<Modal <Modal
title="Transcription Not Found" title="Transcription Not Found"
@@ -40,6 +41,18 @@ export default function TranscriptDetails(details: TranscriptDetails) {
); );
} }
useEffect(() => {
const statusToRedirect = ["idle", "recording", "processing"];
if (statusToRedirect.includes(transcript.response?.status)) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540
// router.push(newUrl, undefined, { shallow: true });
history.replaceState({}, "", newUrl);
}
}, [transcript.response?.status]);
const fullTranscript = const fullTranscript =
topics.topics topics.topics
?.map((topic) => topic.transcript) ?.map((topic) => topic.transcript)
@@ -49,7 +62,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
return ( return (
<> <>
{!transcriptId || transcript?.loading || topics?.loading ? ( {transcript?.loading || topics?.loading ? (
<Modal title="Loading" text={"Loading transcript..."} /> <Modal title="Loading" text={"Loading transcript..."} />
) : ( ) : (
<> <>
@@ -61,32 +74,47 @@ export default function TranscriptDetails(details: TranscriptDetails) {
transcriptId={transcript.response.id} transcriptId={transcript.response.id}
/> />
)} )}
{waveform?.loading === false && ( {waveform.waveform && mp3.media ? (
<Recorder <Player
topics={topics?.topics || []} topics={topics?.topics || []}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform?.waveform} waveform={waveform.waveform.data}
isPastMeeting={true} media={mp3.media}
transcriptId={transcript?.response?.id} mediaDuration={transcript.response.duration}
mp3Blob={mp3.blob}
/> />
) : waveform.error ? (
<div>"error loading this recording"</div>
) : (
<WaveformLoading />
)} )}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full"> <div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full">
<TopicList <TopicList
topics={topics?.topics || []} topics={topics.topics || []}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={false} autoscroll={false}
/> />
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4"> <div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full"> <section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full">
{transcript?.response?.longSummary && ( {transcript.response.longSummary ? (
<FinalSummary <FinalSummary
protectedPath={protectedPath} protectedPath={protectedPath}
fullTranscript={fullTranscript} fullTranscript={fullTranscript}
summary={transcript?.response?.longSummary} summary={transcript.response.longSummary}
transcriptId={transcript?.response?.id} transcriptId={transcript.response.id}
/> />
) : (
<div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? (
<p>Loading Transcript</p>
) : (
<p>
There was an error generating the final summary, please
come back later
</p>
)}
</div>
)} )}
</section> </section>

View File

@@ -8,12 +8,15 @@ import { useWebSockets } from "../../useWebSockets";
import useAudioDevice from "../../useAudioDevice"; import useAudioDevice from "../../useAudioDevice";
import "../../../../styles/button.css"; import "../../../../styles/button.css";
import { Topic } from "../../webSocketTypes"; import { Topic } from "../../webSocketTypes";
import getApi from "../../../../lib/getApi";
import LiveTrancription from "../../liveTranscription"; import LiveTrancription from "../../liveTranscription";
import DisconnectedIndicator from "../../disconnectedIndicator"; import DisconnectedIndicator from "../../disconnectedIndicator";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear } from "@fortawesome/free-solid-svg-icons"; import { faGear } from "@fortawesome/free-solid-svg-icons";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import Player from "../../player";
import useMp3, { Mp3Response } from "../../useMp3";
import WaveformLoading from "../../waveformLoading";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -42,8 +45,12 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const { audioDevices, getAudioStream } = useAudioDevice(); const { audioDevices, getAudioStream } = useAudioDevice();
const [hasRecorded, setHasRecorded] = useState(false); const [recordedTime, setRecordedTime] = useState(0);
const [startTime, setStartTime] = useState(0);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
let mp3 = useMp3(details.params.transcriptId, true);
const router = useRouter();
useEffect(() => { useEffect(() => {
if (!transcriptStarted && webSockets.transcriptText.length !== 0) if (!transcriptStarted && webSockets.transcriptText.length !== 0)
@@ -51,15 +58,27 @@ const TranscriptRecord = (details: TranscriptDetails) => {
}, [webSockets.transcriptText]); }, [webSockets.transcriptText]);
useEffect(() => { useEffect(() => {
if (transcript?.response?.longSummary) { const statusToRedirect = ["ended", "error"];
const newUrl = `/transcripts/${transcript.response.id}`;
//TODO if has no topic and is error, get back to new
if (
statusToRedirect.includes(transcript.response?.status) ||
statusToRedirect.includes(webSockets.status.value)
) {
const newUrl = "/transcripts/" + details.params.transcriptId;
// Shallow redirection does not work on NextJS 13 // Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110 // https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540 // https://github.com/vercel/next.js/discussions/49540
// router.push(newUrl, undefined, { shallow: true }); router.replace(newUrl);
history.replaceState({}, "", newUrl); // history.replaceState({}, "", newUrl);
} // history.replaceState({}, "", newUrl);
}, [webSockets.status.value, transcript.response?.status]);
useEffect(() => {
if (webSockets.duration) {
mp3.getNow();
} }
}); }, [webSockets.duration]);
useEffect(() => { useEffect(() => {
lockWakeState(); lockWakeState();
@@ -70,19 +89,31 @@ const TranscriptRecord = (details: TranscriptDetails) => {
return ( return (
<> <>
<Recorder {webSockets.waveform && webSockets.duration && mp3?.media ? (
setStream={setStream} <Player
onStop={() => { topics={webSockets.topics || []}
setStream(null); useActiveTopic={useActiveTopic}
setHasRecorded(true); waveform={webSockets.waveform}
webRTC?.send(JSON.stringify({ cmd: "STOP" })); media={mp3.media}
}} mediaDuration={webSockets.duration}
topics={webSockets.topics} />
getAudioStream={getAudioStream} ) : recordedTime ? (
useActiveTopic={useActiveTopic} <WaveformLoading />
isPastMeeting={false} ) : (
audioDevices={audioDevices} <Recorder
/> setStream={setStream}
onStop={() => {
setStream(null);
setRecordedTime(Date.now() - startTime);
webRTC?.send(JSON.stringify({ cmd: "STOP" }));
}}
onRecord={() => {
setStartTime(Date.now());
}}
getAudioStream={getAudioStream}
audioDevices={audioDevices}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-mobile-inner lg:grid-rows-1 gap-2 lg:gap-4 h-full"> <div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-mobile-inner lg:grid-rows-1 gap-2 lg:gap-4 h-full">
<TopicList <TopicList
@@ -94,7 +125,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
<section <section
className={`w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4`} className={`w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4`}
> >
{!hasRecorded ? ( {!recordedTime ? (
<> <>
{transcriptStarted && ( {transcriptStarted && (
<h2 className="md:text-lg font-bold">Transcription</h2> <h2 className="md:text-lg font-bold">Transcription</h2>
@@ -128,6 +159,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
couple of minutes. Please do not navigate away from the page couple of minutes. Please do not navigate away from the page
during this time. during this time.
</p> </p>
{/* NTH If login required remove last sentence */}
</div> </div>
)} )}
</section> </section>

View File

@@ -45,7 +45,10 @@ const useCreateTranscript = (): CreateTranscript => {
console.debug("New transcript created:", result); console.debug("New transcript created:", result);
}) })
.catch((err) => { .catch((err) => {
setError(err); setError(
err,
"There was an issue creating a transcript, please try again.",
);
setErrorState(err); setErrorState(err);
setLoading(false); setLoading(false);
}); });

View File

@@ -87,7 +87,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
<div <div
className={ className={
(isEditMode ? "overflow-y-none" : "overflow-y-auto") + (isEditMode ? "overflow-y-none" : "overflow-y-auto") +
" h-auto max-h-full flex flex-col h-full" " max-h-full flex flex-col h-full"
} }
> >
<div className="flex flex-row flex-wrap-reverse justify-between items-center"> <div className="flex flex-row flex-wrap-reverse justify-between items-center">

View File

@@ -0,0 +1,166 @@
import React, { useRef, useEffect, useState } from "react";
import WaveSurfer from "wavesurfer.js";
import CustomRegionsPlugin from "../../lib/custom-plugins/regions";
import { formatTime } from "../../lib/time";
import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api";
import { waveSurferStyles } from "../../styles/recorder";
type PlayerProps = {
topics: Topic[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
waveform: AudioWaveform["data"];
media: HTMLMediaElement;
mediaDuration: number;
};
export default function Player(props: PlayerProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [waveRegions, setWaveRegions] = useState<CustomRegionsPlugin | null>(
null,
);
const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics);
// Waveform setup
useEffect(() => {
if (waveformRef.current) {
// XXX duration is required to prevent recomputing peaks from audio
// However, the current waveform returns only the peaks, and no duration
// And the backend does not save duration properly.
// So at the moment, we deduct the duration from the topics.
// This is not ideal, but it works for now.
const _wavesurfer = WaveSurfer.create({
container: waveformRef.current,
peaks: props.waveform,
hideScrollbar: true,
autoCenter: true,
barWidth: 2,
height: "auto",
duration: props.mediaDuration,
...waveSurferStyles.player,
});
// styling
const wsWrapper = _wavesurfer.getWrapper();
wsWrapper.style.cursor = waveSurferStyles.playerStyle.cursor;
wsWrapper.style.backgroundColor =
waveSurferStyles.playerStyle.backgroundColor;
wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius;
_wavesurfer.on("play", () => {
setIsPlaying(true);
});
_wavesurfer.on("pause", () => {
setIsPlaying(false);
});
_wavesurfer.on("timeupdate", setCurrentTime);
setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create()));
_wavesurfer.toggleInteraction(true);
_wavesurfer.setMediaElement(props.media);
setWavesurfer(_wavesurfer);
return () => {
_wavesurfer.destroy();
setIsPlaying(false);
setCurrentTime(0);
};
}
}, []);
useEffect(() => {
if (!wavesurfer) return;
if (!props.media) return;
wavesurfer.setMediaElement(props.media);
}, [props.media, wavesurfer]);
useEffect(() => {
topicsRef.current = props.topics;
renderMarkers();
}, [props.topics, waveRegions]);
const renderMarkers = () => {
if (!waveRegions) return;
waveRegions.clearRegions();
for (let topic of topicsRef.current) {
const content = document.createElement("div");
content.setAttribute("style", waveSurferStyles.marker);
content.onmouseover = () => {
content.style.backgroundColor =
waveSurferStyles.markerHover.backgroundColor;
content.style.zIndex = "999";
content.style.width = "300px";
};
content.onmouseout = () => {
content.setAttribute("style", waveSurferStyles.marker);
};
content.textContent = topic.title;
const region = waveRegions.addRegion({
start: topic.timestamp,
content,
color: "f00",
drag: false,
});
region.on("click", (e) => {
e.stopPropagation();
setActiveTopic(topic);
wavesurfer?.setTime(region.start);
});
}
};
useEffect(() => {
if (activeTopic) {
wavesurfer?.setTime(activeTopic.timestamp);
}
}, [activeTopic]);
const handlePlayClick = () => {
wavesurfer?.playPause();
};
const timeLabel = () => {
if (props.mediaDuration)
return `${formatTime(currentTime)}/${formatTime(props.mediaDuration)}`;
return "";
};
return (
<div className="flex items-center w-full relative">
<div className="flex-grow items-end relative">
<div
ref={waveformRef}
className="flex-grow rounded-lg md:rounded-xl h-20"
></div>
<div className="absolute right-2 bottom-0">{timeLabel()}</div>
</div>
<button
className={`${
isPlaying
? "bg-orange-400 hover:bg-orange-500 focus-visible:bg-orange-500"
: "bg-green-400 hover:bg-green-500 focus-visible:bg-green-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
id="play-btn"
onClick={handlePlayClick}
>
{isPlaying ? "Pause" : "Play"}
</button>
</div>
);
}

View File

@@ -6,31 +6,19 @@ import CustomRegionsPlugin from "../../lib/custom-plugins/regions";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMicrophone } from "@fortawesome/free-solid-svg-icons"; import { faMicrophone } from "@fortawesome/free-solid-svg-icons";
import { faDownload } from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../../lib/time"; import { formatTime } from "../../lib/time";
import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api";
import AudioInputsDropdown from "./audioInputsDropdown"; import AudioInputsDropdown from "./audioInputsDropdown";
import { Option } from "react-dropdown"; import { Option } from "react-dropdown";
import { useError } from "../../(errors)/errorContext";
import { waveSurferStyles } from "../../styles/recorder"; import { waveSurferStyles } from "../../styles/recorder";
import useMp3 from "./useMp3"; import { useError } from "../../(errors)/errorContext";
type RecorderProps = { type RecorderProps = {
setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>; setStream: React.Dispatch<React.SetStateAction<MediaStream | null>>;
onStop?: () => void; onStop: () => void;
topics: Topic[]; onRecord?: () => void;
getAudioStream?: (deviceId) => Promise<MediaStream | null>; getAudioStream: (deviceId) => Promise<MediaStream | null>;
audioDevices?: Option[]; audioDevices: Option[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
waveform?: AudioWaveform | null;
isPastMeeting: boolean;
transcriptId?: string | null;
mp3Blob?: Blob | null;
}; };
export default function Recorder(props: RecorderProps) { export default function Recorder(props: RecorderProps) {
@@ -38,7 +26,7 @@ export default function Recorder(props: RecorderProps) {
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null); const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
const [record, setRecord] = useState<RecordPlugin | null>(null); const [record, setRecord] = useState<RecordPlugin | null>(null);
const [isRecording, setIsRecording] = useState<boolean>(false); const [isRecording, setIsRecording] = useState<boolean>(false);
const [hasRecorded, setHasRecorded] = useState<boolean>(props.isPastMeeting); const [hasRecorded, setHasRecorded] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [timeInterval, setTimeInterval] = useState<number | null>(null); const [timeInterval, setTimeInterval] = useState<number | null>(null);
@@ -48,8 +36,6 @@ export default function Recorder(props: RecorderProps) {
); );
const [deviceId, setDeviceId] = useState<string | null>(null); const [deviceId, setDeviceId] = useState<string | null>(null);
const [recordStarted, setRecordStarted] = useState(false); const [recordStarted, setRecordStarted] = useState(false);
const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics);
const [showDevices, setShowDevices] = useState(false); const [showDevices, setShowDevices] = useState(false);
const { setError } = useError(); const { setError } = useError();
@@ -73,11 +59,6 @@ export default function Recorder(props: RecorderProps) {
if (!record.isRecording()) return; if (!record.isRecording()) return;
handleRecClick(); handleRecClick();
break; break;
case "%":
setError(new Error("Error triggered by '%' shortcut"));
break;
case "^":
throw new Error("Unhandled Exception thrown by '^' shortcut");
case "(": case "(":
location.href = "/login"; location.href = "/login";
break; break;
@@ -109,7 +90,6 @@ export default function Recorder(props: RecorderProps) {
if (waveformRef.current) { if (waveformRef.current) {
const _wavesurfer = WaveSurfer.create({ const _wavesurfer = WaveSurfer.create({
container: waveformRef.current, container: waveformRef.current,
peaks: props.waveform?.data,
hideScrollbar: true, hideScrollbar: true,
autoCenter: true, autoCenter: true,
barWidth: 2, barWidth: 2,
@@ -118,10 +98,8 @@ export default function Recorder(props: RecorderProps) {
...waveSurferStyles.player, ...waveSurferStyles.player,
}); });
if (!props.transcriptId) { const _wshack: any = _wavesurfer;
const _wshack: any = _wavesurfer; _wshack.renderer.renderSingleCanvas = () => {};
_wshack.renderer.renderSingleCanvas = () => {};
}
// styling // styling
const wsWrapper = _wavesurfer.getWrapper(); const wsWrapper = _wavesurfer.getWrapper();
@@ -141,12 +119,6 @@ export default function Recorder(props: RecorderProps) {
setRecord(_wavesurfer.registerPlugin(RecordPlugin.create())); setRecord(_wavesurfer.registerPlugin(RecordPlugin.create()));
setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create())); setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create()));
if (props.isPastMeeting) _wavesurfer.toggleInteraction(true);
if (props.mp3Blob) {
_wavesurfer.loadBlob(props.mp3Blob);
}
setWavesurfer(_wavesurfer); setWavesurfer(_wavesurfer);
return () => { return () => {
@@ -158,58 +130,6 @@ export default function Recorder(props: RecorderProps) {
} }
}, []); }, []);
useEffect(() => {
if (!wavesurfer) return;
if (!props.mp3Blob) return;
wavesurfer.loadBlob(props.mp3Blob);
}, [props.mp3Blob]);
useEffect(() => {
topicsRef.current = props.topics;
if (!isRecording) renderMarkers();
}, [props.topics, waveRegions]);
const renderMarkers = () => {
if (!waveRegions) return;
waveRegions.clearRegions();
for (let topic of topicsRef.current) {
const content = document.createElement("div");
content.setAttribute("style", waveSurferStyles.marker);
content.onmouseover = () => {
content.style.backgroundColor =
waveSurferStyles.markerHover.backgroundColor;
content.style.zIndex = "999";
content.style.width = "300px";
};
content.onmouseout = () => {
content.setAttribute("style", waveSurferStyles.marker);
};
content.textContent = topic.title;
const region = waveRegions.addRegion({
start: topic.timestamp,
content,
color: "f00",
drag: false,
});
region.on("click", (e) => {
e.stopPropagation();
setActiveTopic(topic);
wavesurfer?.setTime(region.start);
});
}
};
useEffect(() => {
if (!record) return;
return record.on("stopRecording", () => {
renderMarkers();
});
}, [record]);
useEffect(() => { useEffect(() => {
if (isRecording) { if (isRecording) {
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
@@ -226,25 +146,24 @@ export default function Recorder(props: RecorderProps) {
} }
}, [isRecording]); }, [isRecording]);
useEffect(() => {
if (activeTopic) {
wavesurfer?.setTime(activeTopic.timestamp);
}
}, [activeTopic]);
const handleRecClick = async () => { const handleRecClick = async () => {
if (!record) return console.log("no record"); if (!record) return console.log("no record");
if (record.isRecording()) { if (record.isRecording()) {
if (props.onStop) props.onStop(); if (props.onStop) props.onStop();
record.stopRecording(); record.stopRecording();
if (screenMediaStream) {
screenMediaStream.getTracks().forEach((t) => t.stop());
}
setIsRecording(false); setIsRecording(false);
setHasRecorded(true); setHasRecorded(true);
setScreenMediaStream(null);
setDestinationStream(null);
} else { } else {
if (props.onRecord) props.onRecord();
const stream = await getCurrentStream(); const stream = await getCurrentStream();
if (props.setStream) props.setStream(stream); if (props.setStream) props.setStream(stream);
waveRegions?.clearRegions();
if (stream) { if (stream) {
await record.startRecording(stream); await record.startRecording(stream);
setIsRecording(true); setIsRecording(true);
@@ -252,6 +171,76 @@ export default function Recorder(props: RecorderProps) {
} }
}; };
const [screenMediaStream, setScreenMediaStream] =
useState<MediaStream | null>(null);
const handleRecordTabClick = async () => {
if (!record) return console.log("no record");
const stream: MediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100,
},
});
if (stream.getAudioTracks().length == 0) {
setError(new Error("No audio track found in screen recording."));
return;
}
setScreenMediaStream(stream);
};
const [destinationStream, setDestinationStream] =
useState<MediaStream | null>(null);
const startTabRecording = async () => {
if (!screenMediaStream) return;
if (!record) return;
if (destinationStream !== null) return console.log("already recording");
// connect mic audio (microphone)
const micStream = await getCurrentStream();
if (!micStream) {
console.log("no microphone audio");
return;
}
// Create MediaStreamSource nodes for the microphone and tab
const audioContext = new AudioContext();
const micSource = audioContext.createMediaStreamSource(micStream);
const tabSource = audioContext.createMediaStreamSource(screenMediaStream);
// Merge channels
// XXX If the length is not the same, we do not receive audio in WebRTC.
// So for now, merge the channels to have only one stereo source
const channelMerger = audioContext.createChannelMerger(1);
micSource.connect(channelMerger, 0, 0);
tabSource.connect(channelMerger, 0, 0);
// Create a MediaStreamDestination node
const destination = audioContext.createMediaStreamDestination();
channelMerger.connect(destination);
// Use the destination's stream for the WebRTC connection
setDestinationStream(destination.stream);
};
useEffect(() => {
if (!record) return;
if (!destinationStream) return;
if (props.setStream) props.setStream(destinationStream);
if (destinationStream) {
record.startRecording(destinationStream);
setIsRecording(true);
}
}, [record, destinationStream]);
useEffect(() => {
startTabRecording();
}, [record, screenMediaStream]);
const handlePlayClick = () => { const handlePlayClick = () => {
wavesurfer?.playPause(); wavesurfer?.playPause();
}; };
@@ -300,23 +289,9 @@ export default function Recorder(props: RecorderProps) {
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`} } text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
id="play-btn" id="play-btn"
onClick={handlePlayClick} onClick={handlePlayClick}
disabled={isRecording}
> >
{isPlaying ? "Pause" : "Play"} {isPlaying ? "Pause" : "Play"}
</button> </button>
{props.transcriptId && (
<a
title="Download recording"
className="text-center cursor-pointer text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg outline-blue-400"
download={`recording-${
props.transcriptId?.split("-")[0] || "0000"
}`}
href={`${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`}
>
<FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
</a>
)}
</> </>
)} )}
{!hasRecorded && ( {!hasRecorded && (
@@ -332,6 +307,19 @@ export default function Recorder(props: RecorderProps) {
> >
{isRecording ? "Stop" : "Record"} {isRecording ? "Stop" : "Record"}
</button> </button>
{!isRecording && (
<button
className={`${
isRecording
? "bg-red-400 hover:bg-red-500 focus-visible:bg-red-500"
: "bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
onClick={handleRecordTabClick}
>
Record
<br />a tab
</button>
)}
{props.audioDevices && props.audioDevices?.length > 0 && deviceId && ( {props.audioDevices && props.audioDevices?.length > 0 && deviceId && (
<> <>
<button <button

View File

@@ -1,80 +1,60 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import {
DefaultApi,
// V1TranscriptGetAudioMp3Request,
} from "../../api/apis/DefaultApi";
import {} from "../../api";
import { useError } from "../../(errors)/errorContext";
import { DomainContext } from "../domainContext"; import { DomainContext } from "../domainContext";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
import { useFiefAccessTokenInfo } from "@fief/fief/build/esm/nextjs/react"; import { useFiefAccessTokenInfo } from "@fief/fief/build/esm/nextjs/react";
type Mp3Response = { export type Mp3Response = {
url: string | null; media: HTMLMediaElement | null;
blob: Blob | null;
loading: boolean; loading: boolean;
error: Error | null; getNow: () => void;
}; };
const useMp3 = (protectedPath: boolean, id: string): Mp3Response => { const useMp3 = (id: string, waiting?: boolean): Mp3Response => {
const [url, setUrl] = useState<string | null>(null); const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const [blob, setBlob] = useState<Blob | null>(null); const [later, setLater] = useState(waiting);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null); const api = getApi(true);
const { setError } = useError();
const api = getApi(protectedPath);
const { api_url } = useContext(DomainContext); const { api_url } = useContext(DomainContext);
const accessTokenInfo = useFiefAccessTokenInfo(); const accessTokenInfo = useFiefAccessTokenInfo();
const [serviceWorkerReady, setServiceWorkerReady] = useState(false);
const getMp3 = (id: string) => {
if (!id || !api) return;
setLoading(true);
// XXX Current API interface does not output a blob, we need to to is manually
// const requestParameters: V1TranscriptGetAudioMp3Request = {
// transcriptId: id,
// };
// api
// .v1TranscriptGetAudioMp3(requestParameters)
// .then((result) => {
// setUrl(result);
// setLoading(false);
// console.debug("Transcript Mp3 loaded:", result);
// })
// .catch((err) => {
// setError(err);
// setErrorState(err);
// });
const localUrl = `${api_url}/v1/transcripts/${id}/audio/mp3`;
if (localUrl == url) return;
const headers = new Headers();
if (accessTokenInfo) {
headers.set("Authorization", "Bearer " + accessTokenInfo.access_token);
}
fetch(localUrl, {
method: "GET",
headers,
})
.then((response) => {
setUrl(localUrl);
response.blob().then((blob) => {
setBlob(blob);
setLoading(false);
});
})
.catch((err) => {
setError(err);
setErrorState(err);
});
};
useEffect(() => { useEffect(() => {
getMp3(id); if ("serviceWorker" in navigator) {
}, [id, api]); navigator.serviceWorker.register("/service-worker.js").then(() => {
setServiceWorkerReady(true);
});
}
}, []);
return { url, blob, loading, error }; useEffect(() => {
if (!navigator.serviceWorker) return;
if (!navigator.serviceWorker.controller) return;
if (!serviceWorkerReady) return;
// Send the token to the service worker
navigator.serviceWorker.controller.postMessage({
type: "SET_AUTH_TOKEN",
token: accessTokenInfo?.access_token,
});
}, [navigator.serviceWorker, serviceWorkerReady, accessTokenInfo]);
useEffect(() => {
if (!id || !api || later) return;
// createa a audio element and set the source
setLoading(true);
const audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${id}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
setMedia(audioElement);
setLoading(false);
}, [id, api, later]);
const getNow = () => {
setLater(false);
};
return { media, loading, getNow };
}; };
export default useMp3; export default useMp3;

View File

@@ -6,6 +6,7 @@ import {
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { Topic } from "./webSocketTypes"; import { Topic } from "./webSocketTypes";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
import { shouldShowError } from "../../lib/errorUtils";
type TranscriptTopics = { type TranscriptTopics = {
topics: Topic[] | null; topics: Topic[] | null;
@@ -35,8 +36,13 @@ const useTopics = (protectedPath, id: string): TranscriptTopics => {
console.debug("Transcript topics loaded:", result); console.debug("Transcript topics loaded:", result);
}) })
.catch((err) => { .catch((err) => {
setError(err);
setErrorState(err); setErrorState(err);
const shouldShowHuman = shouldShowError(err);
if (shouldShowHuman) {
setError(err, "There was an error loading the topics");
} else {
setError(err);
}
}); });
}, [id, api]); }, [id, api]);

View File

@@ -3,17 +3,30 @@ import { V1TranscriptGetRequest } from "../../api/apis/DefaultApi";
import { GetTranscript } from "../../api"; import { GetTranscript } from "../../api";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
import { shouldShowError } from "../../lib/errorUtils";
type Transcript = { type ErrorTranscript = {
response: GetTranscript | null; error: Error;
loading: boolean; loading: false;
error: Error | null; response: any;
};
type LoadingTranscript = {
response: any;
loading: true;
error: false;
};
type SuccessTranscript = {
response: GetTranscript;
loading: false;
error: null;
}; };
const useTranscript = ( const useTranscript = (
protectedPath: boolean, protectedPath: boolean,
id: string | null, id: string | null,
): Transcript => { ): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
const [response, setResponse] = useState<GetTranscript | null>(null); const [response, setResponse] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
@@ -34,13 +47,21 @@ const useTranscript = (
setLoading(false); setLoading(false);
console.debug("Transcript Loaded:", result); console.debug("Transcript Loaded:", result);
}) })
.catch((err) => { .catch((error) => {
setError(err); const shouldShowHuman = shouldShowError(error);
setErrorState(err); if (shouldShowHuman) {
setError(error, "There was an error loading the transcript");
} else {
setError(error);
}
setErrorState(error);
}); });
}, [id, !api]); }, [id, !api]);
return { response, loading, error }; return { response, loading, error } as
| ErrorTranscript
| LoadingTranscript
| SuccessTranscript;
}; };
export default useTranscript; export default useTranscript;

View File

@@ -6,6 +6,7 @@ import {
import { AudioWaveform } from "../../api"; import { AudioWaveform } from "../../api";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
import { shouldShowError } from "../../lib/errorUtils";
type AudioWaveFormResponse = { type AudioWaveFormResponse = {
waveform: AudioWaveform | null; waveform: AudioWaveform | null;
@@ -22,7 +23,6 @@ const useWaveform = (protectedPath, id: string): AudioWaveFormResponse => {
useEffect(() => { useEffect(() => {
if (!id || !api) return; if (!id || !api) return;
setLoading(true); setLoading(true);
const requestParameters: V1TranscriptGetAudioWaveformRequest = { const requestParameters: V1TranscriptGetAudioWaveformRequest = {
transcriptId: id, transcriptId: id,
@@ -35,8 +35,13 @@ const useWaveform = (protectedPath, id: string): AudioWaveFormResponse => {
console.debug("Transcript waveform loaded:", result); console.debug("Transcript waveform loaded:", result);
}) })
.catch((err) => { .catch((err) => {
setError(err);
setErrorState(err); setErrorState(err);
const shouldShowHuman = shouldShowError(err);
if (shouldShowHuman) {
setError(err, "There was an error loading the waveform");
} else {
setError(err);
}
}); });
}, [id, api]); }, [id, api]);

View File

@@ -28,7 +28,7 @@ const useWebRTC = (
try { try {
p = new Peer({ initiator: true, stream: stream }); p = new Peer({ initiator: true, stream: stream });
} catch (error) { } catch (error) {
setError(error); setError(error, "Error creating WebRTC");
return; return;
} }
@@ -57,7 +57,7 @@ const useWebRTC = (
} }
}) })
.catch((error) => { .catch((error) => {
setError(error); setError(error, "Error loading WebRTCOffer");
}); });
} }
}); });

View File

@@ -1,30 +1,35 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes"; import { Topic, FinalSummary, Status } from "./webSocketTypes";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { useRouter } from "next/navigation";
import { DomainContext } from "../domainContext"; import { DomainContext } from "../domainContext";
import { AudioWaveform } from "../../api";
type UseWebSockets = { export type UseWebSockets = {
transcriptText: string; transcriptText: string;
translateText: string; translateText: string;
title: string;
topics: Topic[]; topics: Topic[];
finalSummary: FinalSummary; finalSummary: FinalSummary;
status: Status; status: Status;
waveform: AudioWaveform["data"] | null;
duration: number | null;
}; };
export const useWebSockets = (transcriptId: string | null): UseWebSockets => { export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [transcriptText, setTranscriptText] = useState<string>(""); const [transcriptText, setTranscriptText] = useState<string>("");
const [translateText, setTranslateText] = useState<string>(""); const [translateText, setTranslateText] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [textQueue, setTextQueue] = useState<string[]>([]); const [textQueue, setTextQueue] = useState<string[]>([]);
const [translationQueue, setTranslationQueue] = useState<string[]>([]); const [translationQueue, setTranslationQueue] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [topics, setTopics] = useState<Topic[]>([]); const [topics, setTopics] = useState<Topic[]>([]);
const [waveform, setWaveForm] = useState<AudioWaveform | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [finalSummary, setFinalSummary] = useState<FinalSummary>({ const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "", summary: "",
}); });
const [status, setStatus] = useState<Status>({ value: "disconnected" }); const [status, setStatus] = useState<Status>({ value: "initial" });
const { setError } = useError(); const { setError } = useError();
const router = useRouter();
const { websocket_url } = useContext(DomainContext); const { websocket_url } = useContext(DomainContext);
@@ -294,7 +299,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (!transcriptId) return; if (!transcriptId) return;
const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`; const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`;
const ws = new WebSocket(url); let ws = new WebSocket(url);
ws.onopen = () => { ws.onopen = () => {
console.debug("WebSocket connection opened"); console.debug("WebSocket connection opened");
@@ -343,21 +348,39 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
case "FINAL_TITLE": case "FINAL_TITLE":
console.debug("FINAL_TITLE event:", message.data); console.debug("FINAL_TITLE event:", message.data);
if (message.data) {
setTitle(message.data.title);
}
break;
case "WAVEFORM":
console.debug(
"WAVEFORM event length:",
message.data.waveform.length,
);
if (message.data) {
setWaveForm(message.data.waveform);
}
break;
case "DURATION":
console.debug("DURATION event:", message.data);
if (message.data) {
setDuration(message.data.duration);
}
break; break;
case "STATUS": case "STATUS":
console.log("STATUS event:", message.data); console.log("STATUS event:", message.data);
if (message.data.value === "ended") { if (message.data.value === "error") {
const newUrl = "/transcripts/" + transcriptId; setError(
router.push(newUrl); Error("Websocket error status"),
console.debug( "There was an error processing this meeting.",
"FINAL_LONG_SUMMARY event:",
message.data,
"newUrl",
newUrl,
); );
} }
setStatus(message.data); setStatus(message.data);
if (message.data.value === "ended") {
ws.close();
}
break; break;
default: default:
@@ -379,13 +402,18 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
console.debug("WebSocket connection closed"); console.debug("WebSocket connection closed");
switch (event.code) { switch (event.code) {
case 1000: // Normal Closure: case 1000: // Normal Closure:
case 1001: // Going Away:
case 1005:
break;
default: default:
setError( setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`), new Error(`WebSocket closed unexpectedly with code: ${event.code}`),
"Disconnected",
); );
console.log(
"Socket is closed. Reconnect will be attempted in 1 second.",
event.reason,
);
setTimeout(function () {
ws = new WebSocket(url);
}, 1000);
} }
}; };
@@ -394,5 +422,14 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}; };
}, [transcriptId]); }, [transcriptId]);
return { transcriptText, translateText, topics, finalSummary, status }; return {
transcriptText,
translateText,
topics,
finalSummary,
title,
status,
waveform,
duration,
};
}; };

View File

@@ -0,0 +1,11 @@
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export default () => (
<div className="flex flex-grow items-center justify-center h-20">
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-10"
/>
</div>
);

View File

@@ -5,7 +5,7 @@ const localConfig = {
features: { features: {
requireLogin: false, requireLogin: false,
privacy: true, privacy: true,
browse: true, browse: false,
}, },
api_url: "http://127.0.0.1:1250", api_url: "http://127.0.0.1:1250",
websocket_url: "ws://127.0.0.1:1250", websocket_url: "ws://127.0.0.1:1250",

View File

@@ -0,0 +1,8 @@
function shouldShowError(error: Error | null | undefined) {
if (error?.name == "ResponseError" && error["response"].status == 404)
return false;
if (error?.name == "FetchError") return false;
return true;
}
export { shouldShowError };

View File

@@ -14,9 +14,7 @@ export default function getApi(protectedPath: boolean): DefaultApi | undefined {
if (!api_url) throw new Error("no API URL"); if (!api_url) throw new Error("no API URL");
useEffect(() => { useEffect(() => {
// console.log('trying auth', protectedPath, requireLogin, accessTokenInfo)
if (protectedPath && requireLogin && !accessTokenInfo) { if (protectedPath && requireLogin && !accessTokenInfo) {
// console.log('waiting auth')
return; return;
} }

View File

@@ -15,7 +15,7 @@
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.64.0", "@sentry/nextjs": "^7.77.0",
"@vercel/edge-config": "^0.4.1", "@vercel/edge-config": "^0.4.1",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"axios": "^1.4.0", "axios": "^1.4.0",

View File

@@ -0,0 +1,25 @@
let authToken = ""; // Variable to store the token
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SET_AUTH_TOKEN") {
authToken = event.data.token;
}
});
self.addEventListener("fetch", function (event) {
// Check if the request is for a media file
if (/\/v1\/transcripts\/.*\/audio\/mp3$/.test(event.request.url)) {
// Modify the request to add the Authorization header
const modifiedHeaders = new Headers(event.request.headers);
if (authToken) {
modifiedHeaders.append("Authorization", `Bearer ${authToken}`);
}
const modifiedRequest = new Request(event.request, {
headers: modifiedHeaders,
});
// Respond with the modified request
event.respondWith(fetch(modifiedRequest));
}
});

View File

@@ -262,27 +262,25 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
picomatch "^2.3.1" picomatch "^2.3.1"
"@sentry-internal/tracing@7.64.0": "@sentry-internal/tracing@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.64.0.tgz#3e110473b8edf805b799cc91d6ee592830237bb4" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.77.0.tgz#f3d82486f8934a955b3dd2aa54c8d29586e42a37"
integrity sha512-1XE8W6ki7hHyBvX9hfirnGkKDBKNq3bDJyXS86E0bYVDl94nvbRM9BD9DHsCFetqYkVm1yDGEK+6aUVs4CztoQ== integrity sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==
dependencies: dependencies:
"@sentry/core" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
tslib "^2.4.1 || ^1.9.3"
"@sentry/browser@7.64.0": "@sentry/browser@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.64.0.tgz#76db08a5d32ffe7c5aa907f258e6c845ce7f10d7" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.77.0.tgz#155440f1a0d3a1bbd5d564c28d6b0c9853a51d72"
integrity sha512-lB2IWUkZavEDclxfLBp554dY10ZNIEvlDZUWWathW+Ws2wRb6PNLtuPUNu12R7Q7z0xpkOLrM1kRNN0OdldgKA== integrity sha512-nJ2KDZD90H8jcPx9BysQLiQW+w7k7kISCWeRjrEMJzjtge32dmHA8G4stlUTRIQugy5F+73cOayWShceFP7QJQ==
dependencies: dependencies:
"@sentry-internal/tracing" "7.64.0" "@sentry-internal/tracing" "7.77.0"
"@sentry/core" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/replay" "7.64.0" "@sentry/replay" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
tslib "^2.4.1 || ^1.9.3"
"@sentry/cli@^1.74.6": "@sentry/cli@^1.74.6":
version "1.75.2" version "1.75.2"
@@ -296,89 +294,94 @@
proxy-from-env "^1.1.0" proxy-from-env "^1.1.0"
which "^2.0.2" which "^2.0.2"
"@sentry/core@7.64.0": "@sentry/core@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.64.0.tgz#9d61cdc29ba299dedbdcbe01cfadf94bd0b7df48" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.77.0.tgz#21100843132beeeff42296c8370cdcc7aa1d8510"
integrity sha512-IzmEyl5sNG7NyEFiyFHEHC+sizsZp9MEw1+RJRLX6U5RITvcsEgcajSkHQFafaBPzRrcxZMdm47Cwhl212LXcw== integrity sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==
dependencies: dependencies:
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
tslib "^2.4.1 || ^1.9.3"
"@sentry/integrations@7.64.0": "@sentry/integrations@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.64.0.tgz#a392ddeebeec0c08ae5ca1f544c80ab15977fe10" resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.77.0.tgz#f2717e05cb7c69363316ccd34096b2ea07ae4c59"
integrity sha512-6gbSGiruOifAmLtXw//Za19GWiL5qugDMEFxSvc5WrBWb+A8UK+foPn3K495OcivLS68AmqAQCUGb+6nlVowwA== integrity sha512-P055qXgBHeZNKnnVEs5eZYLdy6P49Zr77A1aWJuNih/EenzMy922GOeGy2mF6XYrn1YJSjEwsNMNsQkcvMTK8Q==
dependencies: dependencies:
"@sentry/types" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.77.0"
localforage "^1.8.1" localforage "^1.8.1"
tslib "^2.4.1 || ^1.9.3"
"@sentry/nextjs@^7.64.0": "@sentry/nextjs@^7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.64.0.tgz#5c0bd7ccc6637e0b925dec25ca247dcb8476663c" resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.77.0.tgz#036b1c45dd106e01d44967c97985464e108922be"
integrity sha512-hKlIQpFugdRlWj0wcEG9I8JyVm/osdsE72zwMBGnmCw/jf7U63vjOjfxMe/gRuvllCf/AvoGHEkR5jPufcO+bw== integrity sha512-8tYPBt5luFjrng1sAMJqNjM9sq80q0jbt6yariADU9hEr7Zk8YqFaOI2/Q6yn9dZ6XyytIRtLEo54kk2AO94xw==
dependencies: dependencies:
"@rollup/plugin-commonjs" "24.0.0" "@rollup/plugin-commonjs" "24.0.0"
"@sentry/core" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/integrations" "7.64.0" "@sentry/integrations" "7.77.0"
"@sentry/node" "7.64.0" "@sentry/node" "7.77.0"
"@sentry/react" "7.64.0" "@sentry/react" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
"@sentry/vercel-edge" "7.77.0"
"@sentry/webpack-plugin" "1.20.0" "@sentry/webpack-plugin" "1.20.0"
chalk "3.0.0" chalk "3.0.0"
resolve "1.22.8"
rollup "2.78.0" rollup "2.78.0"
stacktrace-parser "^0.1.10" stacktrace-parser "^0.1.10"
tslib "^2.4.1 || ^1.9.3"
"@sentry/node@7.64.0": "@sentry/node@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.64.0.tgz#c6f7a67c1442324298f0525e7191bc18572ee1ce" resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.77.0.tgz#a247452779a5bcb55724457707286e3e4a29dbbe"
integrity sha512-wRi0uTnp1WSa83X2yLD49tV9QPzGh5e42IKdIDBiQ7lV9JhLILlyb34BZY1pq6p4dp35yDasDrP3C7ubn7wo6A== integrity sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==
dependencies: dependencies:
"@sentry-internal/tracing" "7.64.0" "@sentry-internal/tracing" "7.77.0"
"@sentry/core" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
cookie "^0.4.1"
https-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0"
lru_map "^0.3.3"
tslib "^2.4.1 || ^1.9.3"
"@sentry/react@7.64.0": "@sentry/react@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.64.0.tgz#edee24ac232990204e0fb43dd83994642d4b0f54" resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.77.0.tgz#9da14e4b21eae4b5a6306d39bb7c42ef0827d2c2"
integrity sha512-wOyJUQi7OoT1q+F/fVVv1fzbyO4OYbTu6m1DliLOGQPGEHPBsgPc722smPIExd1/rAMK/FxOuNN5oNhubH8nhg== integrity sha512-Q+htKzib5em0MdaQZMmPomaswaU3xhcVqmLi2CxqQypSjbYgBPPd+DuhrXKoWYLDDkkbY2uyfe4Lp3yLRWeXYw==
dependencies: dependencies:
"@sentry/browser" "7.64.0" "@sentry/browser" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/utils" "7.77.0"
hoist-non-react-statics "^3.3.2" hoist-non-react-statics "^3.3.2"
tslib "^2.4.1 || ^1.9.3"
"@sentry/replay@7.64.0": "@sentry/replay@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.64.0.tgz#bdf09b0c4712f9dc6b24b3ebefa55a4ac76708e6" resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.77.0.tgz#21d242c9cd70a7235237216174873fd140b6eb80"
integrity sha512-alaMCZDZhaAVmEyiUnszZnvfdbiZx5MmtMTGrlDd7tYq3K5OA9prdLqqlmfIJYBfYtXF3lD0iZFphOZQD+4CIw== integrity sha512-M9Ik2J5ekl+C1Och3wzLRZVaRGK33BlnBwfwf3qKjgLDwfKW+1YkwDfTHbc2b74RowkJbOVNcp4m8ptlehlSaQ==
dependencies: dependencies:
"@sentry/core" "7.64.0" "@sentry-internal/tracing" "7.77.0"
"@sentry/types" "7.64.0" "@sentry/core" "7.77.0"
"@sentry/utils" "7.64.0" "@sentry/types" "7.77.0"
"@sentry/utils" "7.77.0"
"@sentry/types@7.64.0": "@sentry/types@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.64.0.tgz#21fc545ea05c3c8c4c3e518583eca1a8c5429506" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.77.0.tgz#c5d00fe547b89ccde59cdea59143bf145cee3144"
integrity sha512-LqjQprWXjUFRmzIlUjyA+KL+38elgIYmAeoDrdyNVh8MK5IC1W2Lh1Q87b4yOiZeMiIhIVNBd7Ecoh2rodGrGA== integrity sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==
"@sentry/utils@7.64.0": "@sentry/utils@7.77.0":
version "7.64.0" version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.64.0.tgz#6fe3ce9a56d3433ed32119f914907361a54cc184" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.77.0.tgz#1f88501f0b8777de31b371cf859d13c82ebe1379"
integrity sha512-HRlM1INzK66Gt+F4vCItiwGKAng4gqzCR4C5marsL3qv6SrKH98dQnCGYgXluSWaaa56h97FRQu7TxCk6jkSvQ== integrity sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==
dependencies: dependencies:
"@sentry/types" "7.64.0" "@sentry/types" "7.77.0"
tslib "^2.4.1 || ^1.9.3"
"@sentry/vercel-edge@7.77.0":
version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-7.77.0.tgz#6a90a869878e4e78803c4331c30aea841fcc6a73"
integrity sha512-ffddPCgxVeAccPYuH5sooZeHBqDuJ9OIhIRYKoDi4TvmwAzWo58zzZWhRpkHqHgIQdQvhLVZ5F+FSQVWnYSOkw==
dependencies:
"@sentry/core" "7.77.0"
"@sentry/types" "7.77.0"
"@sentry/utils" "7.77.0"
"@sentry/webpack-plugin@1.20.0": "@sentry/webpack-plugin@1.20.0":
version "1.20.0" version "1.20.0"
@@ -860,11 +863,6 @@ console.table@0.10.0:
dependencies: dependencies:
easy-table "1.1.0" easy-table "1.1.0"
cookie@^0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
cookiejar@^2.1.4: cookiejar@^2.1.4:
version "2.1.4" version "2.1.4"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
@@ -1096,6 +1094,11 @@ function-bind@^1.1.1:
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-browser-rtc@^1.1.0: get-browser-rtc@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz" resolved "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz"
@@ -1185,6 +1188,13 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
hasown@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
dependencies:
function-bind "^1.1.2"
hast-util-to-jsx-runtime@^2.0.0: hast-util-to-jsx-runtime@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.2.0.tgz#ffd59bfcf0eb8321c6ed511bfc4b399ac3404bc2" resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.2.0.tgz#ffd59bfcf0eb8321c6ed511bfc4b399ac3404bc2"
@@ -1307,12 +1317,12 @@ is-binary-path@~2.1.0:
dependencies: dependencies:
binary-extensions "^2.0.0" binary-extensions "^2.0.0"
is-core-module@^2.11.0: is-core-module@^2.13.0:
version "2.12.1" version "2.13.1"
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
dependencies: dependencies:
has "^1.0.3" hasown "^2.0.0"
is-extglob@^2.1.1: is-extglob@^2.1.1:
version "2.1.1" version "2.1.1"
@@ -1465,11 +1475,6 @@ lru-cache@^6.0.0:
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
lru_map@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==
magic-string@^0.27.0: magic-string@^0.27.0:
version "0.27.0" version "0.27.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
@@ -2171,12 +2176,12 @@ require-directory@^2.1.1:
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
resolve@^1.1.7, resolve@^1.22.2: resolve@1.22.8, resolve@^1.1.7, resolve@^1.22.2:
version "1.22.2" version "1.22.8"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
dependencies: dependencies:
is-core-module "^2.11.0" is-core-module "^2.13.0"
path-parse "^1.0.7" path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0" supports-preserve-symlinks-flag "^1.0.0"
@@ -2512,7 +2517,7 @@ tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.1.0, "tslib@^2.4.1 || ^1.9.3": tslib@^2.1.0:
version "2.6.1" version "2.6.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410"
integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==