From 5da81e2ee6155e9c86a63e0ace0cd866071e8ce0 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 29 Dec 2024 21:27:11 +0100 Subject: [PATCH] initial commit --- main.py | 21 ++ pyproject.toml | 0 transcription/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 165 bytes .../__pycache__/job_utils.cpython-311.pyc | Bin 0 -> 8929 bytes transcription/audio_processing.py | 72 +++++++ transcription/job_execution.py | 0 transcription/job_utils.py | 202 ++++++++++++++++++ transcription/transcription.py | 57 +++++ transcription/transcription_helper.py | 155 ++++++++++++++ transcription/utils.py | 32 +++ webapp/__init__.py | 0 webapp/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 158 bytes webapp/__pycache__/home.cpython-311.pyc | Bin 0 -> 1126 bytes webapp/__pycache__/task_list.cpython-311.pyc | Bin 0 -> 5608 bytes webapp/home.py | 22 ++ webapp/login.py | 9 + webapp/task_list.py | 97 +++++++++ webapp_main.py | 66 ++++++ 19 files changed, 733 insertions(+) create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 transcription/__init__.py create mode 100644 transcription/__pycache__/__init__.cpython-311.pyc create mode 100644 transcription/__pycache__/job_utils.cpython-311.pyc create mode 100644 transcription/audio_processing.py create mode 100644 transcription/job_execution.py create mode 100644 transcription/job_utils.py create mode 100644 transcription/transcription.py create mode 100644 transcription/transcription_helper.py create mode 100644 transcription/utils.py create mode 100644 webapp/__init__.py create mode 100644 webapp/__pycache__/__init__.cpython-311.pyc create mode 100644 webapp/__pycache__/home.cpython-311.pyc create mode 100644 webapp/__pycache__/task_list.cpython-311.pyc create mode 100644 webapp/home.py create mode 100644 webapp/login.py create mode 100644 webapp/task_list.py create mode 100644 webapp_main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..b77817a --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +import os + +import yaml + +CONFIG_FILE = os.environ.get('CONFIG_FILE', "config.yml") + +if CONFIG_FILE is None: + raise ValueError('CONFIG_FILE environment variable is not set') +elif not os.path.exists(CONFIG_FILE): + raise ValueError(f'CONFIG_FILE {CONFIG_FILE} does not exist') + +with open(CONFIG_FILE, 'r') as config_file: + config = yaml.safe_load(config_file) + + +def main(): + pass + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/transcription/__init__.py b/transcription/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transcription/__pycache__/__init__.cpython-311.pyc b/transcription/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0cde6c0bf12332483e4bb447008cbac697edfcb GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/transcription/__pycache__/job_utils.cpython-311.pyc b/transcription/__pycache__/job_utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c13862e9d029749a20fea192e52666d0fc319c5b GIT binary patch literal 8929 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 sA}iTa3ml;#MA#6bhsSC3=sb list: + """ + Split the audio file into chunks. + :param audio_file_path: path to the audio file + :param chunk_folder: path to the chunk folder + :param max_num_chunks: number of chunks to split the audio file into + :param min_silence_level: minimum silence level + :param min_silence_length: minimum silence length + :param keep_silence: whether to keep silence + :return: list of chunked audio file paths + """ + + # create the chunk folder + if not os.path.exists(chunk_folder): + os.makedirs(chunk_folder) + + # load audio file + audio = AudioSegment.from_file(audio_file_path) + audio_name = os.path.basename(audio_file_path).split('.')[0] + + # split audio file into chunks + chunks = split_on_silence(audio, + silence_thresh=min_silence_level, + min_silence_len=min_silence_length, + keep_silence=keep_silence) + + if len(chunks) > max_num_chunks: + # randomly combine chunks + while len(chunks) > max_num_chunks: + random_chunk_index = random.randint(0, len(chunks) - 2) + chunks[random_chunk_index] += chunks[random_chunk_index + 1] + chunks.pop(random_chunk_index + 1) + + # save chunks + chunked_audio_files = [] + for i, chunk in enumerate(chunks): + chunk_file_path = os.path.join(chunk_folder, f'{audio_name}_{i}.wav') + chunk.export(chunk_file_path, format='wav') + chunked_audio_files.append(chunk_file_path) + + return chunked_audio_files + + +def convert_to_wav(audio_file_path: str, + output_file_path: str) -> str: + """ + Convert an audio file to wav format. + :param audio_file_path: path to the audio file + :param output_folder_path: path to the output folder + :return: path to the converted audio file + """ + + input_audio = AudioSegment.from_file(audio_file_path) + if ".wav" in output_file_path: + new_audio_file_path = output_file_path + else: + new_audio_file_path = output_file_path + ".wav" + input_audio.export(new_audio_file_path, format='wav') + + return new_audio_file_path diff --git a/transcription/job_execution.py b/transcription/job_execution.py new file mode 100644 index 0000000..e69de29 diff --git a/transcription/job_utils.py b/transcription/job_utils.py new file mode 100644 index 0000000..4398468 --- /dev/null +++ b/transcription/job_utils.py @@ -0,0 +1,202 @@ +import os +import shutil +from multiprocessing import Lock + +# multiprocessing lock for thread safety +lock = Lock() + +chunk_input_subdir = 'input_chunks' +chunk_output_subdir = 'output_chunks' + + +def get_job_data(job_name: str, config: dict) -> dict: + """ + Get job data from the database. + :param job_name: name of the job + :param config: app configuration + :return: job data + """ + + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + + if not os.path.exists(job_dir): + raise ValueError(f'Job {job_name} does not exist') + + job_data = dict() + job_data['name'] = job_name + + output_file = os.path.join(job_dir, 'output.txt') + if os.path.exists(output_file): + with open(output_file, 'r') as f: + job_data['output'] = f.read() + job_data["completed"] = True + else: + job_data['output'] = None + job_data["completed"] = False + + processing_file = os.path.join(job_dir, '_PROCESSING') + if os.path.exists(processing_file): + job_data["processing"] = True + else: + job_data["processing"] = False + + # get progress of task + job_data["progress"] = get_progress(job_name, config) + + return job_data + + +def get_existing_jobs(config: dict) -> list: + """ + Get existing jobs from the database. + :param config: app configuration + :return: list of jobs + """ + + job_root_dir = config['jobs']['root_directory'] + + if not os.path.exists(job_root_dir): + return [] + + jobs = list() + for folder in os.listdir(job_root_dir): + job_data = get_job_data(folder, config) + jobs.append(job_data) + return jobs + + +def is_job(job_name: str, config: dict) -> bool: + """ + Check if a job exists. + :param job_name: name of the job + :param config: app configuration + :return: True if the job exists, False otherwise + """ + + try: + get_job_data(job_name, config) + return True + except ValueError: + return False + + +def create_job(job_name: str, config: dict) -> None: + """ + Create a job in the file system. + :param job_name: name of the job + :param config: app configuration + """ + # check, if job root directory exists + if not os.path.exists(config['jobs']['root_directory']): + os.makedirs(config['jobs']['root_directory']) + + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + + if os.path.exists(job_dir): + raise ValueError(f'Job {job_name} already exists') + + lock.acquire() + + os.mkdir(job_dir) + + lock.release() + + +def rename_job(job_name: str, new_job_name: str, config: dict) -> None: + """ + Rename a job in the file system. + :param job_name: name of the job + :param new_job_name: new name of the job + :param config: app configuration + """ + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + new_job_dir = os.path.join(config['jobs']['root_directory'], new_job_name) + + if not os.path.exists(job_dir): + raise ValueError(f'Job {job_name} does not exist') + + if os.path.exists(new_job_dir): + raise ValueError(f'Job {new_job_name} already exists') + + lock.acquire() + + os.rename(job_dir, new_job_dir) + + lock.release() + + +def remove_job(job_name: str, config: dict) -> None: + """ + Remove a job from the file system. + :param job_name: name of the job + :param config: app configuration + """ + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + + if not os.path.exists(job_dir): + raise ValueError(f'Job {job_name} does not exist') + + lock.acquire() + + # remove the directory + shutil.rmtree(job_dir) + + lock.release() + + +def get_processing(job_name: str, config: dict) -> bool: + """ + Get the processing status of a job. + :param job_name: name of the job + :param config: app configuration + :return: True if the job is processing, False otherwise + """ + + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + processing_file = os.path.join(job_dir, '_PROCESSING') + + return os.path.exists(processing_file) + + +def set_processing(job_name: str, config: dict, processing: bool) -> None: + """ + Set the processing status of a job. + :param job_name: name of the job + :param config: app configuration + :param processing: processing status + """ + + lock.acquire() + + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + processing_file = os.path.join(job_dir, '_PROCESSING') + + if processing: + open(processing_file, 'w').close() + else: + os.remove(processing_file) + + lock.release() + + +def get_progress(job_name: str, config: dict) -> int: + """ + Get the progress of a job. + :param job_name: name of the job + :param config: app configuration + :return: progress of the job + """ + + input_dir = os.path.join(config['jobs']['root_directory'], job_name, chunk_input_subdir) + output_dir = os.path.join(config['jobs']['root_directory'], job_name, chunk_output_subdir) + + num_input_files = len(os.listdir(input_dir)) + num_output_files = len(os.listdir(output_dir)) + + if num_input_files == 0 or num_output_files == 0: + return 0 + + if num_output_files >= num_input_files: + return 100 + + return int(num_output_files / num_input_files * 100) diff --git a/transcription/transcription.py b/transcription/transcription.py new file mode 100644 index 0000000..29206ea --- /dev/null +++ b/transcription/transcription.py @@ -0,0 +1,57 @@ +import whisper + + +# clojure for model +def model_loader() -> callable: + """ + Clojure for loading the model + :return: function that loads the model + """ + + model = None + + def load_model(config: dict): + """ + Load the model + :return: model + """ + nonlocal model + + if model is not None: + return model + else: + model_type = config['model']['type'] + model = whisper.load_model(model_type) + return model + + return load_model + + +load_model = model_loader() + + +def transcribe_audio(audio_file_path: str, + output_file_path: str, + config: dict) -> str: + """ + Transcribe an audio file with openai whisper + :param audio_file_path: filepath of the audio file + :param output_file_path: filepath of the output file + :param config: app config + :return: transcribed text + """ + + # load the model + model = load_model(config) + + # move model to desired device + desired_device = config['model']['device'] + model.to(desired_device) + + result = model.transcribe(audio_file_path) + result_text = result['text'] + + # write the result to a text file + with open(output_file_path, 'w') as result_file: + result_file.write(result["text"]) + return result_text diff --git a/transcription/transcription_helper.py b/transcription/transcription_helper.py new file mode 100644 index 0000000..a8ce812 --- /dev/null +++ b/transcription/transcription_helper.py @@ -0,0 +1,155 @@ +import concurrent.futures +import os +from multiprocessing import Lock +from concurrent.futures import ProcessPoolExecutor + +import job_utils +from utils import get_logger +from audio_processing import split_audio, convert_to_wav +from job_utils import is_job, remove_job, create_job, get_processing, set_processing +from transcription import transcribe_audio + +job_update_lock = Lock() +default_input_audio_file_name = 'input_audio' + +logger = get_logger(__name__) + +# define a global executor for non-blocking transcription jobs +executor = ProcessPoolExecutor() + + +def transcribe(audio_file_path: str, + config: dict, + blocking: bool = True, + overwrite: bool = False, + executor: concurrent.futures.Executor = executor) -> str: + """ + Transcribe an audio file. + :param audio_file_path: path to the audio file + :param config: app configuration + :param blocking: block until the job is finished + :param overwrite: overwrite existing job + :param executor: executor for non-blocking transcription + :return: name of the created job + """ + + job_name = os.path.basename(audio_file_path).split('.')[0] + job_dir = os.path.join(config['jobs']['root_directory'], job_name) + + logger.info(f"Transcribing {audio_file_path} to {job_dir}") + + # check, if a job with the same audio file already exists + job_already_exists = is_job(job_name, config) + + if job_already_exists and not overwrite: + logger.error(f'Job for {audio_file_path} already exists and overwrite is not set') + raise ValueError(f'Job for {audio_file_path} already exists') + + # remove, if overwrite is set + if job_already_exists and overwrite: + logger.info(f'Removing existing job {job_name} since overwrite is set') + remove_job(job_name, config) + + # create a new base job + logger.info(f'Creating job {job_name}') + create_job(job_name, config) + + # copy the audio file to the job directory + logger.info(f'Copying audio file {audio_file_path} to {job_dir}') + input_audio_file_name = os.path.basename(audio_file_path).split('.')[0] + input_audio_file_type = os.path.splitext(audio_file_path)[1] + audio_file_destination = os.path.join(job_dir, f"{input_audio_file_name}{input_audio_file_type}") + os.system(f'cp {audio_file_path} {audio_file_destination}') + + # convert the audio file to wav, if it is not already + if not audio_file_destination.endswith('.wav'): + logger.info(f'Converting audio file {audio_file_destination} to wav') + convert_to_wav(audio_file_path, + os.path.join(job_dir, f"{default_input_audio_file_name}.wav")) + + # run the transcription job + logger.info(f'Running transcription job {job_name}') + + if blocking: + logger.info(f'Starting blocking transcription job {job_name}') + run_transcription_job(job_name, config) + else: + logger.info(f'Starting async transcription job {job_name}') + executor.submit(run_transcription_job, job_name, config, 100) + logger.info(f'Started async transcription job {job_name}') + + return job_name + + +def run_transcription_job(job_name: str, + config: dict, + num_chunks=100) -> None: + """ + Start a transcription job. + :param job_name: id of the job + :param config: app configuration + :param num_chunks: number of chunks to split the audio file into + """ + + # check if the job is already running + if get_processing(job_name, config): + raise ValueError(f'Job {job_name} is already running') + + # set the job to processing + set_processing(job_name, config, processing=True) + + try: + # split the audio file into chunks + logger.info(f'Splitting audio file into chunks for job {job_name}') + audio_file_path = os.path.join(config['jobs']['root_directory'], job_name, + f"{default_input_audio_file_name}.wav") + audio_chunk_folder = os.path.join(config['jobs']['root_directory'], job_name, job_utils.chunk_input_subdir) + audio_chunks = split_audio(audio_file_path, + audio_chunk_folder, + num_chunks, + config["audio"]["chunking"]["min_silence_level"], + config["audio"]["chunking"]["min_silence_length"], + config["audio"]["chunking"]["ms_silence_to_keep"]) + + # transcribe the audio chunks + logger.info(f'Transcribing {len(audio_chunks)} audio chunks for job {job_name}') + # create chunk output folder, if not done yet + chunk_output_folder = os.path.join(config['jobs']['root_directory'], job_name, job_utils.chunk_output_subdir) + if not os.path.exists(chunk_output_folder): + os.makedirs(chunk_output_folder) + for i, audio_chunk in enumerate(audio_chunks): + # transcribe the audio + logger.info(f'Transcribing audio chunk {i + 1} of {len(audio_chunks)} for job {job_name}') + output_file_path = os.path.join(chunk_output_folder, f'output_{i}.txt') + transcribe_audio(audio_chunk, + output_file_path, + config) + + # combine the output text chunks + logger.info(f'Combining output text chunks for job {job_name}') + output_chunk_folder = os.path.join(config['jobs']['root_directory'], job_name, job_utils.chunk_output_subdir) + output_text = '' + for text_chunk_file in sorted(os.listdir(output_chunk_folder)): + with open(os.path.join(output_chunk_folder, text_chunk_file), 'r') as text_chunk: + output_text += text_chunk.read() + ' ' + + # remove trailing space + output_text = output_text.strip() + + # write the output to a file + logger.info(f'Writing output text to file for job {job_name}') + output_file = os.path.join(config['jobs']['root_directory'], job_name, 'output.txt') + with open(output_file, 'w') as f: + f.write(output_text) + + # set the job to not processing + set_processing(job_name, config, processing=False) + + logger.info(f'Job {job_name} completed') + + + + except Exception as e: + # set the job to not processing + set_processing(job_name, config, processing=False) + raise e diff --git a/transcription/utils.py b/transcription/utils.py new file mode 100644 index 0000000..13a1dab --- /dev/null +++ b/transcription/utils.py @@ -0,0 +1,32 @@ +import sys +import logging +from logging import Logger + + +def get_logger(name: str) -> Logger: + """ + Get a logger instance. + :param name: name of the logger + :return: logger instance + """ + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + + # create a file handler + file_handler = logging.FileHandler(f'{name}.log') + file_handler.setLevel(logging.INFO) + + # create stdout handler + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.INFO) + + # create a logging format + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + file_handler.setFormatter(formatter) + stdout_handler.setFormatter(formatter) + + # add the handlers to the logger + logger.addHandler(file_handler) + logger.addHandler(stdout_handler) + + return logger diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webapp/__pycache__/__init__.cpython-311.pyc b/webapp/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0da1a182a70bd9537de8ca0571e6b1a32c8ca60 GIT binary patch literal 158 zcmZ3^%ge<81TS?8(?RrO5CH>>P{wCAAY(d13PUi1CZpdenC-wR%&udv3_D+UTSf2QD#!AetBwAVnKm^e0*kJW=VX!UP0wA4x8Nkl+v73 cyCPPg2_QR)`GLd-W=2NF4-7D(h#4pb0FGfKn*aa+ literal 0 HcmV?d00001 diff --git a/webapp/__pycache__/home.cpython-311.pyc b/webapp/__pycache__/home.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..669bd27452ddc6aa68bcf0b2fa5d10a0c19fec53 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/webapp/__pycache__/task_list.cpython-311.pyc b/webapp/__pycache__/task_list.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f58c0df6afde554a3b99755478dd6d3f0ed00b55 GIT binary patch literal 5608 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