From ac1509c2151f57653cfd1b85e125ee3d05353e94 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 30 Dec 2024 19:13:35 +0100 Subject: [PATCH] added dockerfile and pyproject.toml --- Dockerfile | 17 +++++ pyproject.toml | 21 ++++++ requirements.txt | 5 -- {transcription => src/annescribe}/__init__.py | 0 main.py => src/annescribe/main.py | 0 .../annescribe/transcription}/__init__.py | 0 .../transcription}/audio_processing.py | 0 .../annescribe/transcription}/job_utils.py | 0 .../transcription}/transcription.py | 0 .../transcription}/transcription_helper.py | 10 +-- utils.py => src/annescribe/utils.py | 0 .../annescribe/webapp/__init__.py | 0 {webapp => src/annescribe/webapp}/home.py | 4 +- src/annescribe/webapp/new_task.py | 71 ++++++++++++++++++ .../annescribe/webapp}/task_list.py | 2 +- .../annescribe/webapp_main.py | 41 ++++++---- .../__pycache__/__init__.cpython-311.pyc | Bin 165 -> 0 bytes .../__pycache__/job_utils.cpython-311.pyc | Bin 8929 -> 0 bytes webapp/__pycache__/__init__.cpython-311.pyc | Bin 158 -> 0 bytes webapp/__pycache__/home.cpython-311.pyc | Bin 1126 -> 0 bytes webapp/__pycache__/task_list.cpython-311.pyc | Bin 5608 -> 0 bytes webapp/login.py | 9 --- webapp/new_task.py | 61 --------------- 23 files changed, 142 insertions(+), 99 deletions(-) create mode 100644 Dockerfile delete mode 100644 requirements.txt rename {transcription => src/annescribe}/__init__.py (100%) rename main.py => src/annescribe/main.py (100%) rename {webapp => src/annescribe/transcription}/__init__.py (100%) rename {transcription => src/annescribe/transcription}/audio_processing.py (100%) rename {transcription => src/annescribe/transcription}/job_utils.py (100%) rename {transcription => src/annescribe/transcription}/transcription.py (100%) rename {transcription => src/annescribe/transcription}/transcription_helper.py (94%) rename utils.py => src/annescribe/utils.py (100%) rename transcription/job_execution.py => src/annescribe/webapp/__init__.py (100%) rename {webapp => src/annescribe/webapp}/home.py (86%) create mode 100644 src/annescribe/webapp/new_task.py rename {webapp => src/annescribe/webapp}/task_list.py (98%) rename webapp_main.py => src/annescribe/webapp_main.py (56%) delete mode 100644 transcription/__pycache__/__init__.cpython-311.pyc delete mode 100644 transcription/__pycache__/job_utils.cpython-311.pyc delete mode 100644 webapp/__pycache__/__init__.cpython-311.pyc delete mode 100644 webapp/__pycache__/home.cpython-311.pyc delete mode 100644 webapp/__pycache__/task_list.cpython-311.pyc delete mode 100644 webapp/login.py delete mode 100644 webapp/new_task.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f80545a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.10.16 + +# set env variables for python +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# set work directory +WORKDIR /app + +COPY pyproject.toml /app/ +COPY config.yml /app/ +COPY src/ /app/src + +RUN pip install --upgrade pip +RUN pip install . + +CMD ["streamlit", "run", "src/annescribe/webapp_main.py"] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e69de29..f35b454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "annescribe" +version = "0.1.0" +authors = [ + { name = "Alex Blank", email = "alexblank@fastmail.com" } +] +description = "A tool for transcribing audio files with whisper in a webapp" +readme = "README.md" +requires-python = "==3.10.16" +dependencies = [ + "torch>=2.3", + "openai-whisper>=20240927", + "streamlit==1.41.1", + "streamlit-authenticator==0.4.1", + "streamlit-autorefresh==1.0.1", + "pydub==0.25.1" +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 182168a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -streamlit -streamlit-authenticator -streamlit-autorefresh -openai-whisper -pydub \ No newline at end of file diff --git a/transcription/__init__.py b/src/annescribe/__init__.py similarity index 100% rename from transcription/__init__.py rename to src/annescribe/__init__.py diff --git a/main.py b/src/annescribe/main.py similarity index 100% rename from main.py rename to src/annescribe/main.py diff --git a/webapp/__init__.py b/src/annescribe/transcription/__init__.py similarity index 100% rename from webapp/__init__.py rename to src/annescribe/transcription/__init__.py diff --git a/transcription/audio_processing.py b/src/annescribe/transcription/audio_processing.py similarity index 100% rename from transcription/audio_processing.py rename to src/annescribe/transcription/audio_processing.py diff --git a/transcription/job_utils.py b/src/annescribe/transcription/job_utils.py similarity index 100% rename from transcription/job_utils.py rename to src/annescribe/transcription/job_utils.py diff --git a/transcription/transcription.py b/src/annescribe/transcription/transcription.py similarity index 100% rename from transcription/transcription.py rename to src/annescribe/transcription/transcription.py diff --git a/transcription/transcription_helper.py b/src/annescribe/transcription/transcription_helper.py similarity index 94% rename from transcription/transcription_helper.py rename to src/annescribe/transcription/transcription_helper.py index 61c1e74..008df80 100644 --- a/transcription/transcription_helper.py +++ b/src/annescribe/transcription/transcription_helper.py @@ -3,11 +3,11 @@ import os from multiprocessing import Lock from concurrent.futures import ProcessPoolExecutor -from utils import get_logger -from transcription import job_utils -from transcription.job_utils import is_job, remove_job, create_job, get_processing, set_processing -from transcription.audio_processing import split_audio, convert_to_wav -from transcription.transcription import transcribe_audio +from annescribe.utils import get_logger +from annescribe.transcription import job_utils +from annescribe.transcription.job_utils import is_job, remove_job, create_job, get_processing, set_processing +from annescribe.transcription.audio_processing import split_audio, convert_to_wav +from annescribe.transcription.transcription import transcribe_audio job_update_lock = Lock() default_input_audio_file_name = 'input_audio' diff --git a/utils.py b/src/annescribe/utils.py similarity index 100% rename from utils.py rename to src/annescribe/utils.py diff --git a/transcription/job_execution.py b/src/annescribe/webapp/__init__.py similarity index 100% rename from transcription/job_execution.py rename to src/annescribe/webapp/__init__.py diff --git a/webapp/home.py b/src/annescribe/webapp/home.py similarity index 86% rename from webapp/home.py rename to src/annescribe/webapp/home.py index 393a567..09d50d8 100644 --- a/webapp/home.py +++ b/src/annescribe/webapp/home.py @@ -2,8 +2,8 @@ from functools import partial import streamlit as st -from webapp.new_task import render_new_task -from webapp.task_list import render_task_list +from annescribe.webapp.new_task import render_new_task +from annescribe.webapp.task_list import render_task_list def render_home(container, diff --git a/src/annescribe/webapp/new_task.py b/src/annescribe/webapp/new_task.py new file mode 100644 index 0000000..a173cdd --- /dev/null +++ b/src/annescribe/webapp/new_task.py @@ -0,0 +1,71 @@ +import os + +import streamlit as st + +from annescribe.utils import get_logger +from annescribe.transcription.job_utils import create_job +from annescribe.transcription.transcription_helper import transcribe + +logger = get_logger(__name__) + + +def create_new_task(file_upload, app_config, overwrite: bool): + """ + Create a new task + :param file_upload: file upload from form + :param app_config: app configuration + :param overwrite: overwrite existing task + :return: None + """ + upload_directory = app_config["data"]["upload_directory"] + + st.write(f"Uploading...") + # create the upload directory if it does not exist + if not os.path.exists(upload_directory): + logger.info(f"Creating upload directory {upload_directory}") + os.makedirs(upload_directory) + + logger.info(f"Saving file {file_upload.name} to {upload_directory}") + file_path = f"{upload_directory}/{file_upload.name}" + with open(file_path, "wb") as file: + file.write(file_upload.getbuffer()) + + st.success(f"File uploaded to {file_path}") + + logger.info(f"Creating new task for file {file_upload}") + + if file_path is None: + st.error("No file uploaded") + return + + transcribe(file_path, app_config, blocking=False, overwrite=overwrite) + + +def render_new_task(container, app_config: dict): + """ + Render the new task page + :param container: container object to place the content in + :param app_config: app configuration + :return: None + """ + + def upload_file(file_path: str): + """ + Upload a file + :param file_path: path to the file + :return: None + """ + + @st.dialog("Upload new audio file") + def show_create_task_dialog(): + st.write("Create a transcription job") + # add checkbox for overwrite + overwrite = st.checkbox("Overwrite existing task", value=False, key="overwrite") + uploaded_file = st.file_uploader("Audio File", type=["wav", "mp3", "flac", "ogg"]) + if st.button("Create Task", key="btn_create_task"): + create_new_task(uploaded_file, app_config, overwrite=overwrite) + st.write("Task created") + st.rerun() + + st.button("Create Task", on_click=show_create_task_dialog, key="btn_open_create_task_dialog") + diff --git a/webapp/task_list.py b/src/annescribe/webapp/task_list.py similarity index 98% rename from webapp/task_list.py rename to src/annescribe/webapp/task_list.py index 89b3e98..df73e18 100644 --- a/webapp/task_list.py +++ b/src/annescribe/webapp/task_list.py @@ -4,7 +4,7 @@ import streamlit as st from streamlit_autorefresh import st_autorefresh -from transcription.job_utils import get_existing_jobs, remove_job +from annescribe.transcription.job_utils import get_existing_jobs, remove_job def show_results(job_data: dict): diff --git a/webapp_main.py b/src/annescribe/webapp_main.py similarity index 56% rename from webapp_main.py rename to src/annescribe/webapp_main.py index 23daec5..122d948 100644 --- a/webapp_main.py +++ b/src/annescribe/webapp_main.py @@ -8,7 +8,10 @@ import streamlit as st import streamlit_authenticator as st_auth from streamlit_autorefresh import st_autorefresh -from webapp.home import render_home +from annescribe.utils import get_logger +from annescribe.webapp.home import render_home + +logger = get_logger(__name__) CONFIG_FILE = os.environ.get('CONFIG_FILE', "config.yml") @@ -23,18 +26,23 @@ with open(CONFIG_FILE, 'r') as config_file: AUTH_FILE = os.environ.get('AUTH_FILE', "auth.yml") if AUTH_FILE is None: raise ValueError('AUTH_YML_FILE environment variable is not set') -elif not os.path.exists(AUTH_FILE): - raise ValueError(f'AUTH_YML_FILE {AUTH_FILE} does not exist') -with open(AUTH_FILE, 'r') as auth_file: - auth_config = yaml.safe_load(auth_file) +if os.path.exists(AUTH_FILE): + with open(AUTH_FILE, 'r') as auth_file: + auth_config = yaml.safe_load(auth_file) + print(logger.info(f'User authentication enabled')) + authentication_enabled = True +else: + logger.info(f"AUTH_FILE {AUTH_FILE} does not exist, user authentication disabled") + authentication_enabled = False -authenticator = st_auth.Authenticate( - auth_config["credentials"], - auth_config["cookie"]["name"], - auth_config["cookie"]["key"], - auth_config["cookie"]["expiry_days"], -) +if authentication_enabled: + authenticator = st_auth.Authenticate( + auth_config["credentials"], + auth_config["cookie"]["name"], + auth_config["cookie"]["key"], + auth_config["cookie"]["expiry_days"], + ) content_wrapper = st.empty() @@ -43,7 +51,8 @@ def render_login(): with content_wrapper.container(): st.title('Annescribe Login') st.write("Please login with your credentials.") - authenticator.login() + if authentication_enabled: + authenticator.login() if "authentication_status" not in st.session_state: @@ -54,10 +63,10 @@ if "authentication_status" not in st.session_state: authentication_status = st.session_state.get("authentication_status") username = st.session_state.get("username") if authentication_status is True: - render_home(content_wrapper, config, authenticator) -elif authentication_status is None: - st.warning('Please enter your username and password') - render_login() + render_home(content_wrapper, config, authenticator if authentication_enabled else None) +# elif authentication_status is None: +# st.warning('Please enter your username and password') +# render_login() else: # make sure to remove cookie render_login() diff --git a/transcription/__pycache__/__init__.cpython-311.pyc b/transcription/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index b0cde6c0bf12332483e4bb447008cbac697edfcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165 zcmZ3^%ge<81fO&Y(?RrO5CH>>P{wCAAY(d13PUi1CZpdter~FM zVoqv>enC-wR%&udv3_D+UTSf2QD#!Aeo0Yc9*9{`l9`{UA0MBYmst`YuUAm{i^C>2 hKczG$)vkyYXb{M%Vtyd;ftit!@dE>lC}IYR0RZ+AC%gav diff --git a/transcription/__pycache__/job_utils.cpython-311.pyc b/transcription/__pycache__/job_utils.cpython-311.pyc deleted file mode 100644 index c13862e9d029749a20fea192e52666d0fc319c5b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8929 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 diff --git a/webapp/__pycache__/home.cpython-311.pyc b/webapp/__pycache__/home.cpython-311.pyc deleted file mode 100644 index 669bd27452ddc6aa68bcf0b2fa5d10a0c19fec53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1126 zcmZ`&&1(}u6o0e3NhhhnYPC=ZVKpG6&~8QQA&8Khdg#F(q`j2w?lj%H*zADV{ z?W*g9BF>vZuxk0L8*G;^P2QONsg57`d1VGreX+DFyFP`v;I#Dy%IgKn|QeS)6& z@6#2(VT`R@^=m78+92wpyj&UcjgPEtXp?=ceK4#qQIWsv+9fE%V+ggDvZXFVix61G zDWpFlr)}@%b)?rRY`WNB`i9?NdSKRBYN!S#Hyb(vC``v=ygEAS{@Nz9gf4tNa7~M) z(bAE5LU+82;iDZ?bv(-)xQ`|`h2x()aweMGtLlrs*RQ;NyXJW;w79dz^cUFF_S75Z zTCzK=C~Zrr?ZILt8|Q`%#tJSyBOKwfIMd>eU~$&<>wZ&kxrBtABJT90`oY)hN zWeT5%(vDc1%^DH|!*tn}5pZ7)85-uW8KcG4aER1tE_ff0pTBc5h!-%2ED(M}_5qT6 zke@ux6_0YoPOj9=l@iEi=2P~a=0DNs&chBZcWF7I<#R=)Lua~lCZaPZ^!_m|9?@cl zmb$bQ(bC^6oWN8xcz(cabooiL6J-9Rs3$7Q1pdRg?E4?vvkbKW diff --git a/webapp/__pycache__/task_list.cpython-311.pyc b/webapp/__pycache__/task_list.cpython-311.pyc deleted file mode 100644 index f58c0df6afde554a3b99755478dd6d3f0ed00b55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5608 zcmb_gU2GKB6~1?7c6WBYYkQ52e*$I<31ke|#=(R*4lVcxH;LVVNfS0D%XnvO7WT)P znZ;(ami&;auH;mtN`<9{$`5U76e^*rt$1i9DiJACUuMumu|~3lROKOW8<9vTPd#V$ zcNUX2O|P%doSA#>-#Pbu=iL26C=?)2ULJmH_Mdfx{0m=7g)1@-W`MayWFpf9Nzw?V z$TJBh!orhHa7j1-lQ+$1Ky+XGmPwghl}`&ybt&Q@-h!?oa~49I)`WE0PsPX znIdA{7dWX%ZQq=v>TxMC34Fb#N2QFOR+Sl5(JsK3@T{Uom4&#b$5XS>i|J|2=7C0K zNta@@xNdxS;Gc#62Y&)`jpWD@Bhx8Zy$I#8*cHgUAV}i$bfa=ovLNC`_hPit}aijNMQiC_0_d^>iv~b15mQWINBOQc8=d z@i{%7P6?-rv(2PcXUr`Bm0b zU1_Ke;hX;63+bfNDJCM~kg}tK)00TRVD;a355YKVD^-5wjJKRaP@%|^>}YYVr$*1l(fgKBbvhEvrr4if;QMPupIOnf$0 z_6%t7!N22qzXkJOBU|fdt-1$vC={83))=?e+}^Rs=s13{$m}~r#|;-tj=H5s$cNDK zQK^}@TNl|Jt9E~T|8pISoXl;#2UsmvyIQpI$`d(86xqlZd7pbMI&SaRe; z(8)(-z1;M=->2M&QW??b8WCOPza9}#CineOrhq|}(=aFiFBDEjMwJjx9s@^}6YKIZ zv@jma2X6woMrHxM&vh?8#?u~il1Prq^xIC-d(6sy@I|Jw29d2ejm<)!5gD85>9bh? zRSoST2#y1N6Bt66ilvit$FjTKaN0AJNXMjvb_|%33_6zfERZE)v@I0t)K9KAS?%Iy z?Fa9*A2fOrg4)MpqyT8I2`8fr|D`x?O{NfQr0z`pKL@)cf zE3aU3%vMnY5QVJa9FG(?z6}t_JF4U^a-|M3=f*yFep0*2MOOAe4hNaa2aL=^##Z10 za$S)NF4Gw*hu+~)VwPLvbPPAel4IR-$@Q0<-Ks6FwRubOa)Vo*lGJq_BUQ1~jyFQi zz57!banS?OpnA6$|D57lP0h~Tb1iyZGu6n#Os_dJ`4;({H&>Ont(8|_niHQnzS?^3 znJ96OfVN_=M`H!0a!hR>IgEeqNC5h&+d~c!jhkl{*t2AwQgRkXqh#gvm0xb%Lk>@o zt@~h$*p$8QK=f%PB`d0vlVCa~AchHZ(rh7nEM}X4?79?BDe54Ce1R3x)0o!@dRmxE zNU=g%gRM$R7ve?y|K9^eRc@2@pTU*A3@fv_Nl0B?p#$N`2_46Pal2wwvj0e$A=B$M zhZdB=PG*_zZYQC-QOKukJ}Ie}+?LLXAZLU6L^pYP{8#ih5kmfs!`$C5K zvYc@6piqjg2XF;XA#viOb475%s`@m_LOlIX+WQg6X#+r($ep)7>A1USycW54GGd&J zpfYcrx|RL&Ia3_A#9>1m{`CB`bsnOMYM$4u^IB1*ws#7yE1B=0Jhj=SfHK6(H7=Xl zxwV}8I-oK#m!h)Atzb?MuAx70?%aRG<7l zDxE-;zFtu2sXK4qeKSu;_p6Mf&o@l*xFsGp#N(fyeZx8{8=1?{xPW)n(E#kAk{E2&IUft*;eeX(>RlT2wa2) zCY%(@4!M8d2o(g2S1p^yZ4SO@Hoa)G$RIWd=P@ONQn=wY);f`T0)!61zxD&z6YTcu zCeqlo)?qdruo@0P{^U7Ki8CW$|4G-}QN$xZZ8H4Ondh%b`3^+IMa28aH;0=RItw z9uhGi0n)x_W0z*^(%c`r@(dAP#bbrkCEvBNYs@Iss-`Ex?SbXc*Lz8@VR_N4+iBJ9 zTnpc;+ileC-VD~Sj2kW8W^lh1+;2Gb^Nu}h6J|%h)zP0Po)GK+$a~(a^y)L4t?ln; zf4^rfY3@F3?LKU_4p^-NSGmp3ed{5!^N7`X1Qs8L9zfn3yL$Ypw$a?S8h-!ewTWL% ztnFEw`Q3iAxyNemF`9ce+IOx>AGCsETQE~?>Dy@QUS(G&thVm;XUw)&>&J zW=G~r6#S~(rP8^Ed-(8S%@^nOA!r4b_k77Nj&Z(33w>3#RmEysan8W5?ln@*UFIKT zc>8EQdv|-&$YKB8r-R5J=0=W&?jB{4f1V;gt8>9t$+>R+jP@OQ6_9*2=I?%SLkkLi_rje`-+$-hwu zq=nlXKa@<%nS^po#Xy2Uu9-l<43ut=orb&KC)