diff --git a/server/Dockerfile b/server/Dockerfile index 7a0aa8f7..f977c652 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,5 +25,6 @@ FROM base AS final COPY --from=builder /venv /venv RUN mkdir -p /app COPY reflector /app/reflector +COPY runserver.sh /app/runserver.sh WORKDIR /app -CMD ["/venv/bin/python", "-m", "reflector.app"] +CMD ["./runserver.sh"] diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 00000000..dbb0b3f6 --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/gpu/modal/reflector_transcriber.py b/server/gpu/modal/reflector_transcriber.py index 55df052b..f06706c8 100644 --- a/server/gpu/modal/reflector_transcriber.py +++ b/server/gpu/modal/reflector_transcriber.py @@ -129,6 +129,7 @@ class Whisper: translation = result[0].strip() multilingual_transcript[target_language] = translation + return { "text": multilingual_transcript, "words": words @@ -149,7 +150,7 @@ class Whisper: ) @asgi_app() def web(): - from fastapi import Depends, FastAPI, Form, HTTPException, UploadFile, status + from fastapi import Body, Depends, FastAPI, HTTPException, UploadFile, status from fastapi.security import OAuth2PasswordBearer from typing_extensions import Annotated @@ -174,9 +175,9 @@ def web(): @app.post("/transcribe", dependencies=[Depends(apikey_auth)]) async def transcribe( file: UploadFile, - timestamp: Annotated[float, Form()] = 0, - source_language: Annotated[str, Form()] = "en", - target_language: Annotated[str, Form()] = "en" + source_language: Annotated[str, Body(...)] = "en", + target_language: Annotated[str, Body(...)] = "en", + timestamp: Annotated[float, Body()] = 0.0 ) -> TranscriptResponse: audio_data = await file.read() audio_suffix = file.filename.split(".")[-1] diff --git a/server/migrations/README b/server/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/server/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/migrations/env.py b/server/migrations/env.py new file mode 100644 index 00000000..3c893c01 --- /dev/null +++ b/server/migrations/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig +from reflector.settings import settings +from reflector.db import metadata + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + # url = config.get_main_option("sqlalchemy.url") + url = settings.DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + alembic_config = config.get_section(config.config_ini_section, {}) + alembic_config["sqlalchemy.url"] = settings.DATABASE_URL + connectable = engine_from_config( + alembic_config, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/migrations/script.py.mako b/server/migrations/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/server/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/server/migrations/versions/543ed284d69a_init.py b/server/migrations/versions/543ed284d69a_init.py new file mode 100644 index 00000000..393f8357 --- /dev/null +++ b/server/migrations/versions/543ed284d69a_init.py @@ -0,0 +1,30 @@ +"""init + +Revision ID: 543ed284d69a +Revises: +Create Date: 2023-08-29 10:54:45.142974 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '543ed284d69a' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/server/migrations/versions/b3df9681cae9_add_source_and_target_language.py b/server/migrations/versions/b3df9681cae9_add_source_and_target_language.py new file mode 100644 index 00000000..ed8a85b2 --- /dev/null +++ b/server/migrations/versions/b3df9681cae9_add_source_and_target_language.py @@ -0,0 +1,32 @@ +"""add source and target language + +Revision ID: b3df9681cae9 +Revises: 543ed284d69a +Create Date: 2023-08-29 10:55:37.690469 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3df9681cae9' +down_revision: Union[str, None] = '543ed284d69a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('transcript', sa.Column('source_language', sa.String(), nullable=True)) + op.add_column('transcript', sa.Column('target_language', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('transcript', 'target_language') + op.drop_column('transcript', 'source_language') + # ### end Alembic commands ### diff --git a/server/poetry.lock b/server/poetry.lock index b3b122f4..868ce4fc 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -289,6 +289,25 @@ files = [ dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"] docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"] +[[package]] +name = "alembic" +version = "1.11.3" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "alembic-1.11.3-py3-none-any.whl", hash = "sha256:d6c96c2482740592777c400550a523bc7a9aada4e210cae2e733354ddae6f6f8"}, + {file = "alembic-1.11.3.tar.gz", hash = "sha256:3db4ce81a9072e1b5aa44c2d202add24553182672a12daf21608d6f62a8f9cf9"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["python-dateutil"] + [[package]] name = "annotated-types" version = "0.5.0" @@ -1726,6 +1745,84 @@ files = [ {file = "makefun-1.15.1.tar.gz", hash = "sha256:40b0f118b6ded0d8d78c78f1eb679b8b6b2462e3c1b3e05fb1b2da8cd46b48a5"}, ] +[[package]] +name = "mako" +version = "1.2.4" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, + {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -3298,4 +3395,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d84edfea8ac7a849340af8eb5db47df9c13a7cc1c640062ebedb2a808be0de4e" +content-hash = "912184d26e5c8916fad9e969697ead9043a8688559063dfca4674ffcd5e2230d" diff --git a/server/pyproject.toml b/server/pyproject.toml index 895be79d..c0538789 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -26,6 +26,7 @@ fastapi-pagination = "^0.12.6" databases = {extras = ["aiosqlite", "asyncpg"], version = "^0.7.0"} sqlalchemy = "<1.5" fief-client = {extras = ["fastapi"], version = "^0.17.0"} +alembic = "^1.11.3" [tool.poetry.group.dev.dependencies] diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index 3864b13a..2ac68029 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -1,9 +1,8 @@ import databases import sqlalchemy -from reflector.events import subscribers_startup, subscribers_shutdown +from reflector.events import subscribers_shutdown, subscribers_startup from reflector.settings import settings - database = databases.Database(settings.DATABASE_URL) metadata = sqlalchemy.MetaData() @@ -20,6 +19,8 @@ transcripts = sqlalchemy.Table( sqlalchemy.Column("summary", sqlalchemy.String, nullable=True), sqlalchemy.Column("topics", sqlalchemy.JSON), sqlalchemy.Column("events", sqlalchemy.JSON), + sqlalchemy.Column("source_language", sqlalchemy.String, nullable=True), + sqlalchemy.Column("target_language", sqlalchemy.String, nullable=True), # with user attached, optional sqlalchemy.Column("user_id", sqlalchemy.String), ) diff --git a/server/reflector/processors/audio_transcript_auto.py b/server/reflector/processors/audio_transcript_auto.py index fdae7663..3bc10102 100644 --- a/server/reflector/processors/audio_transcript_auto.py +++ b/server/reflector/processors/audio_transcript_auto.py @@ -1,8 +1,9 @@ -from reflector.processors.base import Processor +import importlib + from reflector.processors.audio_transcript import AudioTranscriptProcessor +from reflector.processors.base import Pipeline, Processor from reflector.processors.types import AudioFile from reflector.settings import settings -import importlib class AudioTranscriptAutoProcessor(AudioTranscriptProcessor): @@ -35,6 +36,10 @@ class AudioTranscriptAutoProcessor(AudioTranscriptProcessor): self.processor = self.get_instance(settings.TRANSCRIPT_BACKEND) super().__init__(**kwargs) + def set_pipeline(self, pipeline: Pipeline): + super().set_pipeline(pipeline) + self.processor.set_pipeline(pipeline) + def connect(self, processor: Processor): self.processor.connect(processor) diff --git a/server/reflector/processors/audio_transcript_modal.py b/server/reflector/processors/audio_transcript_modal.py index 80b6e582..2ecdc2ec 100644 --- a/server/reflector/processors/audio_transcript_modal.py +++ b/server/reflector/processors/audio_transcript_modal.py @@ -15,7 +15,6 @@ API will be a POST request to TRANSCRIPT_URL: from time import monotonic import httpx - from reflector.processors.audio_transcript import AudioTranscriptProcessor from reflector.processors.audio_transcript_auto import AudioTranscriptAutoProcessor from reflector.processors.types import AudioFile, Transcript, TranslationLanguages, Word @@ -54,11 +53,10 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor): "file": (data.name, data.fd), } - # TODO: Get the source / target language from the UI preferences dynamically - # Update code here once this is possible. - # i.e) extract from context/session objects - source_language = "en" - target_language = "en" + # FIXME this should be a processor after, as each user may want + # different languages + source_language = self.get_pref("audio:source_language", "en") + target_language = self.get_pref("audio:target_language", "en") languages = TranslationLanguages() # Only way to set the target should be the UI element like dropdown. @@ -74,7 +72,7 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor): files=files, timeout=self.timeout, headers=self.headers, - json=json_payload, + params=json_payload, ) self.logger.debug( @@ -84,12 +82,14 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor): result = response.json() # Sanity check for translation status in the result - if target_language in result["text"]: - text = result["text"][target_language] - else: - text = result["text"][source_language] + translation = None + if source_language != target_language and target_language in result["text"]: + translation = result["text"][target_language] + text = result["text"][source_language] + transcript = Transcript( text=text, + translation=translation, words=[ Word( text=word["text"], diff --git a/server/reflector/processors/base.py b/server/reflector/processors/base.py index 4a7f2bc2..35e836bc 100644 --- a/server/reflector/processors/base.py +++ b/server/reflector/processors/base.py @@ -1,7 +1,9 @@ -from reflector.logger import logger -from uuid import uuid4 -from concurrent.futures import ThreadPoolExecutor import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from uuid import uuid4 + +from reflector.logger import logger class Processor: @@ -17,9 +19,11 @@ class Processor: self.uid = uuid4().hex self.flushed = False self.logger = (custom_logger or logger).bind(processor=self.__class__.__name__) + self.pipeline = None def set_pipeline(self, pipeline: "Pipeline"): # if pipeline is used, pipeline logger will be used instead + self.pipeline = pipeline self.logger = pipeline.logger.bind(processor=self.__class__.__name__) def connect(self, processor: "Processor"): @@ -54,6 +58,14 @@ class Processor: """ self._callbacks.remove(callback) + def get_pref(self, key: str, default: Any = None): + """ + Get a preference from the pipeline prefs + """ + if self.pipeline: + return self.pipeline.get_pref(key, default) + return default + async def emit(self, data): for callback in self._callbacks: await callback(data) @@ -191,6 +203,7 @@ class Pipeline(Processor): self.logger.info("Pipeline created") self.processors = processors + self.prefs = {} for processor in processors: processor.set_pipeline(self) @@ -220,3 +233,17 @@ class Pipeline(Processor): for processor in self.processors: processor.describe(level + 1) logger.info("") + + def set_pref(self, key: str, value: Any): + """ + Set a preference for this pipeline + """ + self.prefs[key] = value + + def get_pref(self, key: str, default=None): + """ + Get a preference for this pipeline + """ + if key not in self.prefs: + self.logger.warning(f"Pref {key} not found, using default") + return self.prefs.get(key, default) diff --git a/server/reflector/processors/transcript_liner.py b/server/reflector/processors/transcript_liner.py index cca5e6a2..c7ec2f64 100644 --- a/server/reflector/processors/transcript_liner.py +++ b/server/reflector/processors/transcript_liner.py @@ -27,7 +27,7 @@ class TranscriptLinerProcessor(Processor): return # cut to the next . - partial = Transcript(words=[]) + partial = Transcript(translation=self.transcript.translation, words=[]) for word in self.transcript.words[:]: partial.text += word.text partial.words.append(word) @@ -38,7 +38,7 @@ class TranscriptLinerProcessor(Processor): await self.emit(partial) # create new transcript - partial = Transcript(words=[]) + partial = Transcript(translation=self.transcript.translation, words=[]) self.transcript = partial diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py index 1e5c84f2..8aab2a0d 100644 --- a/server/reflector/processors/types.py +++ b/server/reflector/processors/types.py @@ -47,6 +47,7 @@ class Word(BaseModel): class Transcript(BaseModel): text: str = "" + translation: str | None = None words: list[Word] = None @property @@ -84,7 +85,7 @@ class Transcript(BaseModel): words = [ Word(text=word.text, start=word.start, end=word.end) for word in self.words ] - return Transcript(text=self.text, words=words) + return Transcript(text=self.text, translation=self.translation, words=words) class TitleSummary(BaseModel): diff --git a/server/reflector/views/rtc_offer.py b/server/reflector/views/rtc_offer.py index f28eb021..90f44434 100644 --- a/server/reflector/views/rtc_offer.py +++ b/server/reflector/views/rtc_offer.py @@ -1,25 +1,26 @@ import asyncio -from fastapi import Request, APIRouter -from reflector.events import subscribers_shutdown -from pydantic import BaseModel -from reflector.logger import logger -from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack -from json import loads, dumps from enum import StrEnum +from json import dumps, loads from pathlib import Path + import av +from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription +from fastapi import APIRouter, Request +from pydantic import BaseModel +from reflector.events import subscribers_shutdown +from reflector.logger import logger from reflector.processors import ( - Pipeline, AudioChunkerProcessor, + AudioFileWriterProcessor, AudioMergeProcessor, AudioTranscriptAutoProcessor, - AudioFileWriterProcessor, + FinalSummary, + Pipeline, + TitleSummary, + Transcript, + TranscriptFinalSummaryProcessor, TranscriptLinerProcessor, TranscriptTopicDetectorProcessor, - TranscriptFinalSummaryProcessor, - Transcript, - TitleSummary, - FinalSummary, ) sessions = [] @@ -79,6 +80,8 @@ async def rtc_offer_base( event_callback=None, event_callback_args=None, audio_filename: Path | None = None, + source_language: str = "en", + target_language: str = "en", ): # build an rtc session offer = RTCSessionDescription(sdp=params.sdp, type=params.type) @@ -176,6 +179,8 @@ async def rtc_offer_base( TranscriptFinalSummaryProcessor.as_threaded(callback=on_final_summary), ] ctx.pipeline = Pipeline(*processors) + ctx.pipeline.set_pref("audio:source_language", source_language) + ctx.pipeline.set_pref("audio:target_language", target_language) # FIXME: warmup is not working well yet # await ctx.pipeline.warmup() diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index c92079a6..5aed7141 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -49,6 +49,7 @@ class AudioWaveform(BaseModel): class TranscriptText(BaseModel): text: str + translation: str | None class TranscriptTopic(BaseModel): @@ -79,6 +80,8 @@ class Transcript(BaseModel): summary: str | None = None topics: list[TranscriptTopic] = [] events: list[TranscriptEvent] = [] + source_language: str = "en" + target_language: str = "en" def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: ev = TranscriptEvent(event=event, data=data.model_dump()) @@ -184,8 +187,19 @@ class TranscriptController: return None return Transcript(**result) - async def add(self, name: str, user_id: str | None = None): - transcript = Transcript(name=name, user_id=user_id) + async def add( + self, + name: str, + source_language: str = "en", + target_language: str = "en", + user_id: str | None = None, + ): + transcript = Transcript( + name=name, + source_language=source_language, + target_language=target_language, + user_id=user_id, + ) query = transcripts.insert().values(**transcript.model_dump()) await database.execute(query) return transcript @@ -229,10 +243,14 @@ class GetTranscript(BaseModel): duration: int summary: str | None created_at: datetime + source_language: str + target_language: str class CreateTranscript(BaseModel): name: str + source_language: str = Field("en") + target_language: str = Field("en") class UpdateTranscript(BaseModel): @@ -241,10 +259,6 @@ class UpdateTranscript(BaseModel): summary: Optional[str] = Field(None) -class TranscriptEntryCreate(BaseModel): - name: str - - class DeletionStatus(BaseModel): status: str @@ -266,7 +280,12 @@ async def transcripts_create( user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): user_id = user["sub"] if user else None - return await transcripts_controller.add(info.name, user_id=user_id) + return await transcripts_controller.add( + info.name, + source_language=info.source_language, + target_language=info.target_language, + user_id=user_id, + ) # ============================================================== @@ -491,7 +510,10 @@ async def handle_rtc_event(event: PipelineEvent, args, data): # FIXME don't do copy if event == PipelineEvent.TRANSCRIPT: - resp = transcript.add_event(event=event, data=TranscriptText(text=data.text)) + resp = transcript.add_event( + event=event, + data=TranscriptText(text=data.text, translation=data.translation), + ) await transcripts_controller.update( transcript, { @@ -568,4 +590,6 @@ async def transcript_record_webrtc( event_callback=handle_rtc_event, event_callback_args=transcript_id, audio_filename=transcript.audio_filename, + source_language=transcript.source_language, + target_language=transcript.target_language, ) diff --git a/server/runserver.sh b/server/runserver.sh new file mode 100755 index 00000000..d41d383a --- /dev/null +++ b/server/runserver.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -f "venv/bin/activate" ]; then + source venv/bin/activate +fi +alembic upgrade head +python -m reflector.app diff --git a/server/tests/test_transcripts_rtc_ws.py b/server/tests/test_transcripts_rtc_ws.py index 8237d4ab..c6adf320 100644 --- a/server/tests/test_transcripts_rtc_ws.py +++ b/server/tests/test_transcripts_rtc_ws.py @@ -39,8 +39,20 @@ async def dummy_transcript(): class TestAudioTranscriptProcessor(AudioTranscriptProcessor): async def _transcript(self, data: AudioFile): + source_language = self.get_pref("audio:source_language", "en") + target_language = self.get_pref("audio:target_language", "en") + print("transcripting", source_language, target_language) + print("pipeline", self.pipeline) + print("prefs", self.pipeline.prefs) + + translation = None + if source_language != target_language: + if target_language == "fr": + translation = "Bonjour le monde" + return Transcript( text="Hello world", + translation=translation, words=[ Word(start=0.0, end=1.0, text="Hello"), Word(start=1.0, end=2.0, text="world"), @@ -165,6 +177,147 @@ async def test_transcript_rtc_and_websocket(tmpdir, dummy_transcript, dummy_llm) assert "TRANSCRIPT" in eventnames ev = events[eventnames.index("TRANSCRIPT")] assert ev["data"]["text"] == "Hello world" + assert ev["data"]["translation"] is None + + assert "TOPIC" in eventnames + ev = events[eventnames.index("TOPIC")] + assert ev["data"]["id"] + assert ev["data"]["summary"] == "LLM SUMMARY" + assert ev["data"]["transcript"].startswith("Hello world") + assert ev["data"]["timestamp"] == 0.0 + + assert "FINAL_SUMMARY" in eventnames + ev = events[eventnames.index("FINAL_SUMMARY")] + assert ev["data"]["summary"] == "LLM SUMMARY" + + # check status order + statuses = [e["data"]["value"] for e in events if e["event"] == "STATUS"] + assert statuses == ["recording", "processing", "ended"] + + # ensure the last event received is ended + assert events[-1]["event"] == "STATUS" + assert events[-1]["data"]["value"] == "ended" + + # check that transcript status in model is updated + resp = await ac.get(f"/transcripts/{tid}") + assert resp.status_code == 200 + assert resp.json()["status"] == "ended" + + # check that audio is available + resp = await ac.get(f"/transcripts/{tid}/audio") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "audio/wav" + + # check that audio/mp3 is available + resp = await ac.get(f"/transcripts/{tid}/audio/mp3") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "audio/mp3" + + # stop server + server.stop() + + +@pytest.mark.asyncio +async def test_transcript_rtc_and_websocket_and_fr(tmpdir, dummy_transcript, dummy_llm): + # goal: start the server, exchange RTC, receive websocket events + # because of that, we need to start the server in a thread + # to be able to connect with aiortc + # with target french language + + from reflector.settings import settings + from reflector.app import app + + settings.DATA_DIR = Path(tmpdir) + + # start server + host = "127.0.0.1" + port = 1255 + base_url = f"http://{host}:{port}/v1" + config = Config(app=app, host=host, port=port) + server = ThreadedUvicorn(config) + await server.start() + + # create a transcript + ac = AsyncClient(base_url=base_url) + response = await ac.post( + "/transcripts", json={"name": "Test RTC", "target_language": "fr"} + ) + assert response.status_code == 200 + tid = response.json()["id"] + + # create a websocket connection as a task + events = [] + + async def websocket_task(): + print("Test websocket: TASK STARTED") + async with aconnect_ws(f"{base_url}/transcripts/{tid}/events") as ws: + print("Test websocket: CONNECTED") + try: + while True: + msg = await ws.receive_json() + print(f"Test websocket: JSON {msg}") + if msg is None: + break + events.append(msg) + except Exception as e: + print(f"Test websocket: EXCEPTION {e}") + finally: + ws.close() + print("Test websocket: DISCONNECTED") + + websocket_task = asyncio.get_event_loop().create_task(websocket_task()) + + # create stream client + import argparse + from reflector.stream_client import StreamClient + from aiortc.contrib.signaling import add_signaling_arguments, create_signaling + + parser = argparse.ArgumentParser() + add_signaling_arguments(parser) + args = parser.parse_args(["-s", "tcp-socket"]) + signaling = create_signaling(args) + + url = f"{base_url}/transcripts/{tid}/record/webrtc" + path = Path(__file__).parent / "records" / "test_short.wav" + client = StreamClient(signaling, url=url, play_from=path.as_posix()) + await client.start() + + timeout = 20 + while not client.is_ended(): + await asyncio.sleep(1) + timeout -= 1 + if timeout < 0: + raise TimeoutError("Timeout while waiting for RTC to end") + + # XXX aiortc is long to close the connection + # instead of waiting a long time, we just send a STOP + client.channel.send(json.dumps({"cmd": "STOP"})) + + # wait the processing to finish + await asyncio.sleep(2) + + await client.stop() + + # wait the processing to finish + await asyncio.sleep(2) + + # stop websocket task + websocket_task.cancel() + + # check events + assert len(events) > 0 + from pprint import pprint + + pprint(events) + + # get events list + eventnames = [e["event"] for e in events] + + # check events + assert "TRANSCRIPT" in eventnames + ev = events[eventnames.index("TRANSCRIPT")] + assert ev["data"]["text"] == "Hello world" + assert ev["data"]["translation"] == "Bonjour le monde" assert "TOPIC" in eventnames ev = events[eventnames.index("TOPIC")] @@ -186,19 +339,4 @@ async def test_transcript_rtc_and_websocket(tmpdir, dummy_transcript, dummy_llm) assert events[-1]["data"]["value"] == "ended" # stop server - # server.stop() - - # check that transcript status in model is updated - resp = await ac.get(f"/transcripts/{tid}") - assert resp.status_code == 200 - assert resp.json()["status"] == "ended" - - # check that audio is available - resp = await ac.get(f"/transcripts/{tid}/audio") - assert resp.status_code == 200 - assert resp.headers["Content-Type"] == "audio/wav" - - # check that audio/mp3 is available - resp = await ac.get(f"/transcripts/{tid}/audio/mp3") - assert resp.status_code == 200 - assert resp.headers["Content-Type"] == "audio/mp3" + server.stop() diff --git a/server/tests/test_transcripts_translation.py b/server/tests/test_transcripts_translation.py new file mode 100644 index 00000000..adae55e9 --- /dev/null +++ b/server/tests/test_transcripts_translation.py @@ -0,0 +1,63 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_transcript_create_default_translation(): + from reflector.app import app + + async with AsyncClient(app=app, base_url="http://test/v1") as ac: + response = await ac.post("/transcripts", json={"name": "test en"}) + assert response.status_code == 200 + assert response.json()["name"] == "test en" + assert response.json()["source_language"] == "en" + assert response.json()["target_language"] == "en" + tid = response.json()["id"] + + response = await ac.get(f"/transcripts/{tid}") + assert response.status_code == 200 + assert response.json()["name"] == "test en" + assert response.json()["source_language"] == "en" + assert response.json()["target_language"] == "en" + + +@pytest.mark.asyncio +async def test_transcript_create_en_fr_translation(): + from reflector.app import app + + async with AsyncClient(app=app, base_url="http://test/v1") as ac: + response = await ac.post( + "/transcripts", json={"name": "test en/fr", "target_language": "fr"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "test en/fr" + assert response.json()["source_language"] == "en" + assert response.json()["target_language"] == "fr" + tid = response.json()["id"] + + response = await ac.get(f"/transcripts/{tid}") + assert response.status_code == 200 + assert response.json()["name"] == "test en/fr" + assert response.json()["source_language"] == "en" + assert response.json()["target_language"] == "fr" + + +@pytest.mark.asyncio +async def test_transcript_create_fr_en_translation(): + from reflector.app import app + + async with AsyncClient(app=app, base_url="http://test/v1") as ac: + response = await ac.post( + "/transcripts", json={"name": "test fr/en", "source_language": "fr"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "test fr/en" + assert response.json()["source_language"] == "fr" + assert response.json()["target_language"] == "en" + tid = response.json()["id"] + + response = await ac.get(f"/transcripts/{tid}") + assert response.status_code == 200 + assert response.json()["name"] == "test fr/en" + assert response.json()["source_language"] == "fr" + assert response.json()["target_language"] == "en" diff --git a/www/app/api/.openapi-generator/FILES b/www/app/api/.openapi-generator/FILES index 91888ece..16763a8d 100644 --- a/www/app/api/.openapi-generator/FILES +++ b/www/app/api/.openapi-generator/FILES @@ -1,6 +1,7 @@ apis/DefaultApi.ts apis/index.ts index.ts +models/AudioWaveform.ts models/CreateTranscript.ts models/DeletionStatus.ts models/GetTranscript.ts diff --git a/www/app/api/apis/DefaultApi.ts b/www/app/api/apis/DefaultApi.ts index 7fdc25da..33aee151 100644 --- a/www/app/api/apis/DefaultApi.ts +++ b/www/app/api/apis/DefaultApi.ts @@ -14,6 +14,7 @@ import * as runtime from "../runtime"; import type { + AudioWaveform, CreateTranscript, DeletionStatus, GetTranscript, @@ -23,6 +24,8 @@ import type { UpdateTranscript, } from "../models"; import { + AudioWaveformFromJSON, + AudioWaveformToJSON, CreateTranscriptFromJSON, CreateTranscriptToJSON, DeletionStatusFromJSON, @@ -59,6 +62,10 @@ export interface V1TranscriptGetAudioMp3Request { transcriptId: any; } +export interface V1TranscriptGetAudioWaveformRequest { + transcriptId: any; +} + export interface V1TranscriptGetTopicsRequest { transcriptId: any; } @@ -390,6 +397,67 @@ export class DefaultApi extends runtime.BaseAPI { return await response.value(); } + /** + * Transcript Get Audio Waveform + */ + async v1TranscriptGetAudioWaveformRaw( + requestParameters: V1TranscriptGetAudioWaveformRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + if ( + requestParameters.transcriptId === null || + requestParameters.transcriptId === undefined + ) { + throw new runtime.RequiredError( + "transcriptId", + "Required parameter requestParameters.transcriptId was null or undefined when calling v1TranscriptGetAudioWaveform.", + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + // oauth required + headerParameters["Authorization"] = await this.configuration.accessToken( + "OAuth2AuthorizationCodeBearer", + [], + ); + } + + const response = await this.request( + { + path: `/v1/transcripts/{transcript_id}/audio/waveform`.replace( + `{${"transcript_id"}}`, + encodeURIComponent(String(requestParameters.transcriptId)), + ), + method: "GET", + headers: headerParameters, + query: queryParameters, + }, + initOverrides, + ); + + return new runtime.JSONApiResponse(response, (jsonValue) => + AudioWaveformFromJSON(jsonValue), + ); + } + + /** + * Transcript Get Audio Waveform + */ + async v1TranscriptGetAudioWaveform( + requestParameters: V1TranscriptGetAudioWaveformRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.v1TranscriptGetAudioWaveformRaw( + requestParameters, + initOverrides, + ); + return await response.value(); + } + /** * Transcript Get Topics */ diff --git a/www/app/api/models/AudioWaveform.ts b/www/app/api/models/AudioWaveform.ts new file mode 100644 index 00000000..0cc5da1f --- /dev/null +++ b/www/app/api/models/AudioWaveform.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * FastAPI + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from "../runtime"; +/** + * + * @export + * @interface AudioWaveform + */ +export interface AudioWaveform { + /** + * + * @type {any} + * @memberof AudioWaveform + */ + data: any | null; +} + +/** + * Check if a given object implements the AudioWaveform interface. + */ +export function instanceOfAudioWaveform(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "data" in value; + + return isInstance; +} + +export function AudioWaveformFromJSON(json: any): AudioWaveform { + return AudioWaveformFromJSONTyped(json, false); +} + +export function AudioWaveformFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): AudioWaveform { + if (json === undefined || json === null) { + return json; + } + return { + data: json["data"], + }; +} + +export function AudioWaveformToJSON(value?: AudioWaveform | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + data: value.data, + }; +} diff --git a/www/app/api/models/CreateTranscript.ts b/www/app/api/models/CreateTranscript.ts index 6e49a9c6..365e5761 100644 --- a/www/app/api/models/CreateTranscript.ts +++ b/www/app/api/models/CreateTranscript.ts @@ -25,6 +25,18 @@ export interface CreateTranscript { * @memberof CreateTranscript */ name: any | null; + /** + * + * @type {any} + * @memberof CreateTranscript + */ + sourceLanguage?: any | null; + /** + * + * @type {any} + * @memberof CreateTranscript + */ + targetLanguage?: any | null; } /** @@ -50,6 +62,12 @@ export function CreateTranscriptFromJSONTyped( } return { name: json["name"], + sourceLanguage: !exists(json, "source_language") + ? undefined + : json["source_language"], + targetLanguage: !exists(json, "target_language") + ? undefined + : json["target_language"], }; } @@ -62,5 +80,7 @@ export function CreateTranscriptToJSON(value?: CreateTranscript | null): any { } return { name: value.name, + source_language: value.sourceLanguage, + target_language: value.targetLanguage, }; } diff --git a/www/app/api/models/GetTranscript.ts b/www/app/api/models/GetTranscript.ts index 5f242956..4020617f 100644 --- a/www/app/api/models/GetTranscript.ts +++ b/www/app/api/models/GetTranscript.ts @@ -61,6 +61,18 @@ export interface GetTranscript { * @memberof GetTranscript */ createdAt: any | null; + /** + * + * @type {any} + * @memberof GetTranscript + */ + sourceLanguage: any | null; + /** + * + * @type {any} + * @memberof GetTranscript + */ + targetLanguage: any | null; } /** @@ -75,6 +87,8 @@ export function instanceOfGetTranscript(value: object): boolean { isInstance = isInstance && "duration" in value; isInstance = isInstance && "summary" in value; isInstance = isInstance && "createdAt" in value; + isInstance = isInstance && "sourceLanguage" in value; + isInstance = isInstance && "targetLanguage" in value; return isInstance; } @@ -98,6 +112,8 @@ export function GetTranscriptFromJSONTyped( duration: json["duration"], summary: json["summary"], createdAt: json["created_at"], + sourceLanguage: json["source_language"], + targetLanguage: json["target_language"], }; } @@ -116,5 +132,7 @@ export function GetTranscriptToJSON(value?: GetTranscript | null): any { duration: value.duration, summary: value.summary, created_at: value.createdAt, + source_language: value.sourceLanguage, + target_language: value.targetLanguage, }; } diff --git a/www/app/api/models/index.ts b/www/app/api/models/index.ts index d872c782..99874641 100644 --- a/www/app/api/models/index.ts +++ b/www/app/api/models/index.ts @@ -1,5 +1,6 @@ /* tslint:disable */ /* eslint-disable */ +export * from "./AudioWaveform"; export * from "./CreateTranscript"; export * from "./DeletionStatus"; export * from "./GetTranscript"; diff --git a/www/app/styles/globals.scss b/www/app/styles/globals.scss index c24395c8..90f54fe5 100644 --- a/www/app/styles/globals.scss +++ b/www/app/styles/globals.scss @@ -15,8 +15,15 @@ body { } .temp-transcription { - background: beige; + background: rgb(151 190 255); border-radius: 5px; + border: solid 1px #808080; + margin: 1em 0; +} + +.temp-transcription h2 { + font-weight: bold; + font-size: 130%; } .Dropdown-placeholder { @@ -25,3 +32,9 @@ body { .Dropdown-arrow { top: 47% !important; } + +@media (max-width: 768px) { + .audio-source-dropdown .Dropdown-control { + max-width: 90px; + } +} diff --git a/www/app/transcripts/dashboard.tsx b/www/app/transcripts/dashboard.tsx index 1f0c28e1..b74082d0 100644 --- a/www/app/transcripts/dashboard.tsx +++ b/www/app/transcripts/dashboard.tsx @@ -56,14 +56,10 @@ export function Dashboard({ return ( <> -
+

