From ecbbbfd5d1ecefb6d269f13504264d9046b81225 Mon Sep 17 00:00:00 2001 From: German Mikheev Date: Sat, 6 Sep 2025 23:36:33 +0300 Subject: [PATCH] Minor update - added transcription folder with checker and transcription modules - logger is a separated module now --- README.md | 10 ++ environment.yml | 195 +++++++++++++++++++++++++++ main.py | 139 +------------------ make_venv.ps1 | 13 ++ make_venv.sh | 15 ++- transcription/audio_transcription.py | 143 ++++++++++++++++++++ transcription/torch_checker.py | 11 ++ ui.py | 14 +- utils/logger.py | 36 +++++ 9 files changed, 437 insertions(+), 139 deletions(-) create mode 100644 environment.yml create mode 100644 make_venv.ps1 create mode 100644 transcription/audio_transcription.py create mode 100644 transcription/torch_checker.py create mode 100644 utils/logger.py diff --git a/README.md b/README.md index e69de29..8f1165a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,10 @@ +# Notecast + +**Notecast** is a simple and minimalistic utilite to transcribe audiofiles to text and create a conspect. + +Requires nvcc v12.8 + +You can create environment running +``` +conda env create -f environment.yml +``` \ No newline at end of file diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..cbd2d15 --- /dev/null +++ b/environment.yml @@ -0,0 +1,195 @@ +name: notecast +channels: + - conda-forge + - defaults + - nvidia + - pytorch +dependencies: + - accelerate=1.10.1=pyhcf101f3_0 + - aiohappyeyeballs=2.6.1=pyhd8ed1ab_0 + - aiohttp=3.12.15=pyh7db6752_0 + - aiosignal=1.4.0=pyhd8ed1ab_0 + - aom=3.6.0=hd77b12b_0 + - arrow-cpp=19.0.0=h82e8c66_4 + - async-timeout=5.0.1=pyhd8ed1ab_1 + - attrs=25.3.0=pyh71513ae_0 + - aws-c-auth=0.9.0=hd490b63_15 + - aws-c-cal=0.9.2=hd8a8e38_0 + - aws-c-common=0.12.3=h2466b09_0 + - aws-c-compression=0.3.1=h5d0e663_5 + - aws-c-event-stream=0.5.5=ha416645_0 + - aws-c-http=0.10.2=h81282ae_2 + - aws-c-io=0.20.1=hf7624bd_1 + - aws-c-mqtt=0.13.1=h5c1ae27_3 + - aws-c-s3=0.8.3=h1e843c7_0 + - aws-c-sdkutils=0.2.4=h5d0e663_0 + - aws-checksums=0.2.7=h5d0e663_1 + - aws-crt-cpp=0.32.10=h8abd1a4_2 + - aws-sdk-cpp=1.11.528=hd293286_1 + - blas=1.0=mkl + - bottleneck=1.4.2=py312h4b0e54e_0 + - brotlicffi=1.0.9.2=py312h5da7b33_1 + - bzip2=1.0.8=h2bbff1b_6 + - c-ares=1.34.5=h2466b09_0 + - ca-certificates=2025.8.3=h4c7d964_0 + - cairo=1.18.4=he9e932c_0 + - certifi=2025.8.3=py312haa95532_0 + - cffi=1.17.1=py312h827c3e9_1 + - charset-normalizer=3.3.2=pyhd3eb1b0_0 + - colorama=0.4.6=pyhd8ed1ab_1 + - cuda-cccl=13.0.50=h23517cc_1 + - cuda-cccl_win-64=13.0.50=hc667259_1 + - cuda-cudart=12.4.127=0 + - cuda-cudart-dev=12.4.127=0 + - cuda-cupti=12.4.127=0 + - cuda-libraries=12.4.1=0 + - cuda-libraries-dev=12.4.1=0 + - cuda-nvrtc=12.4.127=0 + - cuda-nvrtc-dev=12.4.127=0 + - cuda-nvtx=12.4.127=0 + - cuda-opencl=13.0.39=0 + - cuda-opencl-dev=13.0.39=0 + - cuda-profiler-api=13.0.39=0 + - cuda-runtime=12.4.1=0 + - cuda-version=13.0=3 + - datasets=4.0.0=pyhcf101f3_0 + - dav1d=1.2.1=h2bbff1b_0 + - dill=0.3.8=pyhd8ed1ab_0 + - expat=2.7.1=h8ddb27b_0 + - ffmpeg=4.3.1=ha925a31_0 + - filelock=3.17.0=py312haa95532_0 + - fontconfig=2.14.1=hb33846d_3 + - freeglut=3.4.0=h8a1e904_1 + - freetype=2.13.3=h0620614_0 + - fribidi=1.0.10=h62dcd97_0 + - frozenlist=1.7.0=pyhf298e5d_0 + - fsspec=2025.3.0=pyhd8ed1ab_0 + - gflags=2.2.2=he0c23c2_1005 + - giflib=5.2.2=h7edc060_0 + - glog=0.5.0=h4797de2_0 + - gmp=6.3.0=h537511b_0 + - gmpy2=2.2.1=py312h827c3e9_0 + - graphite2=1.3.14=hd77b12b_1 + - harfbuzz=10.2.0=he2f9f60_1 + - hf-xet=1.1.8=py312h79d111c_0 + - huggingface_hub=0.34.4=pyhd8ed1ab_1 + - icu=73.1=h6c2663c_0 + - idna=3.7=py312haa95532_0 + - intel-openmp=2023.1.0=h59b6b97_46320 + - jinja2=3.1.6=py312haa95532_0 + - jpeg=9e=h827c3e9_3 + - khronos-opencl-icd-loader=2024.05.08=h8cc25b3_0 + - lcms2=2.16=h62be587_1 + - lerc=4.0.0=h5da7b33_0 + - libabseil=20250127.0=cxx17_h4eb7d71_0 + - libavif=1.1.1=h827c3e9_0 + - libbrotlicommon=1.0.9=hcfcfb64_9 + - libbrotlidec=1.0.9=hcfcfb64_9 + - libbrotlienc=1.0.9=hcfcfb64_9 + - libcublas=12.4.5.8=0 + - libcublas-dev=12.4.5.8=0 + - libcufft=11.2.1.3=0 + - libcufft-dev=11.2.1.3=0 + - libcurand=10.4.0.35=0 + - libcurand-dev=10.4.0.35=0 + - libcurl=8.15.0=h2300eb9_0 + - libcusolver=11.6.1.9=0 + - libcusolver-dev=11.6.1.9=0 + - libcusparse=12.3.1.170=0 + - libcusparse-dev=12.3.1.170=0 + - libdeflate=1.22=h5bf469e_0 + - libffi=3.4.4=hd77b12b_1 + - libglib=2.84.2=h405b238_0 + - libgrpc=1.71.0=hf4237ab_0 + - libiconv=1.16=h2bbff1b_3 + - libjpeg-turbo=2.0.0=h196d8e1_0 + - libnpp=12.2.5.30=0 + - libnpp-dev=12.2.5.30=0 + - libnvfatbin=13.0.39=0 + - libnvfatbin-dev=13.0.39=0 + - libnvjitlink=12.4.127=0 + - libnvjitlink-dev=12.4.127=0 + - libnvjpeg=12.3.1.117=0 + - libnvjpeg-dev=12.3.1.117=0 + - libpng=1.6.39=h8cc25b3_0 + - libprotobuf=5.29.3=h65a231f_1 + - libre2-11=2024.07.02=hd248061_3 + - libssh2=1.11.1=h2addb87_0 + - libthrift=0.15.0=ha2884a9_3 + - libtiff=4.7.0=h404307b_0 + - libuv=1.48.0=h827c3e9_0 + - libwebp=1.3.2=h18467be_1 + - libwebp-base=1.3.2=h3d04722_1 + - libxml2=2.13.8=h866ff63_0 + - lz4-c=1.9.4=h2bbff1b_1 + - markupsafe=3.0.2=py312h827c3e9_0 + - mkl=2023.1.0=h6b88ed4_46358 + - mkl-service=2.4.0=py312h827c3e9_2 + - mkl_fft=1.3.11=py312h827c3e9_0 + - mkl_random=1.2.8=py312h0158946_0 + - mpc=1.3.1=h827c3e9_0 + - mpfr=4.2.1=h56c3642_0 + - mpmath=1.3.0=py312haa95532_0 + - multidict=6.6.3=pyh62beb40_0 + - multiprocess=0.70.15=py312haa95532_0 + - networkx=3.5=py312haa95532_0 + - numexpr=2.11.0=py312hdb065b2_0 + - numpy=2.3.1=py312h5f75535_0 + - numpy-base=2.3.1=py312h23d94f8_0 + - openjpeg=2.5.2=h9b5d1b5_1 + - openssl=3.5.2=h725018a_0 + - orc=2.1.1=hd1c1d5c_0 + - packaging=25.0=pyh29332c3_1 + - pandas=2.3.2=py312ha5e6156_0 + - pcre2=10.42=h0ff8eda_1 + - pillow=11.3.0=py312hb328d1f_0 + - pip=25.2=pyhc872135_0 + - pixman=0.46.4=h4043f72_0 + - propcache=0.3.1=pyhe1237c8_0 + - psutil=5.9.0=py312h827c3e9_1 + - pyarrow=19.0.0=py312h5da7b33_1 + - pycparser=2.21=pyhd3eb1b0_0 + - pysocks=1.7.1=py312haa95532_0 + - python=3.12.11=h716150d_0 + - python-dateutil=2.9.0.post0=pyhe01879c_2 + - python-tzdata=2025.2=pyhd8ed1ab_0 + - python-xxhash=3.5.0=py312h827c3e9_0 + - pytorch=2.5.1=py3.12_cuda12.4_cudnn9_0 + - pytorch-cuda=12.4=h3fd98bf_7 + - pytorch-mutex=1.0=cuda + - pytz=2025.2=pyhd8ed1ab_0 + - pyyaml=6.0.2=py312h827c3e9_0 + - re2=2024.07.02=haf4117d_3 + - regex=2024.11.6=py312h827c3e9_0 + - requests=2.32.5=py312haa95532_0 + - safetensors=0.5.3=py312h44068b5_0 + - setuptools=72.1.0=py312haa95532_0 + - six=1.17.0=pyhe01879c_1 + - snappy=1.2.2=h7fa0ca8_0 + - sqlite=3.50.2=hda9a48d_1 + - sympy=1.14.0=py312haa95532_0 + - tbb=2021.8.0=h59b6b97_0 + - tk=8.6.15=hf199647_0 + - tokenizers=0.21.0=py312h482ea96_0 + - tqdm=4.67.1=pyhd8ed1ab_1 + - transformers=4.55.4=pyhd8ed1ab_0 + - typing-extensions=4.15.0=py312haa95532_0 + - typing_extensions=4.15.0=py312haa95532_0 + - tzdata=2025b=h04d1e81_0 + - ucrt=10.0.22621.0=haa95532_0 + - urllib3=2.5.0=py312haa95532_0 + - utf8proc=2.6.1=h2bbff1b_1 + - vc=14.42=haa95532_5 + - vc14_runtime=14.44.35208=h4927774_10 + - vs2015_runtime=14.44.35208=ha6b5a95_10 + - wheel=0.45.1=py312haa95532_0 + - win_inet_pton=1.1.0=py312haa95532_0 + - xxhash=0.8.0=h8ffe710_3 + - xz=5.6.4=h4754444_1 + - yaml=0.2.5=he774522_0 + - yarl=1.20.1=pyhe1237c8_0 + - zlib=1.2.13=h8cc25b3_1 + - zstd=1.5.6=h8880b57_0 + - pip: + - torchaudio==2.5.1 + - torchvision==0.20.1 \ No newline at end of file diff --git a/main.py b/main.py index 5ce09e2..8cc09e4 100644 --- a/main.py +++ b/main.py @@ -1,140 +1,13 @@ -from transformers import WhisperProcessor, WhisperForConditionalGeneration -import torch -import torchaudio -import logging -import time -from tqdm import tqdm +from transcription.audio_transcription import AudioTranscription +from transcription.torch_checker import check_torch +from utils.logger import setup_logger -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) +logger = setup_logger("main") -print("=== Checking PyTorch ===") -print(f"Torch version: {torch.version}") -print(f"CUDA is available: {torch.cuda.is_available()}") -if torch.cuda.is_available(): - print(f"CUDA version: {torch.version.cuda}") - print(f"Number of GPU: {torch.cuda.device_count()}") - print(f"Name of GPU: {torch.cuda.get_device_name(0)}") -print("=== Check completed ===") - -class AudioTranscription: - model_name = "openai/whisper-large-v2" - - filepath: str - waveform: torch.Tensor - sampling_rate: int - - chunks: list = [] - # one chunk size in seconds - chunk_size: int - device = "cuda" - processor: WhisperProcessor - model: WhisperForConditionalGeneration - # self.device here and all previous shit - - language = "ru" - - all_transcription: list = [] - - def __init__( - self, - filepath: str, - language = "ru", - device = "cuda", - model_name = "openai/whisper-large-v2" - ) -> None: - self.filepath = filepath - self.language = language - self.device = device - self.model_name = model_name - self.chunks: list = [] - try: - logger.info("Loading model WhisperProcessor...") - self.processor = WhisperProcessor.from_pretrained(self.model_name) - - self.model = WhisperForConditionalGeneration.from_pretrained( - self.model_name, - torch_dtype=torch.float16, - device_map="auto" - ).to(self.device) - - logger.info("Model loaded.") - - self.waveform, self.sampling_rate = torchaudio.load(filepath, format="mp3", backend="ffmpeg") - logger.info(f"Successfully loaded file {filepath}.") - - except Exception as e: - logger.error(f"Unable to load file {self.filepath}: {e}") - raise - - def resample(self) -> None: - self.waveform = torchaudio.functional.resample(self.waveform, self.sampling_rate, 16000) - - def to_mono(self): - if self.waveform.shape[0] > 1: - self.waveform = self.waveform.mean(dim=0, keepdim=True) - self.waveform = self.waveform.squeeze(0) - - def split_to_chunks(self, chunk_length_s: int = 30) -> None: - logger.info(f"Splitting audio on chunks...") - - self.chunk_size = chunk_length_s * 16000 # 16kHz after resampling - total_samples = self.waveform.shape[0] - chunks_count = (total_samples + self.chunk_size - 1) // self.chunk_size - - logger.info(f"File length - {total_samples / 16000:.1f} seconds, splitting on {chunks_count} chunks by {chunk_length_s} seconds per chunk.") - - self.chunks = [] - for idx in range(chunks_count): - start = idx * self.chunk_size - end = min((idx + 1) * self.chunk_size, total_samples) - chunk = self.waveform[start:end].cpu().numpy().astype("float32") - self.chunks.append(chunk) - - def process_chunk( - self, - chunk - ) -> str: - inputs = self.processor(chunk, sampling_rate=16000, return_tensors="pt") - input_features = inputs.input_features.to(self.device).to(torch.float16) - - with torch.no_grad(): - predicted_ids = self.model.generate( - input_features, - language=self.language, - task="transcribe", - temperature=0.0 - ) - - text = self.processor.batch_decode(predicted_ids, skip_special_tokens=True)[0] - - return text - - def process_all_chunks(self) -> None: - start_time = time.time() - - try: - for chunk_idx in tqdm(range(len(self.chunks))): - self.all_transcription.append(self.process_chunk(self.chunks[chunk_idx])) - - end_time = time.time() - logger.info(f"Transcription completed in {end_time - start_time:.2f} seconds") - - except Exception as e: - logger.error(f"Errors occured while processing chunks: {e}") - - def transcribe_audio(self) -> str: - self.resample() - self.to_mono() - self.split_to_chunks() - self.process_all_chunks() - return " ".join(self.all_transcription) +check_torch() try: track = AudioTranscription("sample.mp3") print(track.transcribe_audio()) except Exception as e: - logger.error(f"Execution error: {e}") \ No newline at end of file + logger.error(f"Execution error: {e}") \ No newline at end of file diff --git a/make_venv.ps1 b/make_venv.ps1 new file mode 100644 index 0000000..b6b327a --- /dev/null +++ b/make_venv.ps1 @@ -0,0 +1,13 @@ +$envName = "notecast" + +Write-Output >>> Creating environment $envName from environment.yml" +conda env create -f environment.yml -n $envName +if ($LASTEXITCODE -ne 0) { + Write-Output ">>> Environment already exists, updating..." + conda env update -f environment.yml --prune +} + +Write-Output ">>> Activating environment" +conda activate $envName + +Write-Output ">>> Download completed!" \ No newline at end of file diff --git a/make_venv.sh b/make_venv.sh index 1b57ae2..7bd82e8 100644 --- a/make_venv.sh +++ b/make_venv.sh @@ -1,8 +1,13 @@ -# python -m venv .venv +#!/bin/bash +set -e -# pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu128 +ENV_NAME="notecast" -conda init -c speech-to-conspect +echo ">>> Creating environment $ENV_NAME from environment.yml" +conda env create -f environment.yml || conda env update -f environment.yml --prune -conda install pytorch torchvision torchaudio -c pytorch -c nvidia -conda install accelerate transformers ffmpeg -c conda-forge \ No newline at end of file +echo ">>> Activating environment" +eval "$(conda shell.bash hook)" +conda activate "$ENV_NAME" + +echo ">>> Download completed!" \ No newline at end of file diff --git a/transcription/audio_transcription.py b/transcription/audio_transcription.py new file mode 100644 index 0000000..6f450c9 --- /dev/null +++ b/transcription/audio_transcription.py @@ -0,0 +1,143 @@ +from transformers import WhisperProcessor, WhisperForConditionalGeneration +import torch +import torchaudio +from utils.logger import setup_logger +import time +import math +from tqdm import tqdm + +logger = setup_logger("AudioTranscribe module") + +class AudioTranscription: + model_name = "openai/whisper-large-v2" + + filepath: str + waveform: torch.Tensor + sampling_rate: int + + chunks: list = [] + chunk_size: int + device = "cuda" + processor: WhisperProcessor + model: WhisperForConditionalGeneration + + language = "ru" + + all_transcription: list = [] + + def __init__( + self, + filepath: str, + language = "ru", + device = "cuda", + model_name = "openai/whisper-large-v2" + ) -> None: + self.filepath = filepath + self.language = language + self.device = device + self.model_name = model_name + self.chunks: list = [] + try: + logger.info("Loading model WhisperProcessor...") + self.processor = WhisperProcessor.from_pretrained(self.model_name) + + self.model = WhisperForConditionalGeneration.from_pretrained( + self.model_name, + torch_dtype=torch.float16, + device_map="auto" + ).to(self.device) + + logger.info("Model loaded.") + + self.waveform, self.sampling_rate = torchaudio.load(filepath, format="mp3", backend="ffmpeg") + logger.info(f"Successfully loaded file {filepath}.") + + except Exception as e: + logger.error(f"Unable to load file {self.filepath}: {e}") + raise + + def resample(self) -> None: + self.waveform = torchaudio.functional.resample(self.waveform, self.sampling_rate, 16000) + + def to_mono(self): + if self.waveform.shape[0] > 1: + self.waveform = self.waveform.mean(dim=0, keepdim=True) + self.waveform = self.waveform.squeeze(0) + + def split_to_chunks(self, chunk_length_s: int = 30) -> None: + logger.info(f"Splitting audio on chunks...") + + self.chunk_size = chunk_length_s * 16000 # 16kHz after resampling + total_samples = self.waveform.shape[0] + chunks_count = (total_samples + self.chunk_size - 1) // self.chunk_size + + logger.info(f"File length - {total_samples / 16000:.1f} seconds, splitting on {chunks_count} chunks by {chunk_length_s} seconds per chunk.") + + self.chunks = [] + for idx in range(chunks_count): + start = idx * self.chunk_size + end = min((idx + 1) * self.chunk_size, total_samples) + chunk = self.waveform[start:end].cpu().numpy().astype("float32") + self.chunks.append(chunk) + + def process_chunk( + self, + chunk + ) -> str: + inputs = self.processor(chunk, sampling_rate=16000, return_tensors="pt") + input_features = inputs.input_features.to(self.device).to(torch.float16) + + with torch.no_grad(): + predicted_ids = self.model.generate( + input_features, + language=self.language, + task="transcribe", + temperature=0.0 + ) + + text = self.processor.batch_decode(predicted_ids, skip_special_tokens=True)[0] + + return text + + def process_all_chunks(self, batch_size: int = 16) -> None: + start_time = time.time() + try: + self.all_transcription = [] + + for i in tqdm(range(math.ceil(len(self.chunks) / batch_size))): + # TODO: rewrite batching as a separate function + batch = self.chunks[i*batch_size:(i+1)*batch_size] + + inputs = self.processor( + batch, + sampling_rate=16000, + return_tensors="pt", + padding=True + ) + + input_features = inputs.input_features.to(self.device).to(torch.float16) + + with torch.no_grad(): + predicted_ids = self.model.generate( + input_features, + language=self.language, + task="transcribe", + temperature=0.0 + ) + + texts = self.processor.batch_decode(predicted_ids, skip_special_tokens=True) + self.all_transcription.extend(texts) + + end_time = time.time() + logger.info(f"Transcription completed in {end_time - start_time:.2f} seconds") + + except Exception as e: + logger.error(f"Errors occured while processing chunks: {e}") + + + def transcribe_audio(self) -> str: + self.resample() + self.to_mono() + self.split_to_chunks() + self.process_all_chunks() + return " ".join(self.all_transcription) \ No newline at end of file diff --git a/transcription/torch_checker.py b/transcription/torch_checker.py new file mode 100644 index 0000000..184d7e5 --- /dev/null +++ b/transcription/torch_checker.py @@ -0,0 +1,11 @@ +import torch + +def check_torch() -> None: + print("=== Checking PyTorch ===") + print(f"Torch version: {torch.version}") + print(f"CUDA is available: {torch.cuda.is_available()}") + if torch.cuda.is_available(): + print(f"CUDA version: {torch.version.cuda}") + print(f"Number of GPU: {torch.cuda.device_count()}") + print(f"Name of GPU: {torch.cuda.get_device_name(0)}") + print("=== Check completed ===") \ No newline at end of file diff --git a/ui.py b/ui.py index 5bbe3e5..3bd2f69 100644 --- a/ui.py +++ b/ui.py @@ -1 +1,13 @@ -# empty(( \ No newline at end of file +import tkinter as tk + +root = tk.Tk() +root.title("Audio Transcriptor") +root.geometry("800x600") + +path_entry = tk.Entry(root, width=40) +path_entry.pack(padx=10, pady=(5, 10)) + +transcribe_button = tk.Button(text="Transcribe") +transcribe_button.pack(anchor="e", rely=25, relx=15) + +root.mainloop() \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..2bf3c0e --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,36 @@ +import logging +import sys + +def setup_logger( + name: str = __name__, + level: int = logging.INFO, + log_to_file: bool = False, + filename: str = "app.log" +) -> logging.Logger: + """ + Logger configuration with output to command line and (optional) to file + + :param name: logger name + :param level: logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + :param log_to_file: logging to filename + :param filename: filename for logs + :return: logger instance + """ + logger = logging.getLogger(name) + logger.setLevel(level) + + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if log_to_file: + file_handler = logging.FileHandler(filename, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger \ No newline at end of file