103 Commits

Author SHA1 Message Date
makaveli 8d77f0fa5a bump version v0.4.0 2024-03-20 12:04:38 +05:30
makaveli 2e37216282 Merge pull request #174 from jsichi/tee-client
Add support for processing same audio stream via multiple clients running different tasks.
2024-03-17 22:51:26 +05:30
makaveli 7c7a446478 Fix: mock pyaudio for ci to pass the server tests 2024-03-15 12:29:32 +05:30
John Sichi 37d7f2ed66 Merge branch 'main' into tee-client 2024-03-13 20:12:51 +09:00
makaveli 754f22dfae Merge pull request #175 from FlippFuzz/fix-faster-whisper-version-setup
Fix faster whisper version in setup.py
2024-03-11 14:52:46 +05:30
makaveli ebd2dc9568 Merge pull request #173 from FlippFuzz/fix-os-error-no-mic
Handle failure on systems without microphones
2024-03-11 14:50:15 +05:30
FlippFuzz 9b2e17ec4d Fix faster whisper version in setup.py 2024-03-10 20:28:54 +08:00
John Sichi 5b32dc4130 Fix default value for multicast. 2024-03-10 21:02:50 +09:00
John Sichi c0f37c77e9 Remove camelcase 2024-03-10 20:49:17 +09:00
John Sichi 3b15dc76b4 Add support for processing same audio stream via multiple clients with different tasks. 2024-03-10 20:44:15 +09:00
FlippFuzz 4d477e35e7 Handle failure on systems without microphones
Catch the OSError and print a WARN log.
2024-03-10 19:43:53 +08:00
makaveli a17f4041de Merge pull request #163 from makaveli10/upgrade_faster_whisper
Upgrade faster whisper==1.0.1
2024-03-04 22:26:44 +05:30
makaveli10 8a06ba802b update cuda version 12.2.2 gpu dockerfile 2024-03-04 03:24:49 -05:00
makaveli10 02d4566289 upgrade faster whisper 1.0.1 2024-03-04 07:35:43 +00:00
Marcus Edel acd4902bec Merge pull request #161 from makaveli10/fix_docker_workflow
Build & push docker image on every new tag.
2024-02-29 10:42:49 -05:00
makaveli10 a495a49b06 build & push docker image on every new tag 2024-02-29 19:08:32 +05:30
makaveli 9e5ab408cd bump version 0.3.0 2024-02-28 23:39:44 +05:30
Marcus Edel 5e6c26c3a0 Merge pull request #158 from makaveli10/cpu_usage
fix: cpu usage issue.
2024-02-28 09:11:32 -05:00
makaveli10 18b6168807 fix: cpu usage issue 2024-02-28 13:55:37 +05:30
makaveli ec1349360a Merge pull request #157 from makaveli10/trt-multilingual
fix: lanuguage, task prefix in decoder start ids
2024-02-27 18:46:33 +05:30
makaveli10 a41e714801 fix: lanuguage, task prefix in decoder start ids 2024-02-26 23:31:19 -05:00
Marcus Edel 2d16ee552f Merge pull request #156 from makaveli10/fix_docker_image_gpu
Fix docker image gpu.
2024-02-26 09:24:26 -05:00
makaveli10 9699611000 push docker image to ghcr on push to main 2024-02-26 18:50:38 +05:30
makaveli10 ea64d47899 run server with python3 2024-02-26 18:50:18 +05:30
makaveli c067224474 bump version 0.2.1 2024-02-22 11:16:10 +05:30
makaveli e92f53cfd9 Update ci.yml
install wheel
2024-02-22 11:15:34 +05:30
makaveli 308ac1cff7 bump version 0.2.0 2024-02-22 11:03:30 +05:30
makaveli 2fced08705 Merge pull request #149 from makaveli10/docker-ghcr-ci
Docker ghcr ci
2024-02-22 10:14:51 +05:30
makaveli10 8bdaf9249d only run docker image build and push on new version release 2024-02-21 22:56:35 +05:30
makaveli10 b47a56ca6d update readme to use ghcr docker containers 2024-02-21 22:50:56 +05:30
makaveli10 f975bd452e change ghcr owner 2024-02-21 22:49:16 +05:30
makaveli10 cb963c4834 Merge remote-tracking branch 'upstream/main' into docker-ghcr-ci 2024-02-21 22:43:34 +05:30
makaveli 1db94ea96e Merge pull request #147 from makaveli10/vad_option
add VAD a client option
2024-02-21 16:10:54 +05:30
makaveli10 babe5de074 add vad option to firefox extension 2024-02-20 13:10:46 +05:30
makaveli10 99af50208d add vad option in chrome extension 2024-02-20 13:04:24 +05:30
makaveli10 dc22b7da9f add srt_file_path option 2024-02-20 11:53:45 +05:30
makaveli10 2e9f67ba0b Merge remote-tracking branch 'upstream/main' into vad_option 2024-02-20 11:43:56 +05:30
makaveli c919ba3501 Merge pull request #146 from makaveli10/code_formatting
Code formatting
2024-02-20 11:32:27 +05:30
makaveli10 a38fdb494d remove timeout from tests job 2024-02-20 00:28:38 +05:30
makaveli10 fddc244228 Merge remote-tracking branch 'upstream/main' into code_formatting 2024-02-19 22:02:40 +05:30
Marcus Edel 5e1174ff33 Merge pull request #136 from makaveli10/add_tests
Add tests.
2024-02-19 09:03:01 -05:00
Marcus Edel e40414ab1b Merge pull request #144 from makaveli10/update_readme
add whisper live demo video.
2024-02-19 09:02:27 -05:00
makaveli10 17873c66a0 prune docker cache 2024-02-19 06:36:10 -05:00
makaveli10 1147f58225 increase job timeout 2024-02-19 05:13:03 -05:00
makaveli10 0baa1dc0a6 update ci to build and gpu docker image to ghcr 2024-02-19 04:53:39 -05:00
makaveli10 d1de4948ee update base cuda version to 11.8; some dockerfile-gpu fixes 2024-02-19 04:38:41 -05:00
makaveli10 ff871ad485 update dockerfile name 2024-02-16 20:37:59 +05:30
makaveli10 5fe5e0c8ba Merge branch 'vad_option' into develop 2024-02-16 20:32:01 +05:30
makaveli10 b42ced9816 fix: tests for end of speech message while mocking pyaudio 2024-02-16 20:31:38 +05:30
makaveli10 06794470f8 build docker image on pus develop 2024-02-16 19:01:54 +05:30
makaveli10 d530957b2c test docker ci on fork 2024-02-16 18:58:57 +05:30
makaveli10 6cabbe441b update cpu dockerfile with python-slim-buster base image 2024-02-16 18:58:38 +05:30
makaveli10 78da3f6750 Merge branch 'code_formatting' into vad_option 2024-02-16 17:29:37 +05:30
makaveli10 b04cffc458 update readme; remove common content 2024-02-16 17:11:56 +05:30
makaveli10 fd7c5965b3 add whisper live demo video 2024-02-16 13:28:02 +05:30
makaveli10 4471665085 remove test audio from tests 2024-02-15 19:09:57 +05:30
makaveli10 147e97002e clear_screen for updated transcript 2024-02-15 18:56:14 +05:30
makaveli10 e3c7666cf7 update readme with use_vad 2024-02-15 18:13:26 +05:30
makaveli10 8266099ed0 update tensorrt readme 2024-02-15 18:08:02 +05:30
makaveli10 01dc69e068 close when end of audio from client 2024-02-15 18:07:19 +05:30
makaveli10 9bb92b9bb2 use_vad option and send end of audio message 2024-02-15 17:59:12 +05:30
makaveli10 57c4b60e04 remove websocket.path log from exception logging 2024-02-15 15:08:08 +05:30
makaveli10 3cd96367fb make vad an option 2024-02-15 14:59:43 +05:30
makaveli10 c1420cba0d add tests for server exception handling 2024-02-15 12:16:58 +05:30
makaveli10 4db91eed66 update vad tests after refactor 2024-02-15 12:16:39 +05:30
makaveli10 7bcb92c266 create new method for handling a new connection; expcetion handling 2024-02-15 12:16:18 +05:30
makaveli10 170ba22e5b update method docstrings 2024-02-09 16:08:18 +05:30
makaveli10 ac00e28b86 add: VoiceActivityDetector to manage vad 2024-02-09 16:07:43 +05:30
makaveli10 ceb3cc8747 update timeout log 2024-02-09 14:20:47 +05:30
makaveli10 eaec0ead08 add: handle_transcription_output method 2024-02-09 14:19:49 +05:30
makaveli10 9fbff47126 🔨 refactor whisper_live according to flake8 2024-02-09 13:45:13 +05:30
makaveli10 b4abe95fc6 add: code-format job 2024-02-09 13:44:17 +05:30
makaveli10 14974af951 update ci to run tests 2024-02-08 14:07:44 +05:30
makaveli10 bc474b4a76 update on_close; on_error 2024-02-08 14:06:06 +05:30
makaveli10 9ccf940f51 remove debug import excpetion tensorrt llm 2024-02-08 14:05:46 +05:30
makaveli10 9a9972007e remove debug stats 2024-02-08 14:04:52 +05:30
makaveli10 b2ad6478f5 update requirement for tests 2024-02-08 14:04:32 +05:30
makaveli10 490efdeacc mv test audio to assets 2024-02-08 14:04:16 +05:30
makaveli10 cb570d28ce add vad tests 2024-02-08 14:03:57 +05:30
makaveli10 4e5e086c38 add server tests 2024-02-08 14:03:39 +05:30
makaveli10 cf78d5d608 add client tests 2024-02-08 14:03:13 +05:30
makaveli 9d29b08cea Merge pull request #135 from collabora/revert-134-test_pypi_upload
Revert "Test pypi upload"
2024-02-08 12:23:36 +05:30
makaveli 6071cc1cc5 Revert "Test pypi upload" 2024-02-08 12:23:15 +05:30
makaveli f98e309663 Merge pull request #134 from makaveli10/test_pypi_upload
Test pypi upload
2024-02-08 12:23:07 +05:30
makaveli10 30b00d6c89 upload to testpypi 2024-02-08 12:18:50 +05:30
makaveli10 da2992bcaf add tests to ci.yml 2024-02-08 11:42:39 +05:30
makaveli10 16c5ed8ce9 add more python versions 2024-02-08 11:26:17 +05:30
makaveli10 e14fefb671 cache req 2024-02-08 11:13:12 +05:30
makaveli10 98399707a3 add pyaudio mock 2024-02-08 11:12:57 +05:30
makaveli10 567ceb1246 add pyaudio mock; refactor 🔨 2024-02-08 11:12:40 +05:30
makaveli10 28ea8a20f1 update tests ci 2024-02-07 23:54:02 +05:30
makaveli10 acf6dfe5b7 update python version 2024-02-07 23:47:54 +05:30
makaveli10 84a97f5fdd remove whisper_live from patch to mock websocket 2024-02-07 23:47:29 +05:30
makaveli10 ca2634bbb6 add tests workflow 2024-02-07 23:33:49 +05:30
makaveli10 444ce63440 add unit tests 2024-02-07 23:33:24 +05:30
makaveli10 8db063ee33 update log level to info 2024-02-07 23:31:58 +05:30
makaveli10 92cbc37e9c remove debug stats vad 2024-02-07 23:31:12 +05:30
makaveli10 24fd835356 update requirements 2024-02-07 23:30:34 +05:30
makaveli10 5409d14bcb move audio files to assets 2024-02-07 23:30:16 +05:30
makaveli10 d6edf8e847 update on_error; on_close 2024-02-07 23:29:22 +05:30
makaveli10 cc3ed74c0e remove unused imports 2024-02-07 23:26:42 +05:30
makaveli10 20a8a8ad3d update log level to warning 2024-02-07 23:26:13 +05:30
makaveli10 07387abbc0 silence WhisperTRTLLM import warning 2024-02-07 23:25:50 +05:30
28 changed files with 1782 additions and 918 deletions
+153 -36
View File
@@ -1,4 +1,4 @@
name: CI
name: Test & Build CI/CD
on:
push:
@@ -7,46 +7,163 @@ on:
tags:
- v*
pull_request:
branches:
- main
branches: [ main ]
types: [opened, synchronize, reopened]
jobs:
build-and-push-package:
runs-on: ubuntu-latest
run-tests:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Cache Python dependencies
uses: actions/cache@v2
with:
path: |
~/.cache/pip
!~/.cache/pip/log
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements/server.txt', 'requirements/client.txt') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ffmpeg portaudio19-dev
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/server.txt --extra-index-url https://download.pytorch.org/whl/cpu
pip install -r requirements/client.txt
- name: Run tests
run: |
echo "Running tests with Python ${{ matrix.python-version }}"
python -m unittest discover -s tests
check-code-format:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11]
steps:
- name: Check Out Repository
uses: actions/checkout@v2
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Set up FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v2
- name: Install Additional requirements
run: |
sudo apt-get -y install portaudio19-dev wget
shell: bash
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install Client Requirements
run: pip install -r requirements/client.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8
- name: Install Server Requirements
run: pip install -r requirements/server.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Install Wheel for build
run: pip install wheel twine
- name: Build wheel
run: |
python setup.py sdist bdist_wheel
- name: Push package on Test PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
build-and-push-docker-cpu:
needs: [run-tests, check-code-format]
runs-on: ubuntu-22.04
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
steps:
- uses: actions/checkout@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile.cpu
push: true
tags: ghcr.io/collabora/whisperlive-cpu:latest
build-and-push-docker-gpu:
needs: [run-tests, check-code-format, build-and-push-docker-cpu]
timeout-minutes: 20
runs-on: ubuntu-22.04
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
steps:
- uses: actions/checkout@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Docker Prune
run: docker system prune -af
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push Docker GPU image
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile.gpu
push: true
tags: ghcr.io/collabora/whisperlive-gpu:latest
publish-to-pypi:
needs: [run-tests, check-code-format]
runs-on: ubuntu-22.04
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Cache Python dependencies
uses: actions/cache@v2
with:
path: |
~/.cache/pip
!~/.cache/pip/log
key: ubuntu-latest-pip-3.8-${{ hashFiles('requirements/server.txt', 'requirements/client.txt') }}
restore-keys: |
ubuntu-latest-pip-3.8-
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ffmpeg portaudio19-dev
- name: Install Python dependencies
run: |
pip install -r requirements/server.txt
pip install -r requirements/client.txt
pip install wheel
- name: Build package
run: python setup.py sdist bdist_wheel
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
+2 -1
View File
@@ -157,7 +157,8 @@ async function startCapture(options) {
multilingual: options.useMultilingual,
language: options.language,
task: options.task,
modelSize: options.modelSize
modelSize: options.modelSize,
useVad: options.useVad,
},
});
} else {
+2 -1
View File
@@ -99,7 +99,8 @@ async function startRecord(option) {
uid: uuid,
language: option.language,
task: option.task,
model: option.modelSize
model: option.modelSize,
use_vad: option.useVad
})
);
};
+4
View File
@@ -15,6 +15,10 @@
<input type="checkbox" id="useServerCheckbox">
<label for="useServerCheckbox">Use Collabora Whisper-Live Server</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label>
</div>
<div class="dropdown-container">
<label for="languageDropdown">Select Language:</label>
<select id="languageDropdown">
+16 -2
View File
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", function () {
const stopButton = document.getElementById("stopCapture");
const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox");
const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -31,6 +32,12 @@ document.addEventListener("DOMContentLoaded", function () {
}
});
chrome.storage.local.get("useVadState", ({ useVadState }) => {
if (useVadState !== undefined) {
useVadCheckbox.checked = useVadState;
}
});
chrome.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage;
@@ -79,7 +86,8 @@ document.addEventListener("DOMContentLoaded", function () {
port: port,
language: selectedLanguage,
task: selectedTask,
modelSize: selectedModelSize
modelSize: selectedModelSize,
useVad: useVadCheckbox.checked,
}, () => {
// Update capturing state in storage and toggle the buttons
chrome.storage.local.set({ capturingState: { isCapturing: true } }, () => {
@@ -118,7 +126,8 @@ document.addEventListener("DOMContentLoaded", function () {
function toggleCaptureButtons(isCapturing) {
startButton.disabled = isCapturing;
stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing;
useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing;
@@ -132,6 +141,11 @@ document.addEventListener("DOMContentLoaded", function () {
chrome.storage.local.set({ useServerState });
});
useVadCheckbox.addEventListener("change", () => {
const useVadState = useVadCheckbox.checked;
chrome.storage.local.set({ useVadState });
});
languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") {
selectedLanguage = null;
+2 -1
View File
@@ -74,7 +74,8 @@ function startRecording(data) {
uid: uuid,
language: data.language,
task: data.task,
model: data.modelSize
model: data.modelSize,
use_vad: data.useVad
})
);
};
+4
View File
@@ -15,6 +15,10 @@
<input type="checkbox" id="useServerCheckbox">
<label for="useServerCheckbox">Use Collabora Whisper-Live Server</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label>
</div>
<textarea id="waitTextBox" style="display: none;"></textarea>
<div class="dropdown-container">
<label for="languageDropdown">Select Language:</label>
+15 -1
View File
@@ -3,6 +3,7 @@ document.addEventListener("DOMContentLoaded", function() {
const stopButton = document.getElementById("stopCapture");
const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox");
const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -34,6 +35,12 @@ document.addEventListener("DOMContentLoaded", function() {
}
});
browser.storage.local.get("useVadState", ({ useVadState }) => {
if (useVadState !== undefined) {
useVadCheckbox.checked = useVadState;
}
});
browser.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage;
@@ -76,7 +83,8 @@ document.addEventListener("DOMContentLoaded", function() {
port: port,
language: selectedLanguage,
task: selectedTask,
modelSize: selectedModelSize
modelSize: selectedModelSize,
useVad: useVadCheckbox.checked,
}
});
toggleCaptureButtons(true);
@@ -115,6 +123,7 @@ document.addEventListener("DOMContentLoaded", function() {
startButton.disabled = isCapturing;
stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing;
@@ -128,6 +137,11 @@ document.addEventListener("DOMContentLoaded", function() {
browser.storage.local.set({ useServerState });
});
useVadCheckbox.addEventListener("change", () => {
const useVadState = useVadCheckbox.checked;
browser.storage.local.set({ useVadState });
});
languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") {
selectedLanguage = null;
+22 -32
View File
@@ -1,9 +1,15 @@
# whisper-live
A nearly-live implementation of OpenAI's Whisper.
# WhisperLive
This project is a real-time transcription application that uses the OpenAI Whisper model to convert speech input into text output. It can be used to transcribe both live audio input from microphone and pre-recorded audio files.
<h2 align="center">
<a href="https://www.youtube.com/watch?v=0PHWCApIcCI"><img
src="https://img.youtube.com/vi/0PHWCApIcCI/0.jpg" style="background-color:rgba(0,0,0,0);" height=300 alt="WhisperLive"></a>
<br><br>A nearly-live implementation of OpenAI's Whisper.
<br><br>
</h2>
Unlike traditional speech recognition systems that rely on continuous audio streaming, we use [voice activity detection (VAD)](https://github.com/snakers4/silero-vad) to detect the presence of speech and only send the audio data to whisper when speech is detected. This helps to reduce the amount of data sent to the whisper model and improves the accuracy of the transcription output.
This project is a real-time transcription application that uses the OpenAI Whisper model
to convert speech input into text output. It can be used to transcribe both live audio
input from microphone and pre-recorded audio files.
## Installation
- Install PyAudio and ffmpeg
@@ -50,7 +56,7 @@ python3 run_server.py -p 9090 \
### Running the Client
- To transcribe an audio file:
- Initializing the client:
```python
from whisper_live.client import TranscriptionClient
client = TranscriptionClient(
@@ -58,58 +64,43 @@ client = TranscriptionClient(
9090,
lang="en",
translate=False,
model="small"
model="small",
use_vad=False,
)
```
It connects to the server running on localhost at port 9090. Using a multilingual model, language for the transcription will be automatically detected. You can also use the language option to specify the target language for the transcription, in this case, English ("en"). The translate option should be set to `True` if we want to translate from the source language to English and `False` if we want to transcribe in the source language.
- Trancribe an audio file:
```python
client("tests/jfk.wav")
```
This command transcribes the specified audio file (audio.wav) using the Whisper model. It connects to the server running on localhost at port 9090. Using a multilingual model, language for the transcription will be automatically detected. You can also use the language option to specify the target language for the transcription, in this case, English ("en"). The translate option should be set to `True` if we want to translate from the source language to English and `False` if we want to transcribe in the source language.
- To transcribe from microphone:
```python
from whisper_live.client import TranscriptionClient
client = TranscriptionClient(
"localhost",
9090,
lang="hi",
translate=True,
model="small"
)
client()
```
This command captures audio from the microphone and sends it to the server for transcription. It uses the multilingual model with `hi` as the selected language. We use whisper `small` by default but can be changed to any other option based on the requirements and the hardware running the server.
- To transcribe from a HLS stream:
```python
from whisper_live.client import TranscriptionClient
client = TranscriptionClient(host, port, lang="en", translate=False)
client(hls_url="http://as-hls-ww-live.akamaized.net/pool_904/live/ww/bbc_1xtra/bbc_1xtra.isml/bbc_1xtra-audio%3d96000.norewind.m3u8")
```
This command streams audio into the server from a HLS stream. It uses the same options as the previous command, using the multilingual model and specifying the target language and task.
## Transcribe audio from browser
- Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server)
### Chrome Extension
- Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) to use Chrome extension.
### Firefox Extension
- Refer to [Audio-Transcription-Firefox](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Firefox#readme) to use Mozilla Firefox extension.
## Browser Extensions
- Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server).
- Transcribe audio directly from your browser using our Chrome or Firefox extensions. Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) and [Audio-Transcription-Firefox](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Firefox#readme) for setup instructions.
## Whisper Live Server in Docker
- GPU
- Faster-Whisper
```bash
docker build . -t whisper-live -f docker/Dockerfile.gpu
docker run -it --gpus all -p 9090:9090 whisper-live:latest
docker run -it --gpus all -p 9090:9090 ghcr.io/collabora/whisperlive-gpu:latest
```
- TensorRT. Follow [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) in order to setup docker and use TensorRT backend. We provide a pre-built docker image which has TensorRT-LLM built and ready to use.
- CPU
```bash
docker build . -t whisper-live -f docker/Dockerfile.cpu
docker run -it -p 9090:9090 whisper-live:latest
docker run -it -p 9090:9090 ghcr.io/collabora/whisperlive-cpu:latest
```
**Note**: By default we use "small" model size. To build docker image for a different model size, change the size in server.py and then build the docker image.
@@ -140,6 +131,5 @@ We are available to help you with both Open Source and proprietary AI projects.
publisher = {GitHub},
journal = {GitHub repository},
howpublished = {\url{https://github.com/snakers4/silero-vad}},
commit = {insert_some_commit_here},
email = {hello@silero.ai}
}
+2 -2
View File
@@ -21,7 +21,7 @@ docker pull ghcr.io/collabora/whisperbot-base:latest
```bash
docker run -it --gpus all --shm-size=8g \
--ipc=host --ulimit memlock=-1 --ulimit stack=67108864 \
-v /path/to/WhisperLive:/home/WhisperLive \
-p 9090:9090 -v /path/to/WhisperLive:/home/WhisperLive \
ghcr.io/collabora/whisperbot-base:latest
```
@@ -48,7 +48,7 @@ bash scripts/build_whisper_tensorrt.sh /root/TensorRT-LLM-examples small
cd /home/WhisperLive
# Install requirements
bash scripts/setup.sh
apt update && bash scripts/setup.sh
pip install -r requirements/server.txt
# Required to create mel spectogram
View File
+6 -27
View File
@@ -1,45 +1,24 @@
FROM ubuntu:focal
FROM python:3.8-slim-buster
ARG DEBIAN_FRONTEND=noninteractive
# Remove any third-party apt sources to avoid issues with expiring keys.
RUN rm -f /etc/apt/sources.list.d/*.list
# Install some basic utilities.
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
sudo \
git \
bzip2 \
libx11-6 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN apt update
# install python
RUN apt install software-properties-common -y && \
add-apt-repository ppa:deadsnakes/ppa && \
apt update
RUN apt install python3-dev -y && \
apt install python-is-python3
# install pip
RUN apt install python3-pip -y
# Create a working directory.
RUN mkdir /app
WORKDIR /app
COPY scripts/setup.sh /app
COPY requirements/ /app
COPY scripts/setup.sh requirements/server.txt /app/
RUN bash setup.sh
RUN pip install -r server.txt
RUN apt update && bash setup.sh && pip install -r server.txt
COPY whisper_live /app/whisper_live
COPY run_server.py /app
CMD ["python", "run_server.py"]
+10 -24
View File
@@ -1,47 +1,33 @@
FROM nvidia/cuda:11.2.2-cudnn8-runtime-ubuntu20.04
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ARG DEBIAN_FRONTEND=noninteractive
# Remove any third-party apt sources to avoid issues with expiring keys.
RUN rm -f /etc/apt/sources.list.d/*.list
# Install some basic utilities.
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
sudo \
git \
bzip2 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
RUN apt update
# install python
RUN apt install software-properties-common -y && \
add-apt-repository ppa:deadsnakes/ppa && \
apt update
RUN apt install python3-dev -y && \
apt install python-is-python3
# install pip
RUN apt install python3-pip -y
python3-dev \
python3-pip \
&& python3 -m pip install --upgrade pip \
&& rm -rf /var/lib/apt/lists/*
# Create a working directory.
RUN mkdir /app
WORKDIR /app
COPY scripts/setup.sh /app
COPY requirements/ /app
COPY scripts/setup.sh requirements/server.txt /app
RUN apt update --fix-missing
RUN bash setup.sh
RUN pip install -r server.txt
RUN apt update && bash setup.sh && rm setup.sh
RUN pip install -r server.txt && rm server.txt
COPY whisper_live /app/whisper_live
COPY run_server.py /app
CMD ["python", "run_server.py"]
CMD ["python3", "run_server.py"]
+3 -1
View File
@@ -1,4 +1,4 @@
faster-whisper==0.10.0
faster-whisper==1.0.1
torch
websockets
onnxruntime==1.16.0
@@ -8,3 +8,5 @@ kaldialign
soundfile
ffmpeg-python
scipy
jiwer
evaluate
+5 -5
View File
@@ -4,15 +4,15 @@ from whisper_live.server import TranscriptionServer
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('--port', '-p',
type=int,
type=int,
default=9090,
help="Websocket port to run the server on.")
parser.add_argument('--backend', '-b',
type=str,
default='faster_whisper',
type=str,
default='faster_whisper',
help='Backends from ["tensorrt", "faster_whisper"]')
parser.add_argument('--faster_whisper_custom_model_path', '-fw',
type=str, default=None,
type=str, default=None,
help="Custom Faster Whisper Model")
parser.add_argument('--trt_model_path', '-trt',
type=str,
@@ -30,7 +30,7 @@ if __name__ == "__main__":
server = TranscriptionServer()
server.run(
"0.0.0.0",
port=args.port,
port=args.port,
backend=args.backend,
faster_whisper_custom_model_path=args.faster_whisper_custom_model_path,
whisper_tensorrt_path=args.trt_model_path,
+35 -33
View File
@@ -10,38 +10,40 @@ HERE = pathlib.Path(__file__).parent
README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(name="whisper-live",
version=__version__,
description="A nearly-live implementation of OpenAI's Whisper.",
long_description=README,
long_description_content_type="text/markdown",
include_package_data=True,
url="https://github.com/collabora/WhisperLive",
author="Collabora Ltd",
author_email="vineet.suryan@collabora.com",
license="MIT",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
],
packages=find_packages(
exclude=("examples",
"Audio-Transcription-Chrome",
"Audio-Transcription-Firefox",
"requirements",
"whisper-finetuning"
)
),
install_requires=[
setup(
name="whisper-live",
version=__version__,
description="A nearly-live implementation of OpenAI's Whisper.",
long_description=README,
long_description_content_type="text/markdown",
include_package_data=True,
url="https://github.com/collabora/WhisperLive",
author="Collabora Ltd",
author_email="vineet.suryan@collabora.com",
license="MIT",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
],
packages=find_packages(
exclude=(
"examples",
"Audio-Transcription-Chrome",
"Audio-Transcription-Firefox",
"requirements",
"whisper-finetuning"
)
),
install_requires=[
"PyAudio",
"faster-whisper==0.10.0",
"faster-whisper==1.0.1",
"torch",
"torchaudio",
"websockets",
@@ -53,6 +55,6 @@ setup(name="whisper-live",
"openai-whisper",
"kaldialign",
"soundfile",
],
python_requires=">=3.8"
],
python_requires=">=3.8"
)
View File
+156
View File
@@ -0,0 +1,156 @@
import json
import os
import scipy
import websocket
import copy
import unittest
from unittest.mock import patch, MagicMock
from whisper_live.client import Client, TranscriptionClient, TranscriptionTeeClient
from whisper_live.utils import resample
from pathlib import Path
class BaseTestCase(unittest.TestCase):
@patch('whisper_live.client.websocket.WebSocketApp')
@patch('whisper_live.client.pyaudio.PyAudio')
def setUp(self, mock_pyaudio, mock_websocket):
self.mock_pyaudio_instance = MagicMock()
mock_pyaudio.return_value = self.mock_pyaudio_instance
self.mock_stream = MagicMock()
self.mock_pyaudio_instance.open.return_value = self.mock_stream
self.mock_ws_app = mock_websocket.return_value
self.mock_ws_app.send = MagicMock()
self.client = TranscriptionClient(host='localhost', port=9090, lang="en").client
self.mock_pyaudio = mock_pyaudio
self.mock_websocket = mock_websocket
self.mock_audio_packet = b'\x00\x01\x02\x03'
def tearDown(self):
self.client.close_websocket()
self.mock_pyaudio.stop()
self.mock_websocket.stop()
del self.client
class TestClientWebSocketCommunication(BaseTestCase):
def test_websocket_communication(self):
expected_url = 'ws://localhost:9090'
self.mock_websocket.assert_called()
self.assertEqual(self.mock_websocket.call_args[0][0], expected_url)
class TestClientCallbacks(BaseTestCase):
def test_on_open(self):
expected_message = json.dumps({
"uid": self.client.uid,
"language": self.client.language,
"task": self.client.task,
"model": self.client.model,
"use_vad": True
})
self.client.on_open(self.mock_ws_app)
self.mock_ws_app.send.assert_called_with(expected_message)
def test_on_message(self):
message = json.dumps(
{
"uid": self.client.uid,
"message": "SERVER_READY",
"backend": "faster_whisper"
}
)
self.client.on_message(self.mock_ws_app, message)
message = json.dumps({
"uid": self.client.uid,
"segments": [
{"start": 0, "end": 1, "text": "Test transcript"},
{"start": 1, "end": 2, "text": "Test transcript 2"},
{"start": 2, "end": 3, "text": "Test transcript 3"}
]
})
self.client.on_message(self.mock_ws_app, message)
# Assert that the transcript was updated correctly
self.assertEqual(len(self.client.transcript), 2)
self.assertEqual(self.client.transcript[1]['text'], "Test transcript 2")
def test_on_close(self):
close_status_code = 1000
close_msg = "Normal closure"
self.client.on_close(self.mock_ws_app, close_status_code, close_msg)
self.assertFalse(self.client.recording)
self.assertFalse(self.client.server_error)
self.assertFalse(self.client.waiting)
def test_on_error(self):
error_message = "Test Error"
self.client.on_error(self.mock_ws_app, error_message)
self.assertTrue(self.client.server_error)
self.assertEqual(self.client.error_message, error_message)
class TestAudioResampling(unittest.TestCase):
def test_resample_audio(self):
original_audio = "assets/jfk.flac"
expected_sr = 16000
resampled_audio = resample(original_audio, expected_sr)
sr, _ = scipy.io.wavfile.read(resampled_audio)
self.assertEqual(sr, expected_sr)
os.remove(resampled_audio)
class TestSendingAudioPacket(BaseTestCase):
def test_send_packet(self):
self.client.send_packet_to_server(self.mock_audio_packet)
self.client.client_socket.send.assert_called_with(self.mock_audio_packet, websocket.ABNF.OPCODE_BINARY)
class TestTee(BaseTestCase):
@patch('whisper_live.client.websocket.WebSocketApp')
@patch('whisper_live.client.pyaudio.PyAudio')
def setUp(self, mock_audio, mock_websocket):
super().setUp()
self.client2 = Client(host='localhost', port=9090, lang="es", translate=False, srt_file_path="transcript.srt")
self.client3 = Client(host='localhost', port=9090, lang="es", translate=True, srt_file_path="translation.srt")
# need a separate mock for each websocket
self.client3.client_socket = copy.deepcopy(self.client3.client_socket)
self.tee = TranscriptionTeeClient([self.client2, self.client3])
def tearDown(self):
self.tee.close_all_clients()
del self.tee
super().tearDown()
def test_invalid_constructor(self):
with self.assertRaises(Exception) as context:
TranscriptionTeeClient([])
def test_multicast_unconditional(self):
self.tee.multicast_packet(self.mock_audio_packet, True)
for client in self.tee.clients:
client.client_socket.send.assert_called_with(self.mock_audio_packet, websocket.ABNF.OPCODE_BINARY)
def test_multicast_conditional(self):
self.client2.recording = False
self.client3.recording = True
self.tee.multicast_packet(self.mock_audio_packet, False)
self.client2.client_socket.send.assert_not_called()
self.client3.client_socket.send.assert_called_with(self.mock_audio_packet, websocket.ABNF.OPCODE_BINARY)
def test_close_all(self):
self.tee.close_all_clients()
for client in self.tee.clients:
client.client_socket.close.assert_called()
def test_write_all_srt(self):
for client in self.tee.clients:
client.server_backend = "faster_whisper"
self.tee.write_all_clients_srt()
self.assertTrue(Path("transcript.srt").is_file())
self.assertTrue(Path("translation.srt").is_file())
+150
View File
@@ -0,0 +1,150 @@
import subprocess
import time
import json
import unittest
from unittest import mock
import numpy as np
import evaluate
from websockets.exceptions import ConnectionClosed
from whisper_live.server import TranscriptionServer
from whisper_live.client import Client, TranscriptionClient, TranscriptionTeeClient
from whisper.normalizers import EnglishTextNormalizer
class TestTranscriptionServerInitialization(unittest.TestCase):
def test_initialization(self):
server = TranscriptionServer()
self.assertEqual(server.client_manager.max_clients, 4)
self.assertEqual(server.client_manager.max_connection_time, 600)
self.assertDictEqual(server.client_manager.clients, {})
self.assertDictEqual(server.client_manager.start_times, {})
class TestGetWaitTime(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
self.server.client_manager.start_times = {
'client1': time.time() - 120,
'client2': time.time() - 300
}
self.server.client_manager.max_connection_time = 600
def test_get_wait_time(self):
expected_wait_time = (600 - (time.time() - self.server.client_manager.start_times['client2'])) / 60
print(self.server.client_manager.get_wait_time(), expected_wait_time)
self.assertAlmostEqual(self.server.client_manager.get_wait_time(), expected_wait_time, places=2)
class TestServerConnection(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
@mock.patch('websockets.WebSocketCommonProtocol')
def test_connection(self, mock_websocket):
mock_websocket.recv.return_value = json.dumps({
'uid': 'test_client',
'language': 'en',
'task': 'transcribe',
'model': 'tiny.en'
})
self.server.recv_audio(mock_websocket, "faster_whisper")
@mock.patch('websockets.WebSocketCommonProtocol')
def test_recv_audio_exception_handling(self, mock_websocket):
mock_websocket.recv.side_effect = [json.dumps({
'uid': 'test_client',
'language': 'en',
'task': 'transcribe',
'model': 'tiny.en'
}), np.array([1, 2, 3]).tobytes()]
with self.assertLogs(level="ERROR"):
self.server.recv_audio(mock_websocket, "faster_whisper")
self.assertNotIn(mock_websocket, self.server.client_manager.clients)
class TestServerInferenceAccuracy(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mock_pyaudio_patch = mock.patch('pyaudio.PyAudio')
cls.mock_pyaudio = cls.mock_pyaudio_patch.start()
cls.mock_pyaudio.return_value.open.return_value = mock.MagicMock()
cls.server_process = subprocess.Popen(["python", "run_server.py"])
time.sleep(2)
@classmethod
def tearDownClass(cls):
cls.server_process.terminate()
cls.server_process.wait()
def setUp(self):
self.metric = evaluate.load("wer")
self.normalizer = EnglishTextNormalizer()
def check_prediction(self, srt_path):
gt = "And so my fellow Americans, ask not, what your country can do for you. Ask what you can do for your country!"
with open(srt_path, "r") as f:
lines = f.readlines()
prediction = " ".join([line.strip() for line in lines[2::4]])
prediction_normalized = self.normalizer(prediction)
gt_normalized = self.normalizer(gt)
# calculate WER
wer = self.metric.compute(
predictions=[prediction_normalized],
references=[gt_normalized]
)
self.assertLess(wer, 0.05)
def test_inference(self):
client = TranscriptionClient(
"localhost", "9090", model="base.en", lang="en",
)
client("assets/jfk.flac")
self.check_prediction("output.srt")
def test_simultaneous_inference(self):
client1 = Client(
"localhost", "9090", model="base.en", lang="en", srt_file_path="transcript1.srt")
client2 = Client(
"localhost", "9090", model="base.en", lang="en", srt_file_path="transcript2.srt")
tee = TranscriptionTeeClient([client1, client2])
tee("assets/jfk.flac")
self.check_prediction("transcript1.srt")
self.check_prediction("transcript2.srt")
class TestExceptionHandling(unittest.TestCase):
def setUp(self):
self.server = TranscriptionServer()
@mock.patch('websockets.WebSocketCommonProtocol')
def test_connection_closed_exception(self, mock_websocket):
mock_websocket.recv.side_effect = ConnectionClosed(1001, "testing connection closed")
with self.assertLogs(level="INFO") as log:
self.server.recv_audio(mock_websocket, "faster_whisper")
self.assertTrue(any("Connection closed by client" in message for message in log.output))
@mock.patch('websockets.WebSocketCommonProtocol')
def test_json_decode_exception(self, mock_websocket):
mock_websocket.recv.return_value = "invalid json"
with self.assertLogs(level="ERROR") as log:
self.server.recv_audio(mock_websocket, "faster_whisper")
self.assertTrue(any("Failed to decode JSON from client" in message for message in log.output))
@mock.patch('websockets.WebSocketCommonProtocol')
def test_unexpected_exception_handling(self, mock_websocket):
mock_websocket.recv.side_effect = RuntimeError("Unexpected error")
with self.assertLogs(level="ERROR") as log:
self.server.recv_audio(mock_websocket, "faster_whisper")
for message in log.output:
print(message)
print()
self.assertTrue(any("Unexpected error" in message for message in log.output))
+26
View File
@@ -0,0 +1,26 @@
import unittest
import numpy as np
from whisper_live.tensorrt_utils import load_audio
from whisper_live.vad import VoiceActivityDetector
class TestVoiceActivityDetection(unittest.TestCase):
def setUp(self):
self.vad = VoiceActivityDetector()
self.sample_rate = 16000
def generate_silence(self, duration_seconds):
return np.zeros(int(self.sample_rate * duration_seconds), dtype=np.float32)
def load_speech_segment(self, filepath):
return load_audio(filepath)
def test_vad_silence_detection(self):
silence = self.generate_silence(3)
is_speech_present = self.vad(silence.copy())
self.assertFalse(is_speech_present, "VAD incorrectly identified silence as speech.")
def test_vad_speech_detection(self):
audio_tensor = load_audio("assets/jfk.flac")
is_speech_present = self.vad(audio_tensor)
self.assertTrue(is_speech_present, "VAD failed to identify speech segment.")
+1 -1
View File
@@ -1 +1 @@
__version__="0.1.0"
__version__ = "0.4.0"
+264 -260
View File
@@ -2,75 +2,22 @@ import os
import wave
import numpy as np
import scipy
import ffmpeg
import pyaudio
import threading
import textwrap
import json
import websocket
import uuid
import time
def format_time(s):
"""Convert seconds (float) to SRT time format."""
hours = int(s // 3600)
minutes = int((s % 3600) // 60)
seconds = int(s % 60)
milliseconds = int((s - int(s)) * 1000)
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
def create_srt_file(segments, output_file):
with open(output_file, 'w', encoding='utf-8') as srt_file:
segment_number = 1
for segment in segments:
start_time = format_time(float(segment['start']))
end_time = format_time(float(segment['end']))
text = segment['text']
srt_file.write(f"{segment_number}\n")
srt_file.write(f"{start_time} --> {end_time}\n")
srt_file.write(f"{text}\n\n")
segment_number += 1
def resample(file: str, sr: int = 16000):
"""
# https://github.com/openai/whisper/blob/7858aa9c08d98f75575035ecd6481f462d66ca27/whisper/audio.py#L22
Open an audio file and read as mono waveform, resampling as necessary,
save the resampled audio
Args:
file (str): The audio file to open
sr (int): The sample rate to resample the audio if necessary
Returns:
resampled_file (str): The resampled audio file
"""
try:
# This launches a subprocess to decode audio while down-mixing and resampling as necessary.
# Requires the ffmpeg CLI and `ffmpeg-python` package to be installed.
out, _ = (
ffmpeg.input(file, threads=0)
.output("-", format="s16le", acodec="pcm_s16le", ac=1, ar=sr)
.run(cmd=["ffmpeg", "-nostdin"], capture_stdout=True, capture_stderr=True)
)
except ffmpeg.Error as e:
raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}") from e
np_buffer = np.frombuffer(out, dtype=np.int16)
resampled_file = f"{file.split('.')[0]}_resampled.wav"
scipy.io.wavfile.write(resampled_file, sr, np_buffer.astype(np.int16))
return resampled_file
import ffmpeg
import whisper_live.utils as utils
class Client:
"""
Handles audio recording, streaming, and communication with a server using WebSocket.
Handles communication with a server using WebSocket.
"""
INSTANCES = {}
END_OF_AUDIO = "END_OF_AUDIO"
def __init__(
self,
@@ -79,7 +26,8 @@ class Client:
lang=None,
translate=False,
model="small",
srt_file_path="output.srt"
srt_file_path="output.srt",
use_vad=True
):
"""
Initializes a Client instance for audio recording and streaming to a server.
@@ -94,35 +42,25 @@ class Client:
lang (str, optional): The selected language for transcription. Default is None.
translate (bool, optional): Specifies if the task is translation. Default is False.
"""
self.chunk = 4096
self.format = pyaudio.paInt16
self.channels = 1
self.rate = 16000
self.record_seconds = 60000
self.recording = False
self.task = "transcribe"
self.uid = str(uuid.uuid4())
self.waiting = False
self.last_response_recieved = None
self.last_response_received = None
self.disconnect_if_no_response_for = 15
self.language = lang
self.model = model
self.server_error = False
self.srt_file_path = srt_file_path
self.use_vad = use_vad
self.last_segment = None
self.last_received_segment = None
if translate:
self.task = "translate"
self.timestamp_offset = 0.0
self.audio_bytes = None
self.p = pyaudio.PyAudio()
self.stream = self.p.open(
format=self.format,
channels=self.channels,
rate=self.rate,
input=True,
frames_per_buffer=self.chunk,
)
if host is not None and port is not None:
socket_url = f"ws://{host}:{port}"
@@ -146,14 +84,47 @@ class Client:
self.ws_thread.setDaemon(True)
self.ws_thread.start()
self.frames = b""
self.transcript = []
print("[INFO]: * recording")
def handle_status_messages(self, message_data):
"""Handles server status messages."""
status = message_data["status"]
if status == "WAIT":
self.waiting = True
print(f"[INFO]: Server is full. Estimated wait time {round(message_data['message'])} minutes.")
elif status == "ERROR":
print(f"Message from Server: {message_data['message']}")
self.server_error = True
elif status == "WARNING":
print(f"Message from Server: {message_data['message']}")
def process_segments(self, segments):
"""Processes transcript segments."""
text = []
for i, seg in enumerate(segments):
if not text or text[-1] != seg["text"]:
text.append(seg["text"])
if i == len(segments) - 1:
self.last_segment = seg
elif (self.server_backend == "faster_whisper" and
(not self.transcript or
float(seg['start']) >= float(self.transcript[-1]['end']))):
self.transcript.append(seg)
# update last received segment and last valid response time
if self.last_received_segment is None or self.last_received_segment != segments[-1]["text"]:
self.last_response_received = time.time()
self.last_received_segment = segments[-1]["text"]
# Truncate to last 3 entries for brevity.
text = text[-3:]
utils.clear_screen()
utils.print_transcript(text)
def on_message(self, ws, message):
"""
Callback function called when a message is received from the server.
It updates various attributes of the client based on the received message, including
recording status, language detection, and server messages. If a disconnect message
is received, it sets the recording status to False.
@@ -163,7 +134,6 @@ class Client:
message (str): The received message from the server.
"""
self.last_response_recieved = time.time()
message = json.loads(message)
if self.uid != message.get("uid"):
@@ -171,21 +141,15 @@ class Client:
return
if "status" in message.keys():
if message["status"] == "WAIT":
self.waiting = True
print(
f"[INFO]:Server is full. Estimated wait time {round(message['message'])} minutes."
)
elif message["status"] == "ERROR":
print(f"Message from Server: {message['message']}")
self.server_error = True
self.handle_status_messages(message)
return
if "message" in message.keys() and message["message"] == "DISCONNECT":
print("[INFO]: Server overtime disconnected.")
print("[INFO]: Server disconnected due to overtime.")
self.recording = False
if "message" in message.keys() and message["message"] == "SERVER_READY":
self.last_response_received = time.time()
self.recording = True
self.server_backend = message["backend"]
print(f"[INFO]: Server Running with backend {self.server_backend}")
@@ -199,49 +163,24 @@ class Client:
)
return
if "segments" not in message.keys():
return
message = message["segments"]
text = []
n_segments = len(message)
if n_segments:
for i, seg in enumerate(message):
if text and text[-1] == seg["text"]:
# already got it
continue
text.append(seg["text"])
if i == n_segments-1:
self.last_segment = seg
elif self.server_backend == "faster_whisper":
if not len(self.transcript) or float(seg['start']) >= float(self.transcript[-1]['end']):
self.transcript.append(seg)
# keep only last 3
if len(text) > 3:
text = text[-3:]
wrapper = textwrap.TextWrapper(width=60)
word_list = wrapper.wrap(text="".join(text))
# Print each line.
if os.name == "nt":
os.system("cls")
else:
os.system("clear")
for element in word_list:
print(element)
if "segments" in message.keys():
self.process_segments(message["segments"])
def on_error(self, ws, error):
print(error)
print(f"[ERROR] WebSocket Error: {error}")
self.server_error = True
self.error_message = error
def on_close(self, ws, close_status_code, close_msg):
print(f"[INFO]: Websocket connection closed: {close_status_code}: {close_msg}")
self.recording = False
self.server_error = False
self.waiting = False
def on_open(self, ws):
"""
Callback function called when the WebSocket connection is successfully opened.
Sends an initial configuration message to the server, including client UID,
language selection, and task type.
@@ -257,27 +196,11 @@ class Client:
"language": self.language,
"task": self.task,
"model": self.model,
"use_vad": self.use_vad
}
)
)
@staticmethod
def bytes_to_float_array(audio_bytes):
"""
Convert audio data from bytes to a NumPy float array.
It assumes that the audio data is in 16-bit PCM format. The audio data is normalized to
have values between -1 and 1.
Args:
audio_bytes (bytes): Audio data in bytes.
Returns:
np.ndarray: A NumPy array containing the audio data as float values normalized between -1 and 1.
"""
raw_data = np.frombuffer(buffer=audio_bytes, dtype=np.int16)
return raw_data.astype(np.float32) / 32768.0
def send_packet_to_server(self, message):
"""
Send an audio packet to the server using WebSocket.
@@ -291,67 +214,11 @@ class Client:
except Exception as e:
print(e)
def play_file(self, filename):
"""
Play an audio file and send it to the server for processing.
Reads an audio file, plays it through the audio output, and simultaneously sends
the audio data to the server for processing. It uses PyAudio to create an audio
stream for playback. The audio data is read from the file in chunks, converted to
floating-point format, and sent to the server using WebSocket communication.
This method is typically used when you want to process pre-recorded audio and send it
to the server in real-time.
Args:
filename (str): The path to the audio file to be played and sent to the server.
"""
# read audio and create pyaudio stream
with wave.open(filename, "rb") as wavfile:
self.stream = self.p.open(
format=self.p.get_format_from_width(wavfile.getsampwidth()),
channels=wavfile.getnchannels(),
rate=wavfile.getframerate(),
input=True,
output=True,
frames_per_buffer=self.chunk,
)
try:
while self.recording:
data = wavfile.readframes(self.chunk)
if data == b"":
break
audio_array = self.bytes_to_float_array(data)
self.send_packet_to_server(audio_array.tobytes())
self.stream.write(data)
wavfile.close()
assert self.last_response_recieved
while time.time() - self.last_response_recieved < self.disconnect_if_no_response_for:
continue
if self.server_backend == "faster_whisper":
self.write_srt_file(self.srt_file_path)
self.stream.close()
self.close_websocket()
except KeyboardInterrupt:
wavfile.close()
self.stream.stop_stream()
self.stream.close()
self.p.terminate()
self.close_websocket()
if self.server_backend == "faster_whisper":
self.write_srt_file(self.srt_file_path)
print("[INFO]: Keyboard interrupt.")
def close_websocket(self):
"""
Close the WebSocket connection and join the WebSocket thread.
First attempts to close the WebSocket connection using `self.client_socket.close()`. After
First attempts to close the WebSocket connection using `self.client_socket.close()`. After
closing the connection, it joins the WebSocket thread to ensure proper termination.
"""
@@ -374,24 +241,163 @@ class Client:
"""
return self.client_socket
def write_audio_frames_to_file(self, frames, file_name):
def write_srt_file(self, output_path="output.srt"):
"""
Write audio frames to a WAV file.
The WAV file is created or overwritten with the specified name. The audio frames should be
in the correct format and match the specified channel, sample width, and sample rate.
Writes out the transcript in .srt format.
Args:
frames (bytes): The audio frames to be written to the file.
file_name (str): The name of the WAV file to which the frames will be written.
message (output_path, optional): The path to the target file. Default is "output.srt".
"""
with wave.open(file_name, "wb") as wavfile:
wavfile: wave.Wave_write
wavfile.setnchannels(self.channels)
wavfile.setsampwidth(2)
wavfile.setframerate(self.rate)
wavfile.writeframes(frames)
if self.server_backend == "faster_whisper":
if (self.last_segment):
self.transcript.append(self.last_segment)
utils.create_srt_file(self.transcript, output_path)
def wait_before_disconnect(self):
"""Waits a bit before disconnecting in order to process pending responses."""
assert self.last_response_received
while time.time() - self.last_response_received < self.disconnect_if_no_response_for:
continue
class TranscriptionTeeClient:
"""
Client for handling audio recording, streaming, and transcription tasks via one or more
WebSocket connections.
Acts as a high-level client for audio transcription tasks using a WebSocket connection. It can be used
to send audio data for transcription to one or more servers, and receive transcribed text segments.
Args:
clients (list): one or more previously initialized Client instances
Attributes:
clients (list): the underlying Client instances responsible for handling WebSocket connections.
"""
def __init__(self, clients):
self.clients = clients
if not self.clients:
raise Exception("At least one client is required.")
self.chunk = 4096
self.format = pyaudio.paInt16
self.channels = 1
self.rate = 16000
self.record_seconds = 60000
self.frames = b""
self.p = pyaudio.PyAudio()
try:
self.stream = self.p.open(
format=self.format,
channels=self.channels,
rate=self.rate,
input=True,
frames_per_buffer=self.chunk,
)
except OSError as error:
print(f"[WARN]: Unable to access microphone. {error}")
self.stream = None
def __call__(self, audio=None, hls_url=None):
"""
Start the transcription process.
Initiates the transcription process by connecting to the server via a WebSocket. It waits for the server
to be ready to receive audio data and then sends audio for transcription. If an audio file is provided, it
will be played and streamed to the server; otherwise, it will perform live recording.
Args:
audio (str, optional): Path to an audio file for transcription. Default is None, which triggers live recording.
"""
print("[INFO]: Waiting for server ready ...")
for client in self.clients:
while not client.recording:
if client.waiting or client.server_error:
self.close_all_clients()
return
print("[INFO]: Server Ready!")
if hls_url is not None:
self.process_hls_stream(hls_url)
elif audio is not None:
resampled_file = utils.resample(audio)
self.play_file(resampled_file)
else:
self.record()
def close_all_clients(self):
"""Closes all client websockets."""
for client in self.clients:
client.close_websocket()
def write_all_clients_srt(self):
"""Writes out .srt files for all clients."""
for client in self.clients:
client.write_srt_file(client.srt_file_path)
def multicast_packet(self, packet, unconditional=False):
"""
Sends an identical packet via all clients.
Args:
packet (bytes): The audio data packet in bytes to be sent.
unconditional (bool, optional): If true, send regardless of whether clients are recording. Default is False.
"""
for client in self.clients:
if (unconditional or client.recording):
client.send_packet_to_server(packet)
def play_file(self, filename):
"""
Play an audio file and send it to the server for processing.
Reads an audio file, plays it through the audio output, and simultaneously sends
the audio data to the server for processing. It uses PyAudio to create an audio
stream for playback. The audio data is read from the file in chunks, converted to
floating-point format, and sent to the server using WebSocket communication.
This method is typically used when you want to process pre-recorded audio and send it
to the server in real-time.
Args:
filename (str): The path to the audio file to be played and sent to the server.
"""
# read audio and create pyaudio stream
with wave.open(filename, "rb") as wavfile:
self.stream = self.p.open(
format=self.p.get_format_from_width(wavfile.getsampwidth()),
channels=wavfile.getnchannels(),
rate=wavfile.getframerate(),
input=True,
output=True,
frames_per_buffer=self.chunk,
)
try:
while any(client.recording for client in self.clients):
data = wavfile.readframes(self.chunk)
if data == b"":
break
audio_array = self.bytes_to_float_array(data)
self.multicast_packet(audio_array.tobytes())
self.stream.write(data)
wavfile.close()
for client in self.clients:
client.wait_before_disconnect()
self.multicast_packet(Client.END_OF_AUDIO.encode('utf-8'), True)
self.write_all_clients_srt()
self.stream.close()
self.close_all_clients()
except KeyboardInterrupt:
wavfile.close()
self.stream.stop_stream()
self.stream.close()
self.p.terminate()
self.close_all_clients()
self.write_all_clients_srt()
print("[INFO]: Keyboard interrupt.")
def process_hls_stream(self, hls_url):
"""
@@ -418,7 +424,7 @@ class Client:
if not in_bytes:
break
audio_array = self.bytes_to_float_array(in_bytes)
self.send_packet_to_server(audio_array.tobytes())
self.multicast_packet(audio_array.tobytes())
except Exception as e:
print(f"[ERROR]: Failed to connect to HLS stream: {e}")
@@ -428,7 +434,6 @@ class Client:
print("[INFO]: HLS stream processing finished.")
def record(self, out_file="output_recording.wav"):
"""
Record audio data from the input stream and save it to a WAV file.
@@ -439,11 +444,12 @@ class Client:
Audio data is saved in chunks to the "chunks" directory. Each chunk is saved as a separate WAV file.
The recording will continue until the specified duration is reached or until the `RECORDING` flag is set to `False`.
The recording process can be interrupted by sending a KeyboardInterrupt (e.g., pressing Ctrl+C). After recording,
The recording process can be interrupted by sending a KeyboardInterrupt (e.g., pressing Ctrl+C). After recording,
the method combines all the saved audio chunks into the specified `out_file`.
Args:
out_file (str, optional): The name of the output WAV file to save the entire recording. Default is "output_recording.wav".
out_file (str, optional): The name of the output WAV file to save the entire recording.
Default is "output_recording.wav".
"""
n_audio_file = 0
@@ -451,14 +457,14 @@ class Client:
os.makedirs("chunks", exist_ok=True)
try:
for _ in range(0, int(self.rate / self.chunk * self.record_seconds)):
if not self.recording:
if not any(client.recording for client in self.clients):
break
data = self.stream.read(self.chunk, exception_on_overflow = False)
data = self.stream.read(self.chunk, exception_on_overflow=False)
self.frames += data
audio_array = Client.bytes_to_float_array(data)
audio_array = self.bytes_to_float_array(data)
self.send_packet_to_server(audio_array.tobytes())
self.multicast_packet(audio_array.tobytes())
# save frames if more than a minute
if len(self.frames) > 60 * self.rate:
@@ -472,8 +478,7 @@ class Client:
t.start()
n_audio_file += 1
self.frames = b""
if self.server_backend == "faster_whisper":
self.write_srt_file(self.srt_file_path)
self.write_all_clients_srt()
except KeyboardInterrupt:
if len(self.frames):
@@ -484,17 +489,36 @@ class Client:
self.stream.stop_stream()
self.stream.close()
self.p.terminate()
self.close_websocket()
for client in self.clients:
client.close_all_clients()
self.write_output_recording(n_audio_file, out_file)
if self.server_backend == "faster_whisper":
self.write_srt_file(self.srt_file_path)
self.write_all_clients_srt()
def write_audio_frames_to_file(self, frames, file_name):
"""
Write audio frames to a WAV file.
The WAV file is created or overwritten with the specified name. The audio frames should be
in the correct format and match the specified channel, sample width, and sample rate.
Args:
frames (bytes): The audio frames to be written to the file.
file_name (str): The name of the WAV file to which the frames will be written.
"""
with wave.open(file_name, "wb") as wavfile:
wavfile: wave.Wave_write
wavfile.setnchannels(self.channels)
wavfile.setsampwidth(2)
wavfile.setframerate(self.rate)
wavfile.writeframes(frames)
def write_output_recording(self, n_audio_file, out_file):
"""
Combine and save recorded audio chunks into a single WAV file.
The individual audio chunk files are expected to be located in the "chunks" directory. Reads each chunk
The individual audio chunk files are expected to be located in the "chunks" directory. Reads each chunk
file, appends its audio data to the final recording, and then deletes the chunk file. After combining
and saving, the final recording is stored in the specified `out_file`.
@@ -525,14 +549,26 @@ class Client:
os.remove(in_file)
wavfile.close()
def write_srt_file(self, output_path="output.srt"):
self.transcript.append(self.last_segment)
create_srt_file(self.transcript, output_path)
@staticmethod
def bytes_to_float_array(audio_bytes):
"""
Convert audio data from bytes to a NumPy float array.
It assumes that the audio data is in 16-bit PCM format. The audio data is normalized to
have values between -1 and 1.
class TranscriptionClient:
Args:
audio_bytes (bytes): Audio data in bytes.
Returns:
np.ndarray: A NumPy array containing the audio data as float values normalized between -1 and 1.
"""
raw_data = np.frombuffer(buffer=audio_bytes, dtype=np.int16)
return raw_data.astype(np.float32) / 32768.0
class TranscriptionClient(TranscriptionTeeClient):
"""
Client for handling audio transcription tasks via a WebSocket connection.
Client for handling audio transcription tasks via a single WebSocket connection.
Acts as a high-level client for audio transcription tasks using a WebSocket connection. It can be used
to send audio data for transcription to a server and receive transcribed text segments.
@@ -553,38 +589,6 @@ class TranscriptionClient:
transcription_client()
```
"""
def __init__(self,
host,
port,
lang=None,
translate=False,
model="small",
):
self.client = Client(host, port, lang, translate, model)
def __call__(self, audio=None, hls_url=None):
"""
Start the transcription process.
Initiates the transcription process by connecting to the server via a WebSocket. It waits for the server
to be ready to receive audio data and then sends audio for transcription. If an audio file is provided, it
will be played and streamed to the server; otherwise, it will perform live recording.
Args:
audio (str, optional): Path to an audio file for transcription. Default is None, which triggers live recording.
"""
print("[INFO]: Waiting for server ready ...")
while not self.client.recording:
if self.client.waiting or self.client.server_error:
self.client.close_websocket()
return
print("[INFO]: Server Ready!")
if hls_url is not None:
self.client.process_hls_stream(hls_url)
elif audio is not None:
resampled_file = resample(audio)
self.client.play_file(resampled_file)
else:
self.client.record()
def __init__(self, host, port, lang=None, translate=False, model="small", use_vad=True):
self.client = Client(host, port, lang, translate, model, srt_file_path="output.srt", use_vad=use_vad)
TranscriptionTeeClient.__init__(self, [self.client])
+593 -423
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -214,7 +214,7 @@ def store_transcripts(filename: Pathlike, texts: Iterable[Tuple[str, str,
print(f"{cut_id}:\thyp={hyp}", file=f)
def write_error_stats(
def write_error_stats( # noqa: C901
f: TextIO,
test_set_name: str,
results: List[Tuple[str, str]],
@@ -362,4 +362,4 @@ def write_error_stats(
hyp_count = corr + hyp_sub + ins
print(f"{word} {corr} {tot_errs} {ref_count} {hyp_count}", file=f)
return float(tot_err_rate)
return float(tot_err_rate)
+197 -29
View File
@@ -1,22 +1,22 @@
# original https://github.com/guillaumekln/faster-whisper/blob/master/faster_whisper/transcribe.py
import itertools
import json
import logging
import os
import zlib
import json
from inspect import signature
from inspect import signature
from typing import BinaryIO, Iterable, List, NamedTuple, Optional, Tuple, Union
import ctranslate2
import numpy as np
import tokenizers
from faster_whisper.audio import decode_audio
from faster_whisper.audio import decode_audio, pad_or_trim
from faster_whisper.feature_extractor import FeatureExtractor
from faster_whisper.tokenizer import _LANGUAGE_CODES, Tokenizer
from faster_whisper.utils import download_model, format_timestamp, get_logger
from faster_whisper.utils import download_model, format_timestamp, get_end, get_logger
from faster_whisper.vad import (
SpeechTimestampsMap,
VadOptions,
@@ -68,6 +68,9 @@ class TranscriptionOptions(NamedTuple):
word_timestamps: bool
prepend_punctuations: str
append_punctuations: str
max_new_tokens: Optional[int]
clip_timestamps: Union[str, List[float]]
hallucination_silence_threshold: Optional[float]
class TranscriptionInfo(NamedTuple):
@@ -96,8 +99,8 @@ class WhisperModel:
Args:
model_size_or_path: Size of the model to use (tiny, tiny.en, base, base.en,
small, small.en, medium, medium.en, large-v1, large-v2, large-v3, or large), a path to a converted
model directory, or a CTranslate2-converted Whisper model ID from the Hugging Face Hub.
small, small.en, medium, medium.en, large-v1, large-v2, large-v3, or large), a path to a
converted model directory, or a CTranslate2-converted Whisper model ID from the HF Hub.
When a size or a model ID is configured, the converted model is downloaded
from the Hugging Face Hub.
device: Device to use for computation ("cpu", "cuda", "auto").
@@ -180,7 +183,7 @@ class WhisperModel:
return config
def transcribe(
def transcribe( # noqa: C901
self,
audio: Union[str, BinaryIO, np.ndarray],
language: Optional[str] = None,
@@ -215,6 +218,10 @@ class WhisperModel:
append_punctuations: str = "\"'.。,!?::”)]}、",
vad_filter: bool = False,
vad_parameters: Optional[Union[dict, VadOptions]] = None,
max_new_tokens: Optional[int] = None,
chunk_length: Optional[int] = None,
clip_timestamps: Union[str, List[float]] = "0",
hallucination_silence_threshold: Optional[float] = None,
) -> Tuple[Iterable[Segment], TranscriptionInfo]:
"""Transcribes an input file.
@@ -266,6 +273,16 @@ class WhisperModel:
https://github.com/snakers4/silero-vad.
vad_parameters: Dictionary of Silero VAD parameters or VadOptions class (see available
parameters and default values in the class `VadOptions`).
max_new_tokens: Maximum number of new tokens to generate per-chunk. If not set,
the maximum will be set by the default max_length.
chunk_length: The length of audio segments. If it is not None, it will overwrite the
default chunk_length of the FeatureExtractor.
clip_timestamps: Union[str, List[float]]
Comma-separated list start,end,start,end,... timestamps (in seconds) of clips to
process. The last end timestamp defaults to the end of the file.
hallucination_silence_threshold: Optional[float]
When word_timestamps is True, skip silent periods longer than this threshold
(in seconds) when a possible hallucination is detected
Returns:
A tuple with:
@@ -315,7 +332,10 @@ class WhisperModel:
else:
speech_chunks = None
features = self.feature_extractor(audio)
if audio.shape[0] == 0:
return None, None
features = self.feature_extractor(audio, chunk_length=chunk_length)
encoder_output = None
all_language_probs = None
@@ -381,6 +401,9 @@ class WhisperModel:
word_timestamps=word_timestamps,
prepend_punctuations=prepend_punctuations,
append_punctuations=append_punctuations,
max_new_tokens=max_new_tokens,
clip_timestamps=clip_timestamps,
hallucination_silence_threshold=hallucination_silence_threshold,
)
segments = self.generate_segments(features, tokenizer, options, encoder_output)
@@ -408,8 +431,33 @@ class WhisperModel:
encoder_output: Optional[ctranslate2.StorageView] = None,
) -> Iterable[Segment]:
content_frames = features.shape[-1] - self.feature_extractor.nb_max_frames
content_duration = float(content_frames * self.feature_extractor.time_per_frame)
if isinstance(options.clip_timestamps, str):
TranscriptionOptions.clip_timestamps = [
float(ts)
for ts in (
options.clip_timestamps.split(",")
if options.clip_timestamps
else []
)
]
seek_points: List[int] = [
round(ts * self.frames_per_second) for ts in options.clip_timestamps
]
if len(seek_points) == 0:
seek_points.append(0)
if len(seek_points) % 2 == 1:
seek_points.append(content_frames)
seek_clips: List[Tuple[int, int]] = list(
zip(seek_points[::2], seek_points[1::2])
)
punctuation = "\"'“¿([{-\"'.。,!?::”)]}、"
idx = 0
seek = 0
clip_idx = 0
seek = seek_clips[clip_idx][0]
all_tokens = []
prompt_reset_since = 0
@@ -423,13 +471,34 @@ class WhisperModel:
last_speech_timestamp = 0.0
all_segments = []
while seek < content_frames:
# NOTE: This loop is obscurely flattened to make the diff readable.
# A later commit should turn this into a simpler nested loop.
# for seek_clip_start, seek_clip_end in seek_clips:
# while seek < seek_clip_end
while clip_idx < len(seek_clips):
seek_clip_start, seek_clip_end = seek_clips[clip_idx]
if seek_clip_end > content_frames:
seek_clip_end = content_frames
if seek < seek_clip_start:
seek = seek_clip_start
if seek >= seek_clip_end:
clip_idx += 1
if clip_idx < len(seek_clips):
seek = seek_clips[clip_idx][0]
continue
time_offset = seek * self.feature_extractor.time_per_frame
segment = features[:, seek : seek + self.feature_extractor.nb_max_frames]
segment_size = min(
self.feature_extractor.nb_max_frames, content_frames - seek
window_end_time = float(
(seek + self.feature_extractor.nb_max_frames)
* self.feature_extractor.time_per_frame
)
segment_size = min(
self.feature_extractor.nb_max_frames,
content_frames - seek,
seek_clip_end - seek,
)
segment = features[:, seek : seek + segment_size]
segment_duration = segment_size * self.feature_extractor.time_per_frame
segment = pad_or_trim(segment, self.feature_extractor.nb_max_frames)
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug(
@@ -481,10 +550,33 @@ class WhisperModel:
previous_seek = seek
current_segments = []
# anomalous words are very long/short/improbable
def word_anomaly_score(word: dict) -> float:
probability = word.get("probability", 0.0)
duration = word["end"] - word["start"]
score = 0.0
if probability < 0.15:
score += 1.0
if duration < 0.133:
score += (0.133 - duration) * 15
if duration > 2.0:
score += duration - 2.0
return score
def is_segment_anomaly(segment: Optional[dict]) -> bool:
if segment is None or not segment["words"]:
return False
words = [w for w in segment["words"] if w["word"] not in punctuation]
words = words[:8]
score = sum(word_anomaly_score(w) for w in words)
return score >= 3 or score + 0.01 >= len(words)
def next_words_segment(segments: List[dict]) -> Optional[dict]:
return next((s for s in segments if s["words"]), None)
single_timestamp_ending = (
len(tokens) >= 2
and tokens[-2] < tokenizer.timestamp_begin
and tokens[-1] >= tokenizer.timestamp_begin
and tokens[-2] < tokenizer.timestamp_begin <= tokens[-1]
)
consecutive_timestamps = [
@@ -567,18 +659,62 @@ class WhisperModel:
last_speech_timestamp=last_speech_timestamp,
)
word_end_timestamps = [
w["end"] for s in current_segments for w in s["words"]
]
if len(word_end_timestamps) > 0:
last_speech_timestamp = word_end_timestamps[-1]
if not single_timestamp_ending and len(word_end_timestamps) > 0:
seek_shift = round(
(word_end_timestamps[-1] - time_offset) * self.frames_per_second
)
if not single_timestamp_ending:
last_word_end = get_end(current_segments)
if last_word_end is not None and last_word_end > time_offset:
seek = round(last_word_end * self.frames_per_second)
if seek_shift > 0:
seek = previous_seek + seek_shift
# skip silence before possible hallucinations
if options.hallucination_silence_threshold is not None:
threshold = options.hallucination_silence_threshold
# if first segment might be a hallucination, skip leading silence
first_segment = next_words_segment(current_segments)
if first_segment is not None and is_segment_anomaly(first_segment):
gap = first_segment["start"] - time_offset
if gap > threshold:
seek = previous_seek + round(gap * self.frames_per_second)
continue
# skip silence before any possible hallucination that is surrounded
# by silence or more hallucinations
hal_last_end = last_speech_timestamp
for si in range(len(current_segments)):
segment = current_segments[si]
if not segment["words"]:
continue
if is_segment_anomaly(segment):
next_segment = next_words_segment(
current_segments[si + 1 :]
)
if next_segment is not None:
hal_next_start = next_segment["words"][0]["start"]
else:
hal_next_start = time_offset + segment_duration
silence_before = (
segment["start"] - hal_last_end > threshold
or segment["start"] < threshold
or segment["start"] - time_offset < 2.0
)
silence_after = (
hal_next_start - segment["end"] > threshold
or is_segment_anomaly(next_segment)
or window_end_time - segment["end"] < 2.0
)
if silence_before and silence_after:
seek = round(
max(time_offset + 1, segment["start"])
* self.frames_per_second
)
if content_duration - segment["end"] < threshold:
seek = content_frames
current_segments[si:] = []
break
hal_last_end = segment["end"]
last_word_end = get_end(current_segments)
if last_word_end is not None:
last_speech_timestamp = last_word_end
for segment in current_segments:
tokens = segment["tokens"]
@@ -605,7 +741,7 @@ class WhisperModel:
[Word(**word) for word in segment["words"]]
if options.word_timestamps
else None
),
),
))
if (
@@ -646,6 +782,21 @@ class WhisperModel:
max_initial_timestamp_index = int(
round(options.max_initial_timestamp / self.time_precision)
)
if options.max_new_tokens is not None:
max_length = len(prompt) + options.max_new_tokens
else:
max_length = self.max_length
if max_length > self.max_length:
raise ValueError(
f"The length of the prompt is {len(prompt)}, and the `max_new_tokens` "
f"{max_length - len(prompt)}. Thus, the combined length of the prompt "
f"and `max_new_tokens` is: {max_length}. This exceeds the "
f"`max_length` of the Whisper model: {self.max_length}. "
"You should either reduce the length of your prompt, or "
"reduce the value of `max_new_tokens`, "
f"so that their combined length is less that {self.max_length}."
)
for temperature in options.temperatures:
if temperature > 0:
@@ -667,7 +818,7 @@ class WhisperModel:
length_penalty=options.length_penalty,
repetition_penalty=options.repetition_penalty,
no_repeat_ngram_size=options.no_repeat_ngram_size,
max_length=self.max_length,
max_length=max_length,
return_scores=True,
return_no_speech_prob=True,
suppress_blank=options.suppress_blank,
@@ -725,6 +876,8 @@ class WhisperModel:
if (
options.no_speech_threshold is not None
and result.no_speech_prob > options.no_speech_threshold
and options.log_prob_threshold is not None
and avg_logprob < options.log_prob_threshold
):
needs_fallback = False # silence
@@ -735,6 +888,13 @@ class WhisperModel:
decode_result = max(
below_cr_threshold_results or all_results, key=lambda x: x[1]
)
# to pass final temperature for prompt_reset_on_temperature
decode_result = (
decode_result[0],
decode_result[1],
temperature,
decode_result[3],
)
return decode_result
@@ -766,7 +926,7 @@ class WhisperModel:
return prompt
def add_word_timestamps(
def add_word_timestamps( # noqa: C901
self,
segments: List[dict],
tokenizer: Tokenizer,
@@ -791,6 +951,7 @@ class WhisperModel:
word_durations = np.array([word["end"] - word["start"] for word in alignment])
word_durations = word_durations[word_durations.nonzero()]
median_duration = np.median(word_durations) if len(word_durations) > 0 else 0.0
median_duration = min(0.7, float(median_duration))
max_duration = median_duration * 2
# hack: truncate long words at sentence boundaries.
@@ -912,6 +1073,13 @@ class WhisperModel:
words, word_tokens = tokenizer.split_to_word_tokens(
text_tokens + [tokenizer.eot]
)
if len(word_tokens) <= 1:
# return on eot only
# >>> np.pad([], (1, 0))
# array([0.])
# This results in crashes when we lookup jump_times with float, like
# IndexError: arrays used as indices must be of integer (or boolean) type
return []
word_boundaries = np.pad(np.cumsum([len(t) for t in word_tokens[:-1]]), (1, 0))
if len(word_boundaries) <= 1:
return []
+10 -30
View File
@@ -1,17 +1,14 @@
import argparse
import json
import re
import time
from collections import OrderedDict
from pathlib import Path
from typing import Dict, Iterable, List, Optional, TextIO, Tuple, Union
from typing import Union
import torch
import numpy as np
import torch.nn.functional as F
from whisper.tokenizer import get_tokenizer
from whisper_live.tensorrt_utils import (mel_filters, store_transcripts,
write_error_stats, load_audio_wav_format,
pad_or_trim, load_audio)
from whisper_live.tensorrt_utils import (mel_filters, load_audio_wav_format, pad_or_trim, load_audio)
import tensorrt_llm
import tensorrt_llm.logger as logger
@@ -38,8 +35,6 @@ class WhisperEncoding:
with open(config_path, 'r') as f:
config = json.load(f)
use_gpt_attention_plugin = config['plugin_config'][
'gpt_attention_plugin']
dtype = config['builder_config']['precision']
n_mels = config['builder_config']['n_mels']
num_languages = config['builder_config']['num_languages']
@@ -176,16 +171,8 @@ class WhisperDecoding:
class WhisperTRTLLM(object):
def __init__(
self,
engine_dir,
debug_mode=False,
assets_dir=None,
device=None,
is_multilingual=False,
language="en",
task="transcribe"
):
def __init__(self, engine_dir, assets_dir=None, device=None, is_multilingual=False,
language="en", task="transcribe"):
world_size = 1
runtime_rank = tensorrt_llm.mpi_rank()
runtime_mapping = tensorrt_llm.Mapping(world_size, runtime_rank)
@@ -212,7 +199,7 @@ class WhisperTRTLLM(object):
self,
audio: Union[str, np.ndarray, torch.Tensor],
padding: int = 0,
return_duration = True
return_duration=True
):
"""
Compute the log-Mel spectrogram of
@@ -242,8 +229,7 @@ class WhisperTRTLLM(object):
audio, _ = load_audio_wav_format(audio)
else:
audio = load_audio(audio)
assert isinstance(audio,
np.ndarray), f"Unsupported audio type: {type(audio)}"
assert isinstance(audio, np.ndarray), f"Unsupported audio type: {type(audio)}"
duration = audio.shape[-1] / SAMPLE_RATE
audio = pad_or_trim(audio, N_SAMPLES)
audio = audio.astype(np.float32)
@@ -254,14 +240,9 @@ class WhisperTRTLLM(object):
if padding > 0:
audio = F.pad(audio, (0, padding))
window = torch.hann_window(N_FFT).to(audio.device)
stft = torch.stft(audio,
N_FFT,
HOP_LENGTH,
window=window,
return_complex=True)
stft = torch.stft(audio, N_FFT, HOP_LENGTH, window=window, return_complex=True)
magnitudes = stft[..., :-1].abs()**2
mel_spec = self.filters @ magnitudes
log_spec = torch.clamp(mel_spec, min=1e-10).log10()
@@ -272,7 +253,6 @@ class WhisperTRTLLM(object):
else:
return log_spec
def process_batch(
self,
mel,
@@ -296,7 +276,7 @@ class WhisperTRTLLM(object):
text = self.tokenizer.decode(output_ids[i][0]).strip()
texts.append(text)
return texts
def transcribe(
self,
mel,
@@ -336,5 +316,5 @@ def decode_wav_file(
prediction = re.sub(r'<\|.*?\|>', '', prediction)
if normalizer:
prediction = normalizer(prediction)
return prediction.strip()
+71
View File
@@ -0,0 +1,71 @@
import os
import textwrap
import scipy
import ffmpeg
import numpy as np
def clear_screen():
"""Clears the console screen."""
os.system("cls" if os.name == "nt" else "clear")
def print_transcript(text):
"""Prints formatted transcript text."""
wrapper = textwrap.TextWrapper(width=60)
for line in wrapper.wrap(text="".join(text)):
print(line)
def format_time(s):
"""Convert seconds (float) to SRT time format."""
hours = int(s // 3600)
minutes = int((s % 3600) // 60)
seconds = int(s % 60)
milliseconds = int((s - int(s)) * 1000)
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
def create_srt_file(segments, output_file):
with open(output_file, 'w', encoding='utf-8') as srt_file:
segment_number = 1
for segment in segments:
start_time = format_time(float(segment['start']))
end_time = format_time(float(segment['end']))
text = segment['text']
srt_file.write(f"{segment_number}\n")
srt_file.write(f"{start_time} --> {end_time}\n")
srt_file.write(f"{text}\n\n")
segment_number += 1
def resample(file: str, sr: int = 16000):
"""
# https://github.com/openai/whisper/blob/7858aa9c08d98f75575035ecd6481f462d66ca27/whisper/audio.py#L22
Open an audio file and read as mono waveform, resampling as necessary,
save the resampled audio
Args:
file (str): The audio file to open
sr (int): The sample rate to resample the audio if necessary
Returns:
resampled_file (str): The resampled audio file
"""
try:
# This launches a subprocess to decode audio while down-mixing and resampling as necessary.
# Requires the ffmpeg CLI and `ffmpeg-python` package to be installed.
out, _ = (
ffmpeg.input(file, threads=0)
.output("-", format="s16le", acodec="pcm_s16le", ac=1, ar=sr)
.run(cmd=["ffmpeg", "-nostdin"], capture_stdout=True, capture_stderr=True)
)
except ffmpeg.Error as e:
raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}") from e
np_buffer = np.frombuffer(out, dtype=np.int16)
resampled_file = f"{file.split('.')[0]}_resampled.wav"
scipy.io.wavfile.write(resampled_file, sr, np_buffer.astype(np.int16))
return resampled_file
+31 -7
View File
@@ -10,9 +10,7 @@ import onnxruntime
class VoiceActivityDetection():
def __init__(self, force_onnx_cpu=True):
print("downloading ONNX model...")
path = self.download()
print("loading session")
opts = onnxruntime.SessionOptions()
opts.log_severity_level = 3
@@ -20,13 +18,11 @@ class VoiceActivityDetection():
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
print("loading onnx model")
if force_onnx_cpu and 'CPUExecutionProvider' in onnxruntime.get_available_providers():
self.session = onnxruntime.InferenceSession(path, providers=['CPUExecutionProvider'], sess_options=opts)
else:
self.session = onnxruntime.InferenceSession(path, providers=['CUDAExecutionProvider'], sess_options=opts)
print("reset states")
self.reset_states()
self.sample_rates = [8000, 16000]
@@ -38,7 +34,7 @@ class VoiceActivityDetection():
if sr != 16000 and (sr % 16000 == 0):
step = sr // 16000
x = x[:,::step]
x = x[:, ::step]
sr = 16000
if sr not in self.sample_rates:
@@ -110,9 +106,37 @@ class VoiceActivityDetection():
# Check if the model file already exists
if not os.path.exists(model_filename):
# If it doesn't exist, download the model using wget
print("Downloading VAD ONNX model...")
try:
subprocess.run(["wget", "-O", model_filename, model_url], check=True)
except subprocess.CalledProcessError:
print("Failed to download the model using wget.")
return model_filename
return model_filename
class VoiceActivityDetector:
def __init__(self, threshold=0.5, frame_rate=16000):
"""
Initializes the VoiceActivityDetector with a voice activity detection model and a threshold.
Args:
threshold (float, optional): The probability threshold for detecting voice activity. Defaults to 0.5.
"""
self.model = VoiceActivityDetection()
self.threshold = threshold
self.frame_rate = frame_rate
def __call__(self, audio_frame):
"""
Determines if the given audio frame contains speech by comparing the detected speech probability against
the threshold.
Args:
audio_frame (np.ndarray): The audio frame to be analyzed for voice activity. It is expected to be a
NumPy array of audio samples.
Returns:
bool: True if the speech probability exceeds the threshold, indicating the presence of voice activity;
False otherwise.
"""
speech_prob = self.model(torch.from_numpy(audio_frame), self.frame_rate).item()
return speech_prob > self.threshold