vnj aadnk commited on
Commit
dbbc71f
·
0 Parent(s):

Duplicate from aadnk/whisper-webui

Browse files

Co-authored-by: Kristian Stangeland <aadnk@users.noreply.huggingface.co>

.gitattributes ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ftz filter=lfs diff=lfs merge=lfs -text
6
+ *.gz filter=lfs diff=lfs merge=lfs -text
7
+ *.h5 filter=lfs diff=lfs merge=lfs -text
8
+ *.joblib filter=lfs diff=lfs merge=lfs -text
9
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
10
+ *.model filter=lfs diff=lfs merge=lfs -text
11
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
12
+ *.npy filter=lfs diff=lfs merge=lfs -text
13
+ *.npz filter=lfs diff=lfs merge=lfs -text
14
+ *.onnx filter=lfs diff=lfs merge=lfs -text
15
+ *.ot filter=lfs diff=lfs merge=lfs -text
16
+ *.parquet filter=lfs diff=lfs merge=lfs -text
17
+ *.pickle filter=lfs diff=lfs merge=lfs -text
18
+ *.pkl filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pt filter=lfs diff=lfs merge=lfs -text
21
+ *.pth filter=lfs diff=lfs merge=lfs -text
22
+ *.rar filter=lfs diff=lfs merge=lfs -text
23
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
24
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
25
+ *.tflite filter=lfs diff=lfs merge=lfs -text
26
+ *.tgz filter=lfs diff=lfs merge=lfs -text
27
+ *.wasm filter=lfs diff=lfs merge=lfs -text
28
+ *.xz filter=lfs diff=lfs merge=lfs -text
29
+ *.zip filter=lfs diff=lfs merge=lfs -text
30
+ *.zst filter=lfs diff=lfs merge=lfs -text
31
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ flagged/
4
+ *.py[cod]
5
+ *$py.class
README.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Whisper Webui
3
+ emoji: ⚡
4
+ colorFrom: pink
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 3.3.1
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ duplicated_from: aadnk/whisper-webui
12
+ ---
13
+
14
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
+
16
+ # Running Locally
17
+
18
+ To run this program locally, first install Python 3.9+ and Git. Then install Pytorch 10.1+ and all the other dependencies:
19
+ ```
20
+ pip install -r requirements.txt
21
+ ```
22
+
23
+ Finally, run the full version (no audio length restrictions) of the app:
24
+ ```
25
+ python app-full.py
26
+ ```
27
+
28
+ You can also run the CLI interface, which is similar to Whisper's own CLI but also supports the following additional arguments:
29
+ ```
30
+ python cli.py \
31
+ [--vad {none,silero-vad,silero-vad-skip-gaps,silero-vad-expand-into-gaps,periodic-vad}] \
32
+ [--vad_merge_window VAD_MERGE_WINDOW] \
33
+ [--vad_max_merge_size VAD_MAX_MERGE_SIZE] \
34
+ [--vad_padding VAD_PADDING] \
35
+ [--vad_prompt_window VAD_PROMPT_WINDOW]
36
+ [--vad_parallel_devices COMMA_DELIMITED_DEVICES]
37
+ ```
38
+ In addition, you may also use URL's in addition to file paths as input.
39
+ ```
40
+ python cli.py --model large --vad silero-vad --language Japanese "https://www.youtube.com/watch?v=4cICErqqRSM"
41
+ ```
42
+
43
+ ## Parallel Execution
44
+
45
+ You can also run both the Web-UI or the CLI on multiple GPUs in parallel, using the `vad_parallel_devices` option. This takes a comma-delimited list of
46
+ device IDs (0, 1, etc.) that Whisper should be distributed to and run on concurrently:
47
+ ```
48
+ python cli.py --model large --vad silero-vad --language Japanese \
49
+ --vad_parallel_devices 0,1 "https://www.youtube.com/watch?v=4cICErqqRSM"
50
+ ```
51
+
52
+ Note that this requires a VAD to function properly, otherwise only the first GPU will be used. Though you could use `period-vad` to avoid taking the hit
53
+ of running Silero-Vad, at a slight cost to accuracy.
54
+
55
+ This is achieved by creating N child processes (where N is the number of selected devices), where Whisper is run concurrently. In `app.py`, you can also
56
+ set the `vad_process_timeout` option. This configures the number of seconds until a process is killed due to inactivity, freeing RAM and video memory.
57
+ The default value is 30 minutes.
58
+
59
+ ```
60
+ python app.py --input_audio_max_duration -1 --vad_parallel_devices 0,1 --vad_process_timeout 3600
61
+ ```
62
+
63
+ You may also use `vad_process_timeout` with a single device (`--vad_parallel_devices 0`), if you prefer to always free video memory after a period of time.
64
+
65
+ # Docker
66
+
67
+ To run it in Docker, first install Docker and optionally the NVIDIA Container Toolkit in order to use the GPU.
68
+ Then either use the GitLab hosted container below, or check out this repository and build an image:
69
+ ```
70
+ sudo docker build -t whisper-webui:1 .
71
+ ```
72
+
73
+ You can then start the WebUI with GPU support like so:
74
+ ```
75
+ sudo docker run -d --gpus=all -p 7860:7860 whisper-webui:1
76
+ ```
77
+
78
+ Leave out "--gpus=all" if you don't have access to a GPU with enough memory, and are fine with running it on the CPU only:
79
+ ```
80
+ sudo docker run -d -p 7860:7860 whisper-webui:1
81
+ ```
82
+
83
+ # GitLab Docker Registry
84
+
85
+ This Docker container is also hosted on GitLab:
86
+
87
+ ```
88
+ sudo docker run -d --gpus=all -p 7860:7860 registry.gitlab.com/aadnk/whisper-webui:latest
89
+ ```
90
+
91
+ ## Custom Arguments
92
+
93
+ You can also pass custom arguments to `app.py` in the Docker container, for instance to be able to use all the GPUs in parallel:
94
+ ```
95
+ sudo docker run -d --gpus all -p 7860:7860 \
96
+ --mount type=bind,source=/home/administrator/.cache/whisper,target=/root/.cache/whisper \
97
+ --restart=on-failure:15 registry.gitlab.com/aadnk/whisper-webui:latest \
98
+ app.py --input_audio_max_duration -1 --server_name 0.0.0.0 --vad_parallel_devices 0,1 \
99
+ --default_vad silero-vad --default_model_name large
100
+ ```
101
+
102
+ You can also call `cli.py` the same way:
103
+ ```
104
+ sudo docker run --gpus all \
105
+ --mount type=bind,source=/home/administrator/.cache/whisper,target=/root/.cache/whisper \
106
+ --mount type=bind,source=${PWD},target=/app/data \
107
+ registry.gitlab.com/aadnk/whisper-webui:latest \
108
+ cli.py --model large --vad_parallel_devices 0,1 --vad silero-vad \
109
+ --output_dir /app/data /app/data/YOUR-FILE-HERE.mp4
110
+ ```
111
+
112
+ ## Caching
113
+
114
+ Note that the models themselves are currently not included in the Docker images, and will be downloaded on the demand.
115
+ To avoid this, bind the directory /root/.cache/whisper to some directory on the host (for instance /home/administrator/.cache/whisper), where you can (optionally)
116
+ prepopulate the directory with the different Whisper models.
117
+ ```
118
+ sudo docker run -d --gpus=all -p 7860:7860 \
119
+ --mount type=bind,source=/home/administrator/.cache/whisper,target=/root/.cache/whisper \
120
+ registry.gitlab.com/aadnk/whisper-webui:latest
121
+ ```
app-local.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Run the app with no audio file restrictions
2
+ from app import create_ui
3
+ create_ui(-1)
app-network.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Run the app with no audio file restrictions, and make it available on the network
2
+ from app import create_ui
3
+ create_ui(-1, server_name="0.0.0.0")
app-shared.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Run the app with no audio file restrictions
2
+ from app import create_ui
3
+ create_ui(-1, share=True)
app.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import Iterator
3
+ import argparse
4
+
5
+ from io import StringIO
6
+ import os
7
+ import pathlib
8
+ import tempfile
9
+ from src.vadParallel import ParallelContext, ParallelTranscription
10
+
11
+ from src.whisperContainer import WhisperContainer, WhisperModelCache
12
+
13
+ # External programs
14
+ import ffmpeg
15
+
16
+ # UI
17
+ import gradio as gr
18
+
19
+ from src.download import ExceededMaximumDuration, download_url
20
+ from src.utils import slugify, write_srt, write_vtt
21
+ from src.vad import AbstractTranscription, NonSpeechStrategy, PeriodicTranscriptionConfig, TranscriptionConfig, VadPeriodicTranscription, VadSileroTranscription
22
+
23
+ # Limitations (set to -1 to disable)
24
+ DEFAULT_INPUT_AUDIO_MAX_DURATION = 600 # seconds
25
+
26
+ # Whether or not to automatically delete all uploaded files, to save disk space
27
+ DELETE_UPLOADED_FILES = True
28
+
29
+ # Gradio seems to truncate files without keeping the extension, so we need to truncate the file prefix ourself
30
+ MAX_FILE_PREFIX_LENGTH = 17
31
+
32
+ LANGUAGES = [
33
+ "English", "Chinese", "German", "Spanish", "Russian", "Korean",
34
+ "French", "Japanese", "Portuguese", "Turkish", "Polish", "Catalan",
35
+ "Dutch", "Arabic", "Swedish", "Italian", "Indonesian", "Hindi",
36
+ "Finnish", "Vietnamese", "Hebrew", "Ukrainian", "Greek", "Malay",
37
+ "Czech", "Romanian", "Danish", "Hungarian", "Tamil", "Norwegian",
38
+ "Thai", "Urdu", "Croatian", "Bulgarian", "Lithuanian", "Latin",
39
+ "Maori", "Malayalam", "Welsh", "Slovak", "Telugu", "Persian",
40
+ "Latvian", "Bengali", "Serbian", "Azerbaijani", "Slovenian",
41
+ "Kannada", "Estonian", "Macedonian", "Breton", "Basque", "Icelandic",
42
+ "Armenian", "Nepali", "Mongolian", "Bosnian", "Kazakh", "Albanian",
43
+ "Swahili", "Galician", "Marathi", "Punjabi", "Sinhala", "Khmer",
44
+ "Shona", "Yoruba", "Somali", "Afrikaans", "Occitan", "Georgian",
45
+ "Belarusian", "Tajik", "Sindhi", "Gujarati", "Amharic", "Yiddish",
46
+ "Lao", "Uzbek", "Faroese", "Haitian Creole", "Pashto", "Turkmen",
47
+ "Nynorsk", "Maltese", "Sanskrit", "Luxembourgish", "Myanmar", "Tibetan",
48
+ "Tagalog", "Malagasy", "Assamese", "Tatar", "Hawaiian", "Lingala",
49
+ "Hausa", "Bashkir", "Javanese", "Sundanese"
50
+ ]
51
+
52
+ class WhisperTranscriber:
53
+ def __init__(self, input_audio_max_duration: float = DEFAULT_INPUT_AUDIO_MAX_DURATION, vad_process_timeout: float = None, delete_uploaded_files: bool = DELETE_UPLOADED_FILES):
54
+ self.model_cache = WhisperModelCache()
55
+ self.parallel_device_list = None
56
+ self.parallel_context = None
57
+ self.vad_process_timeout = vad_process_timeout
58
+
59
+ self.vad_model = None
60
+ self.inputAudioMaxDuration = input_audio_max_duration
61
+ self.deleteUploadedFiles = delete_uploaded_files
62
+
63
+ def set_parallel_devices(self, vad_parallel_devices: str):
64
+ self.parallel_device_list = [ device.strip() for device in vad_parallel_devices.split(",") ] if vad_parallel_devices else None
65
+
66
+ def transcribe_webui(self, modelName, languageName, urlData, uploadFile, microphoneData, task, vad, vadMergeWindow, vadMaxMergeSize, vadPadding, vadPromptWindow):
67
+ try:
68
+ source, sourceName = self.__get_source(urlData, uploadFile, microphoneData)
69
+
70
+ try:
71
+ selectedLanguage = languageName.lower() if len(languageName) > 0 else None
72
+ selectedModel = modelName if modelName is not None else "base"
73
+
74
+ model = WhisperContainer(model_name=selectedModel, cache=self.model_cache)
75
+
76
+ # Execute whisper
77
+ result = self.transcribe_file(model, source, selectedLanguage, task, vad, vadMergeWindow, vadMaxMergeSize, vadPadding, vadPromptWindow)
78
+
79
+ # Write result
80
+ downloadDirectory = tempfile.mkdtemp()
81
+
82
+ filePrefix = slugify(sourceName, allow_unicode=True)
83
+ download, text, vtt = self.write_result(result, filePrefix, downloadDirectory)
84
+
85
+ return download, text, vtt
86
+
87
+ finally:
88
+ # Cleanup source
89
+ if self.deleteUploadedFiles:
90
+ print("Deleting source file " + source)
91
+ os.remove(source)
92
+
93
+ except ExceededMaximumDuration as e:
94
+ return [], ("[ERROR]: Maximum remote video length is " + str(e.maxDuration) + "s, file was " + str(e.videoDuration) + "s"), "[ERROR]"
95
+
96
+ def transcribe_file(self, model: WhisperContainer, audio_path: str, language: str, task: str = None, vad: str = None,
97
+ vadMergeWindow: float = 5, vadMaxMergeSize: float = 150, vadPadding: float = 1, vadPromptWindow: float = 1, **decodeOptions: dict):
98
+
99
+ initial_prompt = decodeOptions.pop('initial_prompt', None)
100
+
101
+ if ('task' in decodeOptions):
102
+ task = decodeOptions.pop('task')
103
+
104
+ # Callable for processing an audio file
105
+ whisperCallable = model.create_callback(language, task, initial_prompt, **decodeOptions)
106
+
107
+ # The results
108
+ if (vad == 'silero-vad'):
109
+ # Silero VAD where non-speech gaps are transcribed
110
+ process_gaps = self._create_silero_config(NonSpeechStrategy.CREATE_SEGMENT, vadMergeWindow, vadMaxMergeSize, vadPadding, vadPromptWindow)
111
+ result = self.process_vad(audio_path, whisperCallable, self.vad_model, process_gaps)
112
+ elif (vad == 'silero-vad-skip-gaps'):
113
+ # Silero VAD where non-speech gaps are simply ignored
114
+ skip_gaps = self._create_silero_config(NonSpeechStrategy.SKIP, vadMergeWindow, vadMaxMergeSize, vadPadding, vadPromptWindow)
115
+ result = self.process_vad(audio_path, whisperCallable, self.vad_model, skip_gaps)
116
+ elif (vad == 'silero-vad-expand-into-gaps'):
117
+ # Use Silero VAD where speech-segments are expanded into non-speech gaps
118
+ expand_gaps = self._create_silero_config(NonSpeechStrategy.EXPAND_SEGMENT, vadMergeWindow, vadMaxMergeSize, vadPadding, vadPromptWindow)
119
+ result = self.process_vad(audio_path, whisperCallable, self.vad_model, expand_gaps)
120
+ elif (vad == 'periodic-vad'):
121
+ # Very simple VAD - mark every 5 minutes as speech. This makes it less likely that Whisper enters an infinite loop, but
122
+ # it may create a break in the middle of a sentence, causing some artifacts.
123
+ periodic_vad = VadPeriodicTranscription()
124
+ period_config = PeriodicTranscriptionConfig(periodic_duration=vadMaxMergeSize, max_prompt_window=vadPromptWindow)
125
+ result = self.process_vad(audio_path, whisperCallable, periodic_vad, period_config)
126
+
127
+ else:
128
+ if (self._has_parallel_devices()):
129
+ # Use a simple period transcription instead, as we need to use the parallel context
130
+ periodic_vad = VadPeriodicTranscription()
131
+ period_config = PeriodicTranscriptionConfig(periodic_duration=math.inf, max_prompt_window=1)
132
+
133
+ result = self.process_vad(audio_path, whisperCallable, periodic_vad, period_config)
134
+ else:
135
+ # Default VAD
136
+ result = whisperCallable(audio_path, 0, None, None)
137
+
138
+ return result
139
+
140
+ def process_vad(self, audio_path, whisperCallable, vadModel: AbstractTranscription, vadConfig: TranscriptionConfig):
141
+ if (not self._has_parallel_devices()):
142
+ # No parallel devices, so just run the VAD and Whisper in sequence
143
+ return vadModel.transcribe(audio_path, whisperCallable, vadConfig)
144
+
145
+ # Create parallel context if needed
146
+ if (self.parallel_context is None):
147
+ # Create a context wih processes and automatically clear the pool after 1 hour of inactivity
148
+ self.parallel_context = ParallelContext(num_processes=len(self.parallel_device_list), auto_cleanup_timeout_seconds=self.vad_process_timeout)
149
+
150
+ parallel_vad = ParallelTranscription()
151
+ return parallel_vad.transcribe_parallel(transcription=vadModel, audio=audio_path, whisperCallable=whisperCallable,
152
+ config=vadConfig, devices=self.parallel_device_list, parallel_context=self.parallel_context)
153
+
154
+ def _has_parallel_devices(self):
155
+ return self.parallel_device_list is not None and len(self.parallel_device_list) > 0
156
+
157
+ def _concat_prompt(self, prompt1, prompt2):
158
+ if (prompt1 is None):
159
+ return prompt2
160
+ elif (prompt2 is None):
161
+ return prompt1
162
+ else:
163
+ return prompt1 + " " + prompt2
164
+
165
+ def _create_silero_config(self, non_speech_strategy: NonSpeechStrategy, vadMergeWindow: float = 5, vadMaxMergeSize: float = 150, vadPadding: float = 1, vadPromptWindow: float = 1):
166
+ # Use Silero VAD
167
+ if (self.vad_model is None):
168
+ self.vad_model = VadSileroTranscription()
169
+
170
+ config = TranscriptionConfig(non_speech_strategy = non_speech_strategy,
171
+ max_silent_period=vadMergeWindow, max_merge_size=vadMaxMergeSize,
172
+ segment_padding_left=vadPadding, segment_padding_right=vadPadding,
173
+ max_prompt_window=vadPromptWindow)
174
+
175
+ return config
176
+
177
+ def write_result(self, result: dict, source_name: str, output_dir: str):
178
+ if not os.path.exists(output_dir):
179
+ os.makedirs(output_dir)
180
+
181
+ text = result["text"]
182
+ language = result["language"]
183
+ languageMaxLineWidth = self.__get_max_line_width(language)
184
+
185
+ print("Max line width " + str(languageMaxLineWidth))
186
+ vtt = self.__get_subs(result["segments"], "vtt", languageMaxLineWidth)
187
+ srt = self.__get_subs(result["segments"], "srt", languageMaxLineWidth)
188
+
189
+ output_files = []
190
+ output_files.append(self.__create_file(srt, output_dir, source_name + "-subs.srt"));
191
+ output_files.append(self.__create_file(vtt, output_dir, source_name + "-subs.vtt"));
192
+ output_files.append(self.__create_file(text, output_dir, source_name + "-transcript.txt"));
193
+
194
+ return output_files, text, vtt
195
+
196
+ def clear_cache(self):
197
+ self.model_cache.clear()
198
+ self.vad_model = None
199
+
200
+ def __get_source(self, urlData, uploadFile, microphoneData):
201
+ if urlData:
202
+ # Download from YouTube
203
+ source = download_url(urlData, self.inputAudioMaxDuration)[0]
204
+ else:
205
+ # File input
206
+ source = uploadFile if uploadFile is not None else microphoneData
207
+
208
+ if self.inputAudioMaxDuration > 0:
209
+ # Calculate audio length
210
+ audioDuration = ffmpeg.probe(source)["format"]["duration"]
211
+
212
+ if float(audioDuration) > self.inputAudioMaxDuration:
213
+ raise ExceededMaximumDuration(videoDuration=audioDuration, maxDuration=self.inputAudioMaxDuration, message="Video is too long")
214
+
215
+ file_path = pathlib.Path(source)
216
+ sourceName = file_path.stem[:MAX_FILE_PREFIX_LENGTH] + file_path.suffix
217
+
218
+ return source, sourceName
219
+
220
+ def __get_max_line_width(self, language: str) -> int:
221
+ if (language and language.lower() in ["japanese", "ja", "chinese", "zh"]):
222
+ # Chinese characters and kana are wider, so limit line length to 40 characters
223
+ return 40
224
+ else:
225
+ # TODO: Add more languages
226
+ # 80 latin characters should fit on a 1080p/720p screen
227
+ return 80
228
+
229
+ def __get_subs(self, segments: Iterator[dict], format: str, maxLineWidth: int) -> str:
230
+ segmentStream = StringIO()
231
+
232
+ if format == 'vtt':
233
+ write_vtt(segments, file=segmentStream, maxLineWidth=maxLineWidth)
234
+ elif format == 'srt':
235
+ write_srt(segments, file=segmentStream, maxLineWidth=maxLineWidth)
236
+ else:
237
+ raise Exception("Unknown format " + format)
238
+
239
+ segmentStream.seek(0)
240
+ return segmentStream.read()
241
+
242
+ def __create_file(self, text: str, directory: str, fileName: str) -> str:
243
+ # Write the text to a file
244
+ with open(os.path.join(directory, fileName), 'w+', encoding="utf-8") as file:
245
+ file.write(text)
246
+
247
+ return file.name
248
+
249
+ def close(self):
250
+ self.clear_cache()
251
+
252
+ if (self.parallel_context is not None):
253
+ self.parallel_context.close()
254
+
255
+
256
+ def create_ui(input_audio_max_duration, share=False, server_name: str = None, server_port: int = 7860,
257
+ default_model_name: str = "medium", default_vad: str = None, vad_parallel_devices: str = None, vad_process_timeout: float = None):
258
+ ui = WhisperTranscriber(input_audio_max_duration, vad_process_timeout)
259
+
260
+ # Specify a list of devices to use for parallel processing
261
+ ui.set_parallel_devices(vad_parallel_devices)
262
+
263
+ ui_description = "Whisper is a general-purpose speech recognition model. It is trained on a large dataset of diverse "
264
+ ui_description += " audio and is also a multi-task model that can perform multilingual speech recognition "
265
+ ui_description += " as well as speech translation and language identification. "
266
+
267
+ ui_description += "\n\n\n\nFor longer audio files (>10 minutes) not in English, it is recommended that you select Silero VAD (Voice Activity Detector) in the VAD option."
268
+
269
+ if input_audio_max_duration > 0:
270
+ ui_description += "\n\n" + "Max audio file length: " + str(input_audio_max_duration) + " s"
271
+
272
+ ui_article = "Read the [documentation here](https://huggingface.co/spaces/aadnk/whisper-webui/blob/main/docs/options.md)"
273
+
274
+ demo = gr.Interface(fn=ui.transcribe_webui, description=ui_description, article=ui_article, inputs=[
275
+ gr.Dropdown(choices=["tiny", "base", "small", "medium", "large"], value=default_model_name, label="Model"),
276
+ gr.Dropdown(choices=sorted(LANGUAGES), label="Language"),
277
+ gr.Text(label="URL (YouTube, etc.)"),
278
+ gr.Audio(source="upload", type="filepath", label="Upload Audio"),
279
+ gr.Audio(source="microphone", type="filepath", label="Microphone Input"),
280
+ gr.Dropdown(choices=["transcribe", "translate"], label="Task"),
281
+ gr.Dropdown(choices=["none", "silero-vad", "silero-vad-skip-gaps", "silero-vad-expand-into-gaps", "periodic-vad"], value=default_vad, label="VAD"),
282
+ gr.Number(label="VAD - Merge Window (s)", precision=0, value=5),
283
+ gr.Number(label="VAD - Max Merge Size (s)", precision=0, value=30),
284
+ gr.Number(label="VAD - Padding (s)", precision=None, value=1),
285
+ gr.Number(label="VAD - Prompt Window (s)", precision=None, value=3)
286
+ ], outputs=[
287
+ gr.File(label="Download"),
288
+ gr.Text(label="Transcription"),
289
+ gr.Text(label="Segments")
290
+ ])
291
+
292
+ demo.launch(share=share, server_name=server_name, server_port=server_port)
293
+
294
+ # Clean up
295
+ ui.close()
296
+
297
+ if __name__ == '__main__':
298
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
299
+ parser.add_argument("--input_audio_max_duration", type=int, default=DEFAULT_INPUT_AUDIO_MAX_DURATION, help="Maximum audio file length in seconds, or -1 for no limit.")
300
+ parser.add_argument("--share", type=bool, default=False, help="True to share the app on HuggingFace.")
301
+ parser.add_argument("--server_name", type=str, default=None, help="The host or IP to bind to. If None, bind to localhost.")
302
+ parser.add_argument("--server_port", type=int, default=7860, help="The port to bind to.")
303
+ parser.add_argument("--default_model_name", type=str, default="medium", help="The default model name.")
304
+ parser.add_argument("--default_vad", type=str, default="silero-vad", help="The default VAD.")
305
+ parser.add_argument("--vad_parallel_devices", type=str, default="", help="A commma delimited list of CUDA devices to use for parallel processing. If None, disable parallel processing.")
306
+ parser.add_argument("--vad_process_timeout", type=float, default="1800", help="The number of seconds before inactivate processes are terminated. Use 0 to close processes immediately, or None for no timeout.")
307
+
308
+ args = parser.parse_args().__dict__
309
+ create_ui(**args)
cli.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import pathlib
4
+ from urllib.parse import urlparse
5
+ import warnings
6
+ import numpy as np
7
+
8
+ import whisper
9
+
10
+ import torch
11
+ from app import LANGUAGES, WhisperTranscriber
12
+ from src.download import download_url
13
+
14
+ from src.utils import optional_float, optional_int, str2bool
15
+ from src.whisperContainer import WhisperContainer
16
+
17
+
18
+ def cli():
19
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
20
+ parser.add_argument("audio", nargs="+", type=str, help="audio file(s) to transcribe")
21
+ parser.add_argument("--model", default="small", choices=["tiny", "base", "small", "medium", "large"], help="name of the Whisper model to use")
22
+ parser.add_argument("--model_dir", type=str, default=None, help="the path to save model files; uses ~/.cache/whisper by default")
23
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu", help="device to use for PyTorch inference")
24
+ parser.add_argument("--output_dir", "-o", type=str, default=".", help="directory to save the outputs")
25
+ parser.add_argument("--verbose", type=str2bool, default=True, help="whether to print out the progress and debug messages")
26
+
27
+ parser.add_argument("--task", type=str, default="transcribe", choices=["transcribe", "translate"], help="whether to perform X->X speech recognition ('transcribe') or X->English translation ('translate')")
28
+ parser.add_argument("--language", type=str, default=None, choices=sorted(LANGUAGES), help="language spoken in the audio, specify None to perform language detection")
29
+
30
+ parser.add_argument("--vad", type=str, default="none", choices=["none", "silero-vad", "silero-vad-skip-gaps", "silero-vad-expand-into-gaps", "periodic-vad"], help="The voice activity detection algorithm to use")
31
+ parser.add_argument("--vad_merge_window", type=optional_float, default=5, help="The window size (in seconds) to merge voice segments")
32
+ parser.add_argument("--vad_max_merge_size", type=optional_float, default=30, help="The maximum size (in seconds) of a voice segment")
33
+ parser.add_argument("--vad_padding", type=optional_float, default=1, help="The padding (in seconds) to add to each voice segment")
34
+ parser.add_argument("--vad_prompt_window", type=optional_float, default=3, help="The window size of the prompt to pass to Whisper")
35
+ parser.add_argument("--vad_parallel_devices", type=str, default="", help="A commma delimited list of CUDA devices to use for parallel processing. If None, disable parallel processing.")
36
+
37
+ parser.add_argument("--temperature", type=float, default=0, help="temperature to use for sampling")
38
+ parser.add_argument("--best_of", type=optional_int, default=5, help="number of candidates when sampling with non-zero temperature")
39
+ parser.add_argument("--beam_size", type=optional_int, default=5, help="number of beams in beam search, only applicable when temperature is zero")
40
+ parser.add_argument("--patience", type=float, default=None, help="optional patience value to use in beam decoding, as in https://arxiv.org/abs/2204.05424, the default (1.0) is equivalent to conventional beam search")
41
+ parser.add_argument("--length_penalty", type=float, default=None, help="optional token length penalty coefficient (alpha) as in https://arxiv.org/abs/1609.08144, uses simple lengt normalization by default")
42
+
43
+ parser.add_argument("--suppress_tokens", type=str, default="-1", help="comma-separated list of token ids to suppress during sampling; '-1' will suppress most special characters except common punctuations")
44
+ parser.add_argument("--initial_prompt", type=str, default=None, help="optional text to provide as a prompt for the first window.")
45
+ parser.add_argument("--condition_on_previous_text", type=str2bool, default=True, help="if True, provide the previous output of the model as a prompt for the next window; disabling may make the text inconsistent across windows, but the model becomes less prone to getting stuck in a failure loop")
46
+ parser.add_argument("--fp16", type=str2bool, default=True, help="whether to perform inference in fp16; True by default")
47
+
48
+ parser.add_argument("--temperature_increment_on_fallback", type=optional_float, default=0.2, help="temperature to increase when falling back when the decoding fails to meet either of the thresholds below")
49
+ parser.add_argument("--compression_ratio_threshold", type=optional_float, default=2.4, help="if the gzip compression ratio is higher than this value, treat the decoding as failed")
50
+ parser.add_argument("--logprob_threshold", type=optional_float, default=-1.0, help="if the average log probability is lower than this value, treat the decoding as failed")
51
+ parser.add_argument("--no_speech_threshold", type=optional_float, default=0.6, help="if the probability of the <|nospeech|> token is higher than this value AND the decoding has failed due to `logprob_threshold`, consider the segment as silence")
52
+
53
+ args = parser.parse_args().__dict__
54
+ model_name: str = args.pop("model")
55
+ model_dir: str = args.pop("model_dir")
56
+ output_dir: str = args.pop("output_dir")
57
+ device: str = args.pop("device")
58
+ os.makedirs(output_dir, exist_ok=True)
59
+
60
+ if model_name.endswith(".en") and args["language"] not in {"en", "English"}:
61
+ warnings.warn(f"{model_name} is an English-only model but receipted '{args['language']}'; using English instead.")
62
+ args["language"] = "en"
63
+
64
+ temperature = args.pop("temperature")
65
+ temperature_increment_on_fallback = args.pop("temperature_increment_on_fallback")
66
+ if temperature_increment_on_fallback is not None:
67
+ temperature = tuple(np.arange(temperature, 1.0 + 1e-6, temperature_increment_on_fallback))
68
+ else:
69
+ temperature = [temperature]
70
+
71
+ vad = args.pop("vad")
72
+ vad_merge_window = args.pop("vad_merge_window")
73
+ vad_max_merge_size = args.pop("vad_max_merge_size")
74
+ vad_padding = args.pop("vad_padding")
75
+ vad_prompt_window = args.pop("vad_prompt_window")
76
+
77
+ model = WhisperContainer(model_name, device=device, download_root=model_dir)
78
+ transcriber = WhisperTranscriber(delete_uploaded_files=False)
79
+ transcriber.set_parallel_devices(args.pop("vad_parallel_devices"))
80
+
81
+ if (transcriber._has_parallel_devices()):
82
+ print("Using parallel devices:", transcriber.parallel_device_list)
83
+
84
+ for audio_path in args.pop("audio"):
85
+ sources = []
86
+
87
+ # Detect URL and download the audio
88
+ if (uri_validator(audio_path)):
89
+ # Download from YouTube/URL directly
90
+ for source_path in download_url(audio_path, maxDuration=-1, destinationDirectory=output_dir, playlistItems=None):
91
+ source_name = os.path.basename(source_path)
92
+ sources.append({ "path": source_path, "name": source_name })
93
+ else:
94
+ sources.append({ "path": audio_path, "name": os.path.basename(audio_path) })
95
+
96
+ for source in sources:
97
+ source_path = source["path"]
98
+ source_name = source["name"]
99
+
100
+ result = transcriber.transcribe_file(model, source_path, temperature=temperature,
101
+ vad=vad, vadMergeWindow=vad_merge_window, vadMaxMergeSize=vad_max_merge_size,
102
+ vadPadding=vad_padding, vadPromptWindow=vad_prompt_window, **args)
103
+
104
+ transcriber.write_result(result, source_name, output_dir)
105
+
106
+ transcriber.close()
107
+
108
+ def uri_validator(x):
109
+ try:
110
+ result = urlparse(x)
111
+ return all([result.scheme, result.netloc])
112
+ except:
113
+ return False
114
+
115
+ if __name__ == '__main__':
116
+ cli()
dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM huggingface/transformers-pytorch-gpu
2
+ EXPOSE 7860
3
+
4
+ ADD . /opt/whisper-webui/
5
+
6
+ # Latest version of transformers-pytorch-gpu seems to lack tk.
7
+ # Further, pip install fails, so we must upgrade pip first.
8
+ RUN apt-get -y install python3-tk
9
+ RUN python3 -m pip install --upgrade pip &&\
10
+ python3 -m pip install -r /opt/whisper-webui/requirements.txt
11
+
12
+ # Note: Models will be downloaded on demand to the directory /root/.cache/whisper.
13
+ # You can also bind this directory in the container to somewhere on the host.
14
+
15
+ # To be able to see logs in real time
16
+ ENV PYTHONUNBUFFERED=1
17
+
18
+ WORKDIR /opt/whisper-webui/
19
+ ENTRYPOINT ["python3"]
20
+ CMD ["app.py", "--input_audio_max_duration -1", "--server_name 0.0.0.0"]
docs/options.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Options
2
+ To transcribe or translate an audio file, you can either copy an URL from a website (all [websites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)
3
+ supported by YT-DLP will work, including YouTube). Otherwise, upload an audio file (choose "All Files (*.*)"
4
+ in the file selector to select any file type, including video files) or use the microphone.
5
+
6
+ For longer audio files (>10 minutes), it is recommended that you select Silero VAD (Voice Activity Detector) in the VAD option.
7
+
8
+ ## Model
9
+ Select the model that Whisper will use to transcribe the audio:
10
+
11
+ | Size | Parameters | English-only model | Multilingual model | Required VRAM | Relative speed |
12
+ |--------|------------|--------------------|--------------------|---------------|----------------|
13
+ | tiny | 39 M | tiny.en | tiny | ~1 GB | ~32x |
14
+ | base | 74 M | base.en | base | ~1 GB | ~16x |
15
+ | small | 244 M | small.en | small | ~2 GB | ~6x |
16
+ | medium | 769 M | medium.en | medium | ~5 GB | ~2x |
17
+ | large | 1550 M | N/A | large | ~10 GB | 1x |
18
+
19
+ ## Language
20
+
21
+ Select the language, or leave it empty for Whisper to automatically detect it.
22
+
23
+ Note that if the selected language and the language in the audio differs, Whisper may start to translate the audio to the selected
24
+ language. For instance, if the audio is in English but you select Japaneese, the model may translate the audio to Japanese.
25
+
26
+ ## Inputs
27
+ The options "URL (YouTube, etc.)", "Upload Audio" or "Micriphone Input" allows you to send an audio input to the model.
28
+
29
+ Note that the UI will only process the first valid input - i.e. if you enter both an URL and upload an audio, it will only process
30
+ the URL.
31
+
32
+ ## Task
33
+ Select the task - either "transcribe" to transcribe the audio to text, or "translate" to translate it to English.
34
+
35
+ ## Vad
36
+ Using a VAD will improve the timing accuracy of each transcribed line, as well as prevent Whisper getting into an infinite
37
+ loop detecting the same sentence over and over again. The downside is that this may be at a cost to text accuracy, especially
38
+ with regards to unique words or names that appear in the audio. You can compensate for this by increasing the prompt window.
39
+
40
+ Note that English is very well handled by Whisper, and it's less susceptible to issues surrounding bad timings and infinite loops.
41
+ So you may only need to use a VAD for other languages, such as Japanese, or when the audio is very long.
42
+
43
+ * none
44
+ * Run whisper on the entire audio input
45
+ * silero-vad
46
+ * Use Silero VAD to detect sections that contain speech, and run Whisper on independently on each section. Whisper is also run
47
+ on the gaps between each speech section, by either expanding the section up to the max merge size, or running Whisper independently
48
+ on the non-speech section.
49
+ * silero-vad-expand-into-gaps
50
+ * Use Silero VAD to detect sections that contain speech, and run Whisper on independently on each section. Each spech section will be expanded
51
+ such that they cover any adjacent non-speech sections. For instance, if an audio file of one minute contains the speech sections
52
+ 00:00 - 00:10 (A) and 00:30 - 00:40 (B), the first section (A) will be expanded to 00:00 - 00:30, and (B) will be expanded to 00:30 - 00:60.
53
+ * silero-vad-skip-gaps
54
+ * As above, but sections that doesn't contain speech according to Silero will be skipped. This will be slightly faster, but
55
+ may cause dialogue to be skipped.
56
+ * periodic-vad
57
+ * Create sections of speech every 'VAD - Max Merge Size' seconds. This is very fast and simple, but will potentially break
58
+ a sentence or word in two.
59
+
60
+ ## VAD - Merge Window
61
+ If set, any adjacent speech sections that are at most this number of seconds apart will be automatically merged.
62
+
63
+ ## VAD - Max Merge Size (s)
64
+ Disables merging of adjacent speech sections if they are this number of seconds long.
65
+
66
+ ## VAD - Padding (s)
67
+ The number of seconds (floating point) to add to the beginning and end of each speech section. Setting this to a number
68
+ larger than zero ensures that Whisper is more likely to correctly transcribe a sentence in the beginning of
69
+ a speech section. However, this also increases the probability of Whisper assigning the wrong timestamp
70
+ to each transcribed line. The default value is 1 second.
71
+
72
+ ## VAD - Prompt Window (s)
73
+ The text of a detected line will be included as a prompt to the next speech section, if the speech section starts at most this
74
+ number of seconds after the line has finished. For instance, if a line ends at 10:00, and the next speech section starts at
75
+ 10:04, the line's text will be included if the prompt window is 4 seconds or more (10:04 - 10:00 = 4 seconds).
76
+
77
+ Note that detected lines in gaps between speech sections will not be included in the prompt
78
+ (if silero-vad or silero-vad-expand-into-gaps) is used.
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ git+https://github.com/openai/whisper.git
2
+ transformers
3
+ ffmpeg-python==0.2.0
4
+ gradio
5
+ yt-dlp
6
+ torchaudio
src/__init__.py ADDED
File without changes
src/download.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tempfile import mkdtemp
2
+ from typing import List
3
+ from yt_dlp import YoutubeDL
4
+
5
+ import yt_dlp
6
+ from yt_dlp.postprocessor import PostProcessor
7
+
8
+ class FilenameCollectorPP(PostProcessor):
9
+ def __init__(self):
10
+ super(FilenameCollectorPP, self).__init__(None)
11
+ self.filenames = []
12
+
13
+ def run(self, information):
14
+ self.filenames.append(information["filepath"])
15
+ return [], information
16
+
17
+ def download_url(url: str, maxDuration: int = None, destinationDirectory: str = None, playlistItems: str = "1") -> List[str]:
18
+ try:
19
+ return _perform_download(url, maxDuration=maxDuration, outputTemplate=None, destinationDirectory=destinationDirectory, playlistItems=playlistItems)
20
+ except yt_dlp.utils.DownloadError as e:
21
+ # In case of an OS error, try again with a different output template
22
+ if e.msg and e.msg.find("[Errno 36] File name too long") >= 0:
23
+ return _perform_download(url, maxDuration=maxDuration, outputTemplate="%(title).10s %(id)s.%(ext)s")
24
+ pass
25
+
26
+ def _perform_download(url: str, maxDuration: int = None, outputTemplate: str = None, destinationDirectory: str = None, playlistItems: str = "1"):
27
+ # Create a temporary directory to store the downloaded files
28
+ if destinationDirectory is None:
29
+ destinationDirectory = mkdtemp()
30
+
31
+ ydl_opts = {
32
+ "format": "bestaudio/best",
33
+ 'paths': {
34
+ 'home': destinationDirectory
35
+ }
36
+ }
37
+ if (playlistItems):
38
+ ydl_opts['playlist_items'] = playlistItems
39
+
40
+ # Add output template if specified
41
+ if outputTemplate:
42
+ ydl_opts['outtmpl'] = outputTemplate
43
+
44
+ filename_collector = FilenameCollectorPP()
45
+
46
+ with YoutubeDL(ydl_opts) as ydl:
47
+ if maxDuration and maxDuration > 0:
48
+ info = ydl.extract_info(url, download=False)
49
+ duration = info['duration']
50
+
51
+ if duration >= maxDuration:
52
+ raise ExceededMaximumDuration(videoDuration=duration, maxDuration=maxDuration, message="Video is too long")
53
+
54
+ ydl.add_post_processor(filename_collector)
55
+ ydl.download([url])
56
+
57
+ if len(filename_collector.filenames) <= 0:
58
+ raise Exception("Cannot download " + url)
59
+
60
+ result = []
61
+
62
+ for filename in filename_collector.filenames:
63
+ result.append(filename)
64
+ print("Downloaded " + filename)
65
+
66
+ return result
67
+
68
+ class ExceededMaximumDuration(Exception):
69
+ def __init__(self, videoDuration, maxDuration, message):
70
+ self.videoDuration = videoDuration
71
+ self.maxDuration = maxDuration
72
+ super().__init__(message)
src/segments.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List
2
+
3
+ import copy
4
+
5
+ def merge_timestamps(timestamps: List[Dict[str, Any]], merge_window: float = 5, max_merge_size: float = 30, padding_left: float = 1, padding_right: float = 1):
6
+ result = []
7
+
8
+ if len(timestamps) == 0:
9
+ return result
10
+ if max_merge_size is None:
11
+ return timestamps
12
+
13
+ if padding_left is None:
14
+ padding_left = 0
15
+ if padding_right is None:
16
+ padding_right = 0
17
+
18
+ processed_time = 0
19
+ current_segment = None
20
+
21
+ for i in range(len(timestamps)):
22
+ next_segment = timestamps[i]
23
+
24
+ delta = next_segment['start'] - processed_time
25
+
26
+ # Note that segments can still be longer than the max merge size, they just won't be merged in that case
27
+ if current_segment is None or (merge_window is not None and delta > merge_window) \
28
+ or next_segment['end'] - current_segment['start'] > max_merge_size:
29
+ # Finish the current segment
30
+ if current_segment is not None:
31
+ # Add right padding
32
+ finish_padding = min(padding_right, delta / 2) if delta < padding_left + padding_right else padding_right
33
+ current_segment['end'] += finish_padding
34
+ delta -= finish_padding
35
+
36
+ result.append(current_segment)
37
+
38
+ # Start a new segment
39
+ current_segment = copy.deepcopy(next_segment)
40
+
41
+ # Pad the segment
42
+ current_segment['start'] = current_segment['start'] - min(padding_left, delta)
43
+ processed_time = current_segment['end']
44
+
45
+ else:
46
+ # Merge the segment
47
+ current_segment['end'] = next_segment['end']
48
+ processed_time = current_segment['end']
49
+
50
+ # Add the last segment
51
+ if current_segment is not None:
52
+ current_segment['end'] += padding_right
53
+ result.append(current_segment)
54
+
55
+ return result
src/utils.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import textwrap
2
+ import unicodedata
3
+ import re
4
+
5
+ import zlib
6
+ from typing import Iterator, TextIO
7
+
8
+
9
+ def exact_div(x, y):
10
+ assert x % y == 0
11
+ return x // y
12
+
13
+
14
+ def str2bool(string):
15
+ str2val = {"True": True, "False": False}
16
+ if string in str2val:
17
+ return str2val[string]
18
+ else:
19
+ raise ValueError(f"Expected one of {set(str2val.keys())}, got {string}")
20
+
21
+
22
+ def optional_int(string):
23
+ return None if string == "None" else int(string)
24
+
25
+
26
+ def optional_float(string):
27
+ return None if string == "None" else float(string)
28
+
29
+
30
+ def compression_ratio(text) -> float:
31
+ return len(text) / len(zlib.compress(text.encode("utf-8")))
32
+
33
+
34
+ def format_timestamp(seconds: float, always_include_hours: bool = False, fractionalSeperator: str = '.'):
35
+ assert seconds >= 0, "non-negative timestamp expected"
36
+ milliseconds = round(seconds * 1000.0)
37
+
38
+ hours = milliseconds // 3_600_000
39
+ milliseconds -= hours * 3_600_000
40
+
41
+ minutes = milliseconds // 60_000
42
+ milliseconds -= minutes * 60_000
43
+
44
+ seconds = milliseconds // 1_000
45
+ milliseconds -= seconds * 1_000
46
+
47
+ hours_marker = f"{hours:02d}:" if always_include_hours or hours > 0 else ""
48
+ return f"{hours_marker}{minutes:02d}:{seconds:02d}{fractionalSeperator}{milliseconds:03d}"
49
+
50
+
51
+ def write_txt(transcript: Iterator[dict], file: TextIO):
52
+ for segment in transcript:
53
+ print(segment['text'].strip(), file=file, flush=True)
54
+
55
+
56
+ def write_vtt(transcript: Iterator[dict], file: TextIO, maxLineWidth=None):
57
+ print("WEBVTT\n", file=file)
58
+ for segment in transcript:
59
+ text = process_text(segment['text'], maxLineWidth).replace('-->', '->')
60
+
61
+ print(
62
+ f"{format_timestamp(segment['start'])} --> {format_timestamp(segment['end'])}\n"
63
+ f"{text}\n",
64
+ file=file,
65
+ flush=True,
66
+ )
67
+
68
+
69
+ def write_srt(transcript: Iterator[dict], file: TextIO, maxLineWidth=None):
70
+ """
71
+ Write a transcript to a file in SRT format.
72
+ Example usage:
73
+ from pathlib import Path
74
+ from whisper.utils import write_srt
75
+ result = transcribe(model, audio_path, temperature=temperature, **args)
76
+ # save SRT
77
+ audio_basename = Path(audio_path).stem
78
+ with open(Path(output_dir) / (audio_basename + ".srt"), "w", encoding="utf-8") as srt:
79
+ write_srt(result["segments"], file=srt)
80
+ """
81
+ for i, segment in enumerate(transcript, start=1):
82
+ text = process_text(segment['text'].strip(), maxLineWidth).replace('-->', '->')
83
+
84
+ # write srt lines
85
+ print(
86
+ f"{i}\n"
87
+ f"{format_timestamp(segment['start'], always_include_hours=True, fractionalSeperator=',')} --> "
88
+ f"{format_timestamp(segment['end'], always_include_hours=True, fractionalSeperator=',')}\n"
89
+ f"{text}\n",
90
+ file=file,
91
+ flush=True,
92
+ )
93
+
94
+ def process_text(text: str, maxLineWidth=None):
95
+ if (maxLineWidth is None or maxLineWidth < 0):
96
+ return text
97
+
98
+ lines = textwrap.wrap(text, width=maxLineWidth, tabsize=4)
99
+ return '\n'.join(lines)
100
+
101
+ def slugify(value, allow_unicode=False):
102
+ """
103
+ Taken from https://github.com/django/django/blob/master/django/utils/text.py
104
+ Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
105
+ dashes to single dashes. Remove characters that aren't alphanumerics,
106
+ underscores, or hyphens. Convert to lowercase. Also strip leading and
107
+ trailing whitespace, dashes, and underscores.
108
+ """
109
+ value = str(value)
110
+ if allow_unicode:
111
+ value = unicodedata.normalize('NFKC', value)
112
+ else:
113
+ value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
114
+ value = re.sub(r'[^\w\s-]', '', value.lower())
115
+ return re.sub(r'[-\s]+', '-', value).strip('-_')
src/vad.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from collections import Counter, deque
3
+
4
+ from typing import Any, Deque, Iterator, List, Dict
5
+
6
+ from pprint import pprint
7
+
8
+ from src.segments import merge_timestamps
9
+ from src.whisperContainer import WhisperCallback
10
+
11
+ # Workaround for https://github.com/tensorflow/tensorflow/issues/48797
12
+ try:
13
+ import tensorflow as tf
14
+ except ModuleNotFoundError:
15
+ # Error handling
16
+ pass
17
+
18
+ import torch
19
+
20
+ import ffmpeg
21
+ import numpy as np
22
+
23
+ from src.utils import format_timestamp
24
+ from enum import Enum
25
+
26
+ class NonSpeechStrategy(Enum):
27
+ """
28
+ Ignore non-speech frames segments.
29
+ """
30
+ SKIP = 1
31
+ """
32
+ Just treat non-speech segments as speech.
33
+ """
34
+ CREATE_SEGMENT = 2
35
+ """
36
+ Expand speech segments into subsequent non-speech segments.
37
+ """
38
+ EXPAND_SEGMENT = 3
39
+
40
+ # Defaults for Silero
41
+ SPEECH_TRESHOLD = 0.3
42
+
43
+ # Minimum size of segments to process
44
+ MIN_SEGMENT_DURATION = 1
45
+
46
+ # The maximum time for texts from old segments to be used in the next segment
47
+ MAX_PROMPT_WINDOW = 0 # seconds (0 = disabled)
48
+ PROMPT_NO_SPEECH_PROB = 0.1 # Do not pass the text from segments with a no speech probability higher than this
49
+
50
+ VAD_MAX_PROCESSING_CHUNK = 60 * 60 # 60 minutes of audio
51
+
52
+ class TranscriptionConfig(ABC):
53
+ def __init__(self, non_speech_strategy: NonSpeechStrategy = NonSpeechStrategy.SKIP,
54
+ segment_padding_left: float = None, segment_padding_right = None, max_silent_period: float = None,
55
+ max_merge_size: float = None, max_prompt_window: float = None, initial_segment_index = -1):
56
+ self.non_speech_strategy = non_speech_strategy
57
+ self.segment_padding_left = segment_padding_left
58
+ self.segment_padding_right = segment_padding_right
59
+ self.max_silent_period = max_silent_period
60
+ self.max_merge_size = max_merge_size
61
+ self.max_prompt_window = max_prompt_window
62
+ self.initial_segment_index = initial_segment_index
63
+
64
+ class PeriodicTranscriptionConfig(TranscriptionConfig):
65
+ def __init__(self, periodic_duration: float, non_speech_strategy: NonSpeechStrategy = NonSpeechStrategy.SKIP,
66
+ segment_padding_left: float = None, segment_padding_right = None, max_silent_period: float = None,
67
+ max_merge_size: float = None, max_prompt_window: float = None, initial_segment_index = -1):
68
+ super().__init__(non_speech_strategy, segment_padding_left, segment_padding_right, max_silent_period, max_merge_size, max_prompt_window, initial_segment_index)
69
+ self.periodic_duration = periodic_duration
70
+
71
+ class AbstractTranscription(ABC):
72
+ def __init__(self, sampling_rate: int = 16000):
73
+ self.sampling_rate = sampling_rate
74
+
75
+ def get_audio_segment(self, str, start_time: str = None, duration: str = None):
76
+ return load_audio(str, self.sampling_rate, start_time, duration)
77
+
78
+ @abstractmethod
79
+ def get_transcribe_timestamps(self, audio: str, config: TranscriptionConfig):
80
+ """
81
+ Get the start and end timestamps of the sections that should be transcribed by this VAD method.
82
+
83
+ Parameters
84
+ ----------
85
+ audio: str
86
+ The audio file.
87
+ config: TranscriptionConfig
88
+ The transcription configuration.
89
+
90
+ Returns
91
+ -------
92
+ A list of start and end timestamps, in fractional seconds.
93
+ """
94
+ return
95
+
96
+ def get_merged_timestamps(self, audio: str, config: TranscriptionConfig):
97
+ """
98
+ Get the start and end timestamps of the sections that should be transcribed by this VAD method,
99
+ after merging the segments using the specified configuration.
100
+
101
+ Parameters
102
+ ----------
103
+ audio: str
104
+ The audio file.
105
+ config: TranscriptionConfig
106
+ The transcription configuration.
107
+
108
+ Returns
109
+ -------
110
+ A list of start and end timestamps, in fractional seconds.
111
+ """
112
+ seconds_timestamps = self.get_transcribe_timestamps(audio, config)
113
+
114
+ merged = merge_timestamps(seconds_timestamps, config.max_silent_period, config.max_merge_size,
115
+ config.segment_padding_left, config.segment_padding_right)
116
+
117
+ if config.non_speech_strategy != NonSpeechStrategy.SKIP:
118
+ max_audio_duration = get_audio_duration(audio)
119
+
120
+ # Expand segments to include the gaps between them
121
+ if (config.non_speech_strategy == NonSpeechStrategy.CREATE_SEGMENT):
122
+ # When we have a prompt window, we create speech segments betwen each segment if we exceed the merge size
123
+ merged = self.fill_gaps(merged, total_duration=max_audio_duration, max_expand_size=config.max_merge_size)
124
+ elif config.non_speech_strategy == NonSpeechStrategy.EXPAND_SEGMENT:
125
+ # With no prompt window, it is better to just expand the segments (this effectively passes the prompt to the next segment)
126
+ merged = self.expand_gaps(merged, total_duration=max_audio_duration)
127
+ else:
128
+ raise Exception("Unknown non-speech strategy: " + str(config.non_speech_strategy))
129
+
130
+ print("Transcribing non-speech:")
131
+ pprint(merged)
132
+ return merged
133
+
134
+ def transcribe(self, audio: str, whisperCallable: WhisperCallback, config: TranscriptionConfig):
135
+ """
136
+ Transcribe the given audo file.
137
+
138
+ Parameters
139
+ ----------
140
+ audio: str
141
+ The audio file.
142
+ whisperCallable: WhisperCallback
143
+ A callback object to call to transcribe each segment.
144
+
145
+ Returns
146
+ -------
147
+ A list of start and end timestamps, in fractional seconds.
148
+ """
149
+
150
+ # Get speech timestamps from full audio file
151
+ merged = self.get_merged_timestamps(audio, config)
152
+
153
+ # A deque of transcribed segments that is passed to the next segment as a prompt
154
+ prompt_window = deque()
155
+
156
+ print("Processing timestamps:")
157
+ pprint(merged)
158
+
159
+ result = {
160
+ 'text': "",
161
+ 'segments': [],
162
+ 'language': ""
163
+ }
164
+ languageCounter = Counter()
165
+ detected_language = None
166
+
167
+ segment_index = config.initial_segment_index
168
+
169
+ # For each time segment, run whisper
170
+ for segment in merged:
171
+ segment_index += 1
172
+ segment_start = segment['start']
173
+ segment_end = segment['end']
174
+ segment_expand_amount = segment.get('expand_amount', 0)
175
+ segment_gap = segment.get('gap', False)
176
+
177
+ segment_duration = segment_end - segment_start
178
+
179
+ if segment_duration < MIN_SEGMENT_DURATION:
180
+ continue;
181
+
182
+ # Audio to run on Whisper
183
+ segment_audio = self.get_audio_segment(audio, start_time = str(segment_start), duration = str(segment_duration))
184
+ # Previous segments to use as a prompt
185
+ segment_prompt = ' '.join([segment['text'] for segment in prompt_window]) if len(prompt_window) > 0 else None
186
+
187
+ # Detected language
188
+ detected_language = languageCounter.most_common(1)[0][0] if len(languageCounter) > 0 else None
189
+
190
+ print("Running whisper from ", format_timestamp(segment_start), " to ", format_timestamp(segment_end), ", duration: ",
191
+ segment_duration, "expanded: ", segment_expand_amount, "prompt: ", segment_prompt, "language: ", detected_language)
192
+ segment_result = whisperCallable.invoke(segment_audio, segment_index, segment_prompt, detected_language)
193
+
194
+ adjusted_segments = self.adjust_timestamp(segment_result["segments"], adjust_seconds=segment_start, max_source_time=segment_duration)
195
+
196
+ # Propagate expand amount to the segments
197
+ if (segment_expand_amount > 0):
198
+ segment_without_expansion = segment_duration - segment_expand_amount
199
+
200
+ for adjusted_segment in adjusted_segments:
201
+ adjusted_segment_end = adjusted_segment['end']
202
+
203
+ # Add expand amount if the segment got expanded
204
+ if (adjusted_segment_end > segment_without_expansion):
205
+ adjusted_segment["expand_amount"] = adjusted_segment_end - segment_without_expansion
206
+
207
+ # Append to output
208
+ result['text'] += segment_result['text']
209
+ result['segments'].extend(adjusted_segments)
210
+
211
+ # Increment detected language
212
+ if not segment_gap:
213
+ languageCounter[segment_result['language']] += 1
214
+
215
+ # Update prompt window
216
+ self.__update_prompt_window(prompt_window, adjusted_segments, segment_end, segment_gap, config)
217
+
218
+ if detected_language is not None:
219
+ result['language'] = detected_language
220
+
221
+ return result
222
+
223
+ def __update_prompt_window(self, prompt_window: Deque, adjusted_segments: List, segment_end: float, segment_gap: bool, config: TranscriptionConfig):
224
+ if (config.max_prompt_window is not None and config.max_prompt_window > 0):
225
+ # Add segments to the current prompt window (unless it is a speech gap)
226
+ if not segment_gap:
227
+ for segment in adjusted_segments:
228
+ if segment.get('no_speech_prob', 0) <= PROMPT_NO_SPEECH_PROB:
229
+ prompt_window.append(segment)
230
+
231
+ while (len(prompt_window) > 0):
232
+ first_end_time = prompt_window[0].get('end', 0)
233
+ # Time expanded in the segments should be discounted from the prompt window
234
+ first_expand_time = prompt_window[0].get('expand_amount', 0)
235
+
236
+ if (first_end_time - first_expand_time < segment_end - config.max_prompt_window):
237
+ prompt_window.popleft()
238
+ else:
239
+ break
240
+
241
+ def include_gaps(self, segments: Iterator[dict], min_gap_length: float, total_duration: float):
242
+ result = []
243
+ last_end_time = 0
244
+
245
+ for segment in segments:
246
+ segment_start = float(segment['start'])
247
+ segment_end = float(segment['end'])
248
+
249
+ if (last_end_time != segment_start):
250
+ delta = segment_start - last_end_time
251
+
252
+ if (min_gap_length is None or delta >= min_gap_length):
253
+ result.append( { 'start': last_end_time, 'end': segment_start, 'gap': True } )
254
+
255
+ last_end_time = segment_end
256
+ result.append(segment)
257
+
258
+ # Also include total duration if specified
259
+ if (total_duration is not None and last_end_time < total_duration):
260
+ delta = total_duration - segment_start
261
+
262
+ if (min_gap_length is None or delta >= min_gap_length):
263
+ result.append( { 'start': last_end_time, 'end': total_duration, 'gap': True } )
264
+
265
+ return result
266
+
267
+ # Expand the end time of each segment to the start of the next segment
268
+ def expand_gaps(self, segments: List[Dict[str, Any]], total_duration: float):
269
+ result = []
270
+
271
+ if len(segments) == 0:
272
+ return result
273
+
274
+ # Add gap at the beginning if needed
275
+ if (segments[0]['start'] > 0):
276
+ result.append({ 'start': 0, 'end': segments[0]['start'], 'gap': True } )
277
+
278
+ for i in range(len(segments) - 1):
279
+ current_segment = segments[i]
280
+ next_segment = segments[i + 1]
281
+
282
+ delta = next_segment['start'] - current_segment['end']
283
+
284
+ # Expand if the gap actually exists
285
+ if (delta >= 0):
286
+ current_segment = current_segment.copy()
287
+ current_segment['expand_amount'] = delta
288
+ current_segment['end'] = next_segment['start']
289
+
290
+ result.append(current_segment)
291
+
292
+ # Add last segment
293
+ last_segment = segments[-1]
294
+ result.append(last_segment)
295
+
296
+ # Also include total duration if specified
297
+ if (total_duration is not None):
298
+ last_segment = result[-1]
299
+
300
+ if (last_segment['end'] < total_duration):
301
+ last_segment = last_segment.copy()
302
+ last_segment['end'] = total_duration
303
+ result[-1] = last_segment
304
+
305
+ return result
306
+
307
+ def fill_gaps(self, segments: List[Dict[str, Any]], total_duration: float, max_expand_size: float = None):
308
+ result = []
309
+
310
+ if len(segments) == 0:
311
+ return result
312
+
313
+ # Add gap at the beginning if needed
314
+ if (segments[0]['start'] > 0):
315
+ result.append({ 'start': 0, 'end': segments[0]['start'], 'gap': True } )
316
+
317
+ for i in range(len(segments) - 1):
318
+ expanded = False
319
+ current_segment = segments[i]
320
+ next_segment = segments[i + 1]
321
+
322
+ delta = next_segment['start'] - current_segment['end']
323
+
324
+ if (max_expand_size is not None and delta <= max_expand_size):
325
+ # Just expand the current segment
326
+ current_segment = current_segment.copy()
327
+ current_segment['expand_amount'] = delta
328
+ current_segment['end'] = next_segment['start']
329
+ expanded = True
330
+
331
+ result.append(current_segment)
332
+
333
+ # Add a gap to the next segment if needed
334
+ if (delta >= 0 and not expanded):
335
+ result.append({ 'start': current_segment['end'], 'end': next_segment['start'], 'gap': True } )
336
+
337
+ # Add last segment
338
+ last_segment = segments[-1]
339
+ result.append(last_segment)
340
+
341
+ # Also include total duration if specified
342
+ if (total_duration is not None):
343
+ last_segment = result[-1]
344
+
345
+ delta = total_duration - last_segment['end']
346
+
347
+ if (delta > 0):
348
+ if (max_expand_size is not None and delta <= max_expand_size):
349
+ # Expand the last segment
350
+ last_segment = last_segment.copy()
351
+ last_segment['expand_amount'] = delta
352
+ last_segment['end'] = total_duration
353
+ result[-1] = last_segment
354
+ else:
355
+ result.append({ 'start': last_segment['end'], 'end': total_duration, 'gap': True } )
356
+
357
+ return result
358
+
359
+ def adjust_timestamp(self, segments: Iterator[dict], adjust_seconds: float, max_source_time: float = None):
360
+ result = []
361
+
362
+ for segment in segments:
363
+ segment_start = float(segment['start'])
364
+ segment_end = float(segment['end'])
365
+
366
+ # Filter segments?
367
+ if (max_source_time is not None):
368
+ if (segment_start > max_source_time):
369
+ continue
370
+ segment_end = min(max_source_time, segment_end)
371
+
372
+ new_segment = segment.copy()
373
+
374
+ # Add to start and end
375
+ new_segment['start'] = segment_start + adjust_seconds
376
+ new_segment['end'] = segment_end + adjust_seconds
377
+ result.append(new_segment)
378
+ return result
379
+
380
+ def multiply_timestamps(self, timestamps: List[Dict[str, Any]], factor: float):
381
+ result = []
382
+
383
+ for entry in timestamps:
384
+ start = entry['start']
385
+ end = entry['end']
386
+
387
+ result.append({
388
+ 'start': start * factor,
389
+ 'end': end * factor
390
+ })
391
+ return result
392
+
393
+
394
+ class VadSileroTranscription(AbstractTranscription):
395
+ def __init__(self, sampling_rate: int = 16000):
396
+ super().__init__(sampling_rate=sampling_rate)
397
+
398
+ self.model, utils = torch.hub.load(repo_or_dir='snakers4/silero-vad', model='silero_vad')
399
+ (self.get_speech_timestamps, _, _, _, _) = utils
400
+
401
+
402
+ def get_transcribe_timestamps(self, audio: str, config: TranscriptionConfig):
403
+ audio_duration = get_audio_duration(audio)
404
+ result = []
405
+
406
+ # Divide procesisng of audio into chunks
407
+ chunk_start = 0.0
408
+
409
+ while (chunk_start < audio_duration):
410
+ chunk_duration = min(audio_duration - chunk_start, VAD_MAX_PROCESSING_CHUNK)
411
+
412
+ print("Processing VAD in chunk from {} to {}".format(format_timestamp(chunk_start), format_timestamp(chunk_start + chunk_duration)))
413
+ wav = self.get_audio_segment(audio, str(chunk_start), str(chunk_duration))
414
+
415
+ sample_timestamps = self.get_speech_timestamps(wav, self.model, sampling_rate=self.sampling_rate, threshold=SPEECH_TRESHOLD)
416
+ seconds_timestamps = self.multiply_timestamps(sample_timestamps, factor=1 / self.sampling_rate)
417
+ adjusted = self.adjust_timestamp(seconds_timestamps, adjust_seconds=chunk_start, max_source_time=chunk_start + chunk_duration)
418
+
419
+ #pprint(adjusted)
420
+
421
+ result.extend(adjusted)
422
+ chunk_start += chunk_duration
423
+
424
+ return result
425
+
426
+ # A very simple VAD that just marks every N seconds as speech
427
+ class VadPeriodicTranscription(AbstractTranscription):
428
+ def __init__(self, sampling_rate: int = 16000):
429
+ super().__init__(sampling_rate=sampling_rate)
430
+
431
+ def get_transcribe_timestamps(self, audio: str, config: PeriodicTranscriptionConfig):
432
+ # Get duration in seconds
433
+ audio_duration = get_audio_duration(audio)
434
+ result = []
435
+
436
+ # Generate a timestamp every N seconds
437
+ start_timestamp = 0
438
+
439
+ while (start_timestamp < audio_duration):
440
+ end_timestamp = min(start_timestamp + config.periodic_duration, audio_duration)
441
+ segment_duration = end_timestamp - start_timestamp
442
+
443
+ # Minimum duration is 1 second
444
+ if (segment_duration >= 1):
445
+ result.append( { 'start': start_timestamp, 'end': end_timestamp } )
446
+
447
+ start_timestamp = end_timestamp
448
+
449
+ return result
450
+
451
+ def get_audio_duration(file: str):
452
+ return float(ffmpeg.probe(file)["format"]["duration"])
453
+
454
+ def load_audio(file: str, sample_rate: int = 16000,
455
+ start_time: str = None, duration: str = None):
456
+ """
457
+ Open an audio file and read as mono waveform, resampling as necessary
458
+
459
+ Parameters
460
+ ----------
461
+ file: str
462
+ The audio file to open
463
+
464
+ sr: int
465
+ The sample rate to resample the audio if necessary
466
+
467
+ start_time: str
468
+ The start time, using the standard FFMPEG time duration syntax, or None to disable.
469
+
470
+ duration: str
471
+ The duration, using the standard FFMPEG time duration syntax, or None to disable.
472
+
473
+ Returns
474
+ -------
475
+ A NumPy array containing the audio waveform, in float32 dtype.
476
+ """
477
+ try:
478
+ inputArgs = {'threads': 0}
479
+
480
+ if (start_time is not None):
481
+ inputArgs['ss'] = start_time
482
+ if (duration is not None):
483
+ inputArgs['t'] = duration
484
+
485
+ # This launches a subprocess to decode audio while down-mixing and resampling as necessary.
486
+ # Requires the ffmpeg CLI and `ffmpeg-python` package to be installed.
487
+ out, _ = (
488
+ ffmpeg.input(file, **inputArgs)
489
+ .output("-", format="s16le", acodec="pcm_s16le", ac=1, ar=sample_rate)
490
+ .run(cmd="ffmpeg", capture_stdout=True, capture_stderr=True)
491
+ )
492
+ except ffmpeg.Error as e:
493
+ raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}")
494
+
495
+ return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0
src/vadParallel.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import multiprocessing
2
+ import threading
3
+ import time
4
+ from src.vad import AbstractTranscription, TranscriptionConfig
5
+ from src.whisperContainer import WhisperCallback
6
+
7
+ from multiprocessing import Pool
8
+
9
+ from typing import List
10
+ import os
11
+
12
+
13
+ class ParallelContext:
14
+ def __init__(self, num_processes: int = None, auto_cleanup_timeout_seconds: float = None):
15
+ self.num_processes = num_processes
16
+ self.auto_cleanup_timeout_seconds = auto_cleanup_timeout_seconds
17
+ self.lock = threading.Lock()
18
+
19
+ self.ref_count = 0
20
+ self.pool = None
21
+ self.cleanup_timer = None
22
+
23
+ def get_pool(self):
24
+ # Initialize pool lazily
25
+ if (self.pool is None):
26
+ context = multiprocessing.get_context('spawn')
27
+ self.pool = context.Pool(self.num_processes)
28
+
29
+ self.ref_count = self.ref_count + 1
30
+
31
+ if (self.auto_cleanup_timeout_seconds is not None):
32
+ self._stop_auto_cleanup()
33
+
34
+ return self.pool
35
+
36
+ def return_pool(self, pool):
37
+ if (self.pool == pool and self.ref_count > 0):
38
+ self.ref_count = self.ref_count - 1
39
+
40
+ if (self.ref_count == 0):
41
+ if (self.auto_cleanup_timeout_seconds is not None):
42
+ self._start_auto_cleanup()
43
+
44
+ def _start_auto_cleanup(self):
45
+ if (self.cleanup_timer is not None):
46
+ self.cleanup_timer.cancel()
47
+ self.cleanup_timer = threading.Timer(self.auto_cleanup_timeout_seconds, self._execute_cleanup)
48
+ self.cleanup_timer.start()
49
+
50
+ print("Started auto cleanup of pool in " + str(self.auto_cleanup_timeout_seconds) + " seconds")
51
+
52
+ def _stop_auto_cleanup(self):
53
+ if (self.cleanup_timer is not None):
54
+ self.cleanup_timer.cancel()
55
+ self.cleanup_timer = None
56
+
57
+ print("Stopped auto cleanup of pool")
58
+
59
+ def _execute_cleanup(self):
60
+ print("Executing cleanup of pool")
61
+
62
+ if (self.ref_count == 0):
63
+ self.close()
64
+
65
+ def close(self):
66
+ self._stop_auto_cleanup()
67
+
68
+ if (self.pool is not None):
69
+ print("Closing pool of " + str(self.num_processes) + " processes")
70
+ self.pool.close()
71
+ self.pool.join()
72
+ self.pool = None
73
+
74
+ class ParallelTranscriptionConfig(TranscriptionConfig):
75
+ def __init__(self, device_id: str, override_timestamps, initial_segment_index, copy: TranscriptionConfig = None):
76
+ super().__init__(copy.non_speech_strategy, copy.segment_padding_left, copy.segment_padding_right, copy.max_silent_period, copy.max_merge_size, copy.max_prompt_window, initial_segment_index)
77
+ self.device_id = device_id
78
+ self.override_timestamps = override_timestamps
79
+
80
+ class ParallelTranscription(AbstractTranscription):
81
+ def __init__(self, sampling_rate: int = 16000):
82
+ super().__init__(sampling_rate=sampling_rate)
83
+
84
+
85
+ def transcribe_parallel(self, transcription: AbstractTranscription, audio: str, whisperCallable: WhisperCallback, config: TranscriptionConfig, devices: List[str], parallel_context: ParallelContext = None):
86
+ # First, get the timestamps for the original audio
87
+ merged = transcription.get_merged_timestamps(audio, config)
88
+
89
+ # Split into a list for each device
90
+ # TODO: Split by time instead of by number of chunks
91
+ merged_split = list(self._split(merged, len(devices)))
92
+
93
+ # Parameters that will be passed to the transcribe function
94
+ parameters = []
95
+ segment_index = config.initial_segment_index
96
+
97
+ for i in range(len(merged_split)):
98
+ device_segment_list = list(merged_split[i])
99
+ device_id = devices[i]
100
+
101
+ if (len(device_segment_list) <= 0):
102
+ continue
103
+
104
+ print("Device " + device_id + " (index " + str(i) + ") has " + str(len(device_segment_list)) + " segments")
105
+
106
+ # Create a new config with the given device ID
107
+ device_config = ParallelTranscriptionConfig(devices[i], device_segment_list, segment_index, config)
108
+ segment_index += len(device_segment_list)
109
+
110
+ parameters.append([audio, whisperCallable, device_config]);
111
+
112
+ merged = {
113
+ 'text': '',
114
+ 'segments': [],
115
+ 'language': None
116
+ }
117
+
118
+ created_context = False
119
+
120
+ # Spawn a separate process for each device
121
+ try:
122
+ if (parallel_context is None):
123
+ parallel_context = ParallelContext(len(devices))
124
+ created_context = True
125
+
126
+ # Get a pool of processes
127
+ pool = parallel_context.get_pool()
128
+
129
+ # Run the transcription in parallel
130
+ results = pool.starmap(self.transcribe, parameters)
131
+
132
+ for result in results:
133
+ # Merge the results
134
+ if (result['text'] is not None):
135
+ merged['text'] += result['text']
136
+ if (result['segments'] is not None):
137
+ merged['segments'].extend(result['segments'])
138
+ if (result['language'] is not None):
139
+ merged['language'] = result['language']
140
+
141
+ finally:
142
+ # Return the pool to the context
143
+ if (parallel_context is not None):
144
+ parallel_context.return_pool(pool)
145
+ # Always close the context if we created it
146
+ if (created_context):
147
+ parallel_context.close()
148
+
149
+ return merged
150
+
151
+ def get_transcribe_timestamps(self, audio: str, config: ParallelTranscriptionConfig):
152
+ return []
153
+
154
+ def get_merged_timestamps(self, audio: str, config: ParallelTranscriptionConfig):
155
+ # Override timestamps that will be processed
156
+ if (config.override_timestamps is not None):
157
+ print("Using override timestamps of size " + str(len(config.override_timestamps)))
158
+ return config.override_timestamps
159
+ return super().get_merged_timestamps(audio, config)
160
+
161
+ def transcribe(self, audio: str, whisperCallable: WhisperCallback, config: ParallelTranscriptionConfig):
162
+ # Override device ID
163
+ if (config.device_id is not None):
164
+ print("Using device " + config.device_id)
165
+ os.environ["CUDA_VISIBLE_DEVICES"] = config.device_id
166
+ return super().transcribe(audio, whisperCallable, config)
167
+
168
+ def _split(self, a, n):
169
+ """Split a list into n approximately equal parts."""
170
+ k, m = divmod(len(a), n)
171
+ return (a[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(n))
172
+
src/whisperContainer.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # External programs
2
+ import whisper
3
+
4
+ class WhisperModelCache:
5
+ def __init__(self):
6
+ self._cache = dict()
7
+
8
+ def get(self, model_name, device: str = None):
9
+ key = model_name + ":" + (device if device else '')
10
+
11
+ result = self._cache.get(key)
12
+
13
+ if result is None:
14
+ print("Loading whisper model " + model_name)
15
+ result = whisper.load_model(name=model_name, device=device)
16
+ self._cache[key] = result
17
+ return result
18
+
19
+ def clear(self):
20
+ self._cache.clear()
21
+
22
+ # A global cache of models. This is mainly used by the daemon processes to avoid loading the same model multiple times.
23
+ GLOBAL_WHISPER_MODEL_CACHE = WhisperModelCache()
24
+
25
+ class WhisperContainer:
26
+ def __init__(self, model_name: str, device: str = None, download_root: str = None, cache: WhisperModelCache = None):
27
+ self.model_name = model_name
28
+ self.device = device
29
+ self.download_root = download_root
30
+ self.cache = cache
31
+
32
+ # Will be created on demand
33
+ self.model = None
34
+
35
+ def get_model(self):
36
+ if self.model is None:
37
+
38
+ if (self.cache is None):
39
+ print("Loading whisper model " + self.model_name)
40
+ self.model = whisper.load_model(self.model_name, device=self.device, download_root=self.download_root)
41
+ else:
42
+ self.model = self.cache.get(self.model_name, device=self.device)
43
+ return self.model
44
+
45
+ def create_callback(self, language: str = None, task: str = None, initial_prompt: str = None, **decodeOptions: dict):
46
+ """
47
+ Create a WhisperCallback object that can be used to transcript audio files.
48
+
49
+ Parameters
50
+ ----------
51
+ language: str
52
+ The target language of the transcription. If not specified, the language will be inferred from the audio content.
53
+ task: str
54
+ The task - either translate or transcribe.
55
+ initial_prompt: str
56
+ The initial prompt to use for the transcription.
57
+ decodeOptions: dict
58
+ Additional options to pass to the decoder. Must be pickleable.
59
+
60
+ Returns
61
+ -------
62
+ A WhisperCallback object.
63
+ """
64
+ return WhisperCallback(self, language=language, task=task, initial_prompt=initial_prompt, **decodeOptions)
65
+
66
+ # This is required for multiprocessing
67
+ def __getstate__(self):
68
+ return { "model_name": self.model_name, "device": self.device }
69
+
70
+ def __setstate__(self, state):
71
+ self.model_name = state["model_name"]
72
+ self.device = state["device"]
73
+ self.model = None
74
+ # Depickled objects must use the global cache
75
+ self.cache = GLOBAL_WHISPER_MODEL_CACHE
76
+
77
+
78
+ class WhisperCallback:
79
+ def __init__(self, model_container: WhisperContainer, language: str = None, task: str = None, initial_prompt: str = None, **decodeOptions: dict):
80
+ self.model_container = model_container
81
+ self.language = language
82
+ self.task = task
83
+ self.initial_prompt = initial_prompt
84
+ self.decodeOptions = decodeOptions
85
+
86
+ def invoke(self, audio, segment_index: int, prompt: str, detected_language: str):
87
+ """
88
+ Peform the transcription of the given audio file or data.
89
+
90
+ Parameters
91
+ ----------
92
+ audio: Union[str, np.ndarray, torch.Tensor]
93
+ The audio file to transcribe, or the audio data as a numpy array or torch tensor.
94
+ segment_index: int
95
+ The target language of the transcription. If not specified, the language will be inferred from the audio content.
96
+ task: str
97
+ The task - either translate or transcribe.
98
+ prompt: str
99
+ The prompt to use for the transcription.
100
+ detected_language: str
101
+ The detected language of the audio file.
102
+
103
+ Returns
104
+ -------
105
+ The result of the Whisper call.
106
+ """
107
+ model = self.model_container.get_model()
108
+
109
+ return model.transcribe(audio, \
110
+ language=self.language if self.language else detected_language, task=self.task, \
111
+ initial_prompt=self._concat_prompt(self.initial_prompt, prompt) if segment_index == 0 else prompt, \
112
+ **self.decodeOptions)
113
+
114
+ def _concat_prompt(self, prompt1, prompt2):
115
+ if (prompt1 is None):
116
+ return prompt2
117
+ elif (prompt2 is None):
118
+ return prompt1
119
+ else:
120
+ return prompt1 + " " + prompt2
tests/segments_test.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+
4
+ sys.path.append('../whisper-webui')
5
+
6
+ from src.segments import merge_timestamps
7
+
8
+ class TestSegments(unittest.TestCase):
9
+ def __init__(self, *args, **kwargs):
10
+ super(TestSegments, self).__init__(*args, **kwargs)
11
+
12
+ def test_merge_segments(self):
13
+ segments = [
14
+ {'start': 10.0, 'end': 20.0},
15
+ {'start': 22.0, 'end': 27.0},
16
+ {'start': 31.0, 'end': 35.0},
17
+ {'start': 45.0, 'end': 60.0},
18
+ {'start': 61.0, 'end': 65.0},
19
+ {'start': 68.0, 'end': 98.0},
20
+ {'start': 100.0, 'end': 102.0},
21
+ {'start': 110.0, 'end': 112.0}
22
+ ]
23
+
24
+ result = merge_timestamps(segments, merge_window=5, max_merge_size=30, padding_left=1, padding_right=1)
25
+
26
+ self.assertListEqual(result, [
27
+ {'start': 9.0, 'end': 36.0},
28
+ {'start': 44.0, 'end': 66.0},
29
+ {'start': 67.0, 'end': 99.0},
30
+ {'start': 99.0, 'end': 103.0},
31
+ {'start': 109.0, 'end': 113.0}
32
+ ])
33
+
34
+ def test_overlap_next(self):
35
+ segments = [
36
+ {'start': 5.0, 'end': 39.182},
37
+ {'start': 39.986, 'end': 40.814}
38
+ ]
39
+
40
+ result = merge_timestamps(segments, merge_window=5, max_merge_size=30, padding_left=1, padding_right=1)
41
+
42
+ self.assertListEqual(result, [
43
+ {'start': 4.0, 'end': 39.584},
44
+ {'start': 39.584, 'end': 41.814}
45
+ ])
46
+
47
+ if __name__ == '__main__':
48
+ unittest.main()
tests/vad_test.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pprint
2
+ import unittest
3
+ import numpy as np
4
+ import sys
5
+
6
+ sys.path.append('../whisper-webui')
7
+
8
+ from src.vad import AbstractTranscription, VadSileroTranscription
9
+
10
+ class TestVad(unittest.TestCase):
11
+ def __init__(self, *args, **kwargs):
12
+ super(TestVad, self).__init__(*args, **kwargs)
13
+ self.transcribe_calls = []
14
+
15
+ def test_transcript(self):
16
+ mock = MockVadTranscription()
17
+
18
+ self.transcribe_calls.clear()
19
+ result = mock.transcribe("mock", lambda segment : self.transcribe_segments(segment))
20
+
21
+ self.assertListEqual(self.transcribe_calls, [
22
+ [30, 30],
23
+ [100, 100]
24
+ ])
25
+
26
+ self.assertListEqual(result['segments'],
27
+ [{'end': 50.0, 'start': 40.0, 'text': 'Hello world '},
28
+ {'end': 120.0, 'start': 110.0, 'text': 'Hello world '}]
29
+ )
30
+
31
+ def transcribe_segments(self, segment):
32
+ self.transcribe_calls.append(segment.tolist())
33
+
34
+ # Dummy text
35
+ return {
36
+ 'text': "Hello world ",
37
+ 'segments': [
38
+ {
39
+ "start": 10.0,
40
+ "end": 20.0,
41
+ "text": "Hello world "
42
+ }
43
+ ],
44
+ 'language': ""
45
+ }
46
+
47
+ class MockVadTranscription(AbstractTranscription):
48
+ def __init__(self):
49
+ super().__init__()
50
+
51
+ def get_audio_segment(self, str, start_time: str = None, duration: str = None):
52
+ start_time_seconds = float(start_time.removesuffix("s"))
53
+ duration_seconds = float(duration.removesuffix("s"))
54
+
55
+ # For mocking, this just returns a simple numppy array
56
+ return np.array([start_time_seconds, duration_seconds], dtype=np.float64)
57
+
58
+ def get_transcribe_timestamps(self, audio: str):
59
+ result = []
60
+
61
+ result.append( { 'start': 30, 'end': 60 } )
62
+ result.append( { 'start': 100, 'end': 200 } )
63
+ return result
64
+
65
+ if __name__ == '__main__':
66
+ unittest.main()