Meeting Notes

-
-
Timestamp
-
Topic
-
-
{formatTime(item.timestamp)}
-
- {item.title} +
+ + [{formatTime(item.timestamp)}] + {" "} + {item.title} +

Final Summary

{props.text}

diff --git a/www/app/transcripts/recorder.tsx b/www/app/transcripts/recorder.tsx index 6467494f..eae79b18 100644 --- a/www/app/transcripts/recorder.tsx +++ b/www/app/transcripts/recorder.tsx @@ -235,7 +235,7 @@ export default function Recorder(props: RecorderProps) { return (
-
+
{ const requestParameters: V1TranscriptsCreateRequest = { createTranscript: { name: "Weekly All-Hands", // Hardcoded for now + targetLanguage: "fr", // Hardcoded for now }, }; diff --git a/www/app/transcripts/useWebSockets.ts b/www/app/transcripts/useWebSockets.ts index 80d4f560..a6a7a387 100644 --- a/www/app/transcripts/useWebSockets.ts +++ b/www/app/transcripts/useWebSockets.ts @@ -17,6 +17,54 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { const [status, setStatus] = useState({ value: "disconnected" }); useEffect(() => { + document.onkeyup = (e) => { + if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") { + setTranscriptText("Lorem Ipsum"); + setTopics([ + { + id: "1", + timestamp: 10, + summary: "This is test topic 1", + title: "Topic 1: Introduction to Quantum Mechanics", + transcript: + "A brief overview of quantum mechanics and its principles.", + }, + { + id: "2", + timestamp: 20, + summary: "This is test topic 2", + title: "Topic 2: Machine Learning Algorithms", + transcript: + "Understanding the different types of machine learning algorithms.", + }, + { + id: "3", + timestamp: 30, + summary: "This is test topic 3", + title: "Topic 3: Mental Health Awareness", + transcript: "Ways to improve mental health and reduce stigma.", + }, + { + id: "4", + timestamp: 40, + summary: "This is test topic 4", + title: "Topic 4: Basics of Productivity", + transcript: "Tips and tricks to increase daily productivity.", + }, + { + id: "5", + timestamp: 50, + summary: "This is test topic 5", + title: "Topic 5: Future of Aviation", + transcript: + "Exploring the advancements and possibilities in aviation.", + }, + ]); + + setFinalSummary({ summary: "This is the final summary" }); + } + }; + if (!transcriptId) return; const url = `${process.env.NEXT_PUBLIC_WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`; @@ -32,7 +80,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { switch (message.event) { case "TRANSCRIPT": if (message.data.text) { - setTranscriptText(message.data.text.trim()); + setTranscriptText( + (message.data.translation ?? message.data.text ?? "").trim(), + ); console.debug("TRANSCRIPT event:", message.data); } break;