Compare commits
227 Commits
0.2.4.dev0
...
0.2.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6206fff118 | ||
|
|
b5067249c0 | ||
|
|
f4f9831d39 | ||
|
|
254faaf64c | ||
|
|
8e7aea4fcf | ||
|
|
270faf2069 | ||
|
|
b7c1cc77cc | ||
|
|
9a45ec221c | ||
|
|
3e13ee6fc3 | ||
|
|
b7d20a0ff0 | ||
|
|
c1bb9c2bde | ||
|
|
11e9def0b2 | ||
|
|
3104f40f6e | ||
|
|
e9b4ceeee5 | ||
|
|
437641fb43 | ||
|
|
bfd60b3921 | ||
|
|
1e67bf97f0 | ||
|
|
bbd4fd6cff | ||
|
|
28985962a0 | ||
|
|
a38c103fcd | ||
|
|
4d2ffb24f8 | ||
|
|
1bbbb7903c | ||
|
|
bcffdbc6b3 | ||
|
|
80b77998f9 | ||
|
|
d310f7e25f | ||
|
|
8d9be88fe6 | ||
|
|
16461052ed | ||
|
|
5491dbd824 | ||
|
|
13401ffe24 | ||
|
|
7108d2ddc5 | ||
|
|
a732e0903e | ||
|
|
0491681be4 | ||
|
|
ffe5284764 | ||
|
|
41ca17acda | ||
|
|
06b31f51eb | ||
|
|
ece02db6a3 | ||
|
|
939a7ebf8b | ||
|
|
61edb70fff | ||
|
|
4e455b8aab | ||
|
|
9434390ad3 | ||
|
|
65250db92c | ||
|
|
416dce7975 | ||
|
|
0c5365e7c6 | ||
|
|
19e9d76610 | ||
|
|
e7b05b0138 | ||
|
|
818c9c37ca | ||
|
|
714fb3b14a | ||
|
|
0af379c465 | ||
|
|
9c5bb5df19 | ||
|
|
dc6ea79036 | ||
|
|
21bbb59e31 | ||
|
|
12a69205ed | ||
|
|
1f684cdd97 | ||
|
|
3467109668 | ||
|
|
971f8473eb | ||
|
|
8434ef5efc | ||
|
|
290470dd60 | ||
|
|
425ac7b51d | ||
|
|
0382cfbeba | ||
|
|
9b1e061b32 | ||
|
|
b4abc158b9 | ||
|
|
5832d7433d | ||
|
|
3736458503 | ||
|
|
374618e050 | ||
|
|
543972ef38 | ||
|
|
73f36cc0ef | ||
|
|
a7db39d999 | ||
|
|
a153e11fe0 | ||
|
|
ca6f9246cc | ||
|
|
d080d675a8 | ||
|
|
40bff38933 | ||
|
|
2fe3ca0188 | ||
|
|
545ea15c9a | ||
|
|
8cbaeecc75 | ||
|
|
70e854b346 | ||
|
|
d55490cd27 | ||
|
|
1fa9e1f656 | ||
|
|
994f30e1ed | ||
|
|
b22478c0b4 | ||
|
|
94c34efd90 | ||
|
|
32099b9275 | ||
|
|
9fc6654a4a | ||
|
|
d24c110d55 | ||
|
|
4dd5d8bf8a | ||
|
|
cd9a32a36b | ||
|
|
6caf3e0485 | ||
|
|
93f002cafb | ||
|
|
c5e30c2c07 | ||
|
|
1c2afb8bd2 | ||
|
|
674b20d3af | ||
|
|
a5503308c5 | ||
|
|
e61afdefa3 | ||
|
|
426d70a790 | ||
|
|
b03a212fbf | ||
|
|
1833e7c921 | ||
|
|
777ec63a71 | ||
|
|
0a6e5ae9c1 | ||
|
|
ee448a37e9 | ||
|
|
9c051052b0 | ||
|
|
4d7c487614 | ||
|
|
65025cc448 | ||
|
|
bbba1d9bb7 | ||
|
|
99dc96c644 | ||
|
|
2a27d2030a | ||
|
|
cd160caaa1 | ||
|
|
d27b5eb23e | ||
|
|
f9d704a900 | ||
|
|
2f6e00f512 | ||
|
|
5aa312e437 | ||
|
|
ebaf36a8be | ||
|
|
babe93b99a | ||
|
|
a4e9f3cab7 | ||
|
|
b06866877a | ||
|
|
967cdfebc8 | ||
|
|
3c11c60126 | ||
|
|
2963e8a757 | ||
|
|
cb2d4ea88a | ||
|
|
add7ea07ee | ||
|
|
da8726b2cb | ||
|
|
3358877054 | ||
|
|
1f7798c7c1 | ||
|
|
c7b3bb5e58 | ||
|
|
f661f21675 | ||
|
|
b6164aa59b | ||
|
|
4209d7f7c0 | ||
|
|
334b338ab0 | ||
|
|
72f33be6f2 | ||
|
|
84890b8e61 | ||
|
|
c6668adcf3 | ||
|
|
a178ed5c22 | ||
|
|
7601c74c9c | ||
|
|
fad9ee4d21 | ||
|
|
d1a9913c47 | ||
|
|
e4ca2623cb | ||
|
|
9c1bf37960 | ||
|
|
f46528471b | ||
|
|
191680940b | ||
|
|
ee02afec56 | ||
|
|
a458028de2 | ||
|
|
abd8f2c269 | ||
|
|
f3ad4e39e4 | ||
|
|
e0a5cbf0e7 | ||
|
|
953697cd86 | ||
|
|
3bd2122eb4 | ||
|
|
50b0527858 | ||
|
|
b044fcdec2 | ||
|
|
b0508fcf2c | ||
|
|
ce89b0aebc | ||
|
|
d5008ed828 | ||
|
|
d467716e26 | ||
|
|
199e21b3ef | ||
|
|
1d926f2e67 | ||
|
|
4a71a391b8 | ||
|
|
d3ed4e46e2 | ||
|
|
057a1026d7 | ||
|
|
1ba171a58d | ||
|
|
1adac67155 | ||
|
|
42be1a3773 | ||
|
|
0a49fafa0d | ||
|
|
4a5d5e1f3b | ||
|
|
583a2ec2e4 | ||
|
|
19765e89e9 | ||
|
|
9895bc83bf | ||
|
|
ab98c31f16 | ||
|
|
f9c9c4188a | ||
|
|
c21d2302e7 | ||
|
|
4ed62e181d | ||
|
|
52a755a08c | ||
|
|
9a8d3cbd90 | ||
|
|
b101ce06bd | ||
|
|
c83fd179a8 | ||
|
|
5258305745 | ||
|
|
ce781831ee | ||
|
|
58297daf6d | ||
|
|
3393a08f7e | ||
|
|
5b2ddeccdb | ||
|
|
26cc1072dd | ||
|
|
12973711f6 | ||
|
|
909ac9dd41 | ||
|
|
d94a07d417 | ||
|
|
b32dd8bfc4 | ||
|
|
9feb0e597b | ||
|
|
9dab84a573 | ||
|
|
d089c7fce0 | ||
|
|
253a080df5 | ||
|
|
0c6e4b2aee | ||
|
|
e14bbde77d | ||
|
|
7496163467 | ||
|
|
696a94d1ce | ||
|
|
2699b0974c | ||
|
|
90c0250ba4 | ||
|
|
eb96153ffd | ||
|
|
47e3eb9b5b | ||
|
|
b8b07adeef | ||
|
|
d0e9e37ef6 | ||
|
|
820f92d8cb | ||
|
|
e42523af84 | ||
|
|
e2184d5e06 | ||
|
|
7fe0353260 | ||
|
|
0f2eba507e | ||
|
|
55e08474f3 | ||
|
|
28bdc52e1d | ||
|
|
e4221fa6c3 | ||
|
|
1652db9a2d | ||
|
|
601f17653a | ||
|
|
7718190fcd | ||
|
|
349c7dcb9e | ||
|
|
1c42b867cf | ||
|
|
d4771e563e | ||
|
|
b0a5fc0693 | ||
|
|
3b96fb8776 | ||
|
|
7f93c4b978 | ||
|
|
15c3df1cba | ||
|
|
7fb8e66c01 | ||
|
|
728e1f1290 | ||
|
|
87b9ed6ecd | ||
|
|
38b4ebe8ba | ||
|
|
d098af3185 | ||
|
|
4e56130a40 | ||
|
|
2bbdc70187 | ||
|
|
b678a55f63 | ||
|
|
5491964e81 | ||
|
|
b05297a96d | ||
|
|
197293e25e | ||
|
|
ba41c4ab56 | ||
|
|
bda72b8bc0 | ||
|
|
bb6b9f4cb1 |
19
.gitignore
vendored
@@ -54,21 +54,6 @@ coverage.xml
|
|||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
target/
|
target/
|
||||||
@@ -137,4 +122,6 @@ run_*.sh
|
|||||||
test_*.py
|
test_*.py
|
||||||
launch.json
|
launch.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
test/*
|
test/*
|
||||||
|
nllb-200-distilled-600M-ctranslate2/*
|
||||||
|
*.mp3
|
||||||
@@ -15,7 +15,7 @@ Thank you for considering contributing ! We appreciate your time and effort to h
|
|||||||
|
|
||||||
## Opening Issues
|
## Opening Issues
|
||||||
|
|
||||||
If you encounter a problem with diart or want to suggest an improvement, please follow these guidelines when opening an issue:
|
If you encounter a problem with WhisperLiveKit or want to suggest an improvement, please follow these guidelines when opening an issue:
|
||||||
|
|
||||||
- **Bug Reports:**
|
- **Bug Reports:**
|
||||||
- Clearly describe the error. **Please indicate the parameters you use, especially the model(s)**
|
- Clearly describe the error. **Please indicate the parameters you use, especially the model(s)**
|
||||||
@@ -43,4 +43,4 @@ We welcome and appreciate contributions! To ensure a smooth review process, plea
|
|||||||
|
|
||||||
## Thank You
|
## Thank You
|
||||||
|
|
||||||
Your contributions make diart better for everyone. Thank you for your time and dedication!
|
Your contributions make WhisperLiveKit better for everyone. Thank you for your time and dedication!
|
||||||
|
|||||||
91
DEV_NOTES.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 1. Simulstreaming: Decouple the encoder for faster inference
|
||||||
|
|
||||||
|
Simulstreaming encoder time (whisperlivekit/simul_whisper/simul_whisper.py l. 397) experimentations :
|
||||||
|
|
||||||
|
On macOS Apple Silicon M4 :
|
||||||
|
|
||||||
|
| Encoder | base.en | small |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| WHISPER (no modification) | 0.35s | 1.09s |
|
||||||
|
| FASTER_WHISPER | 0.4s | 1.20s |
|
||||||
|
| MLX_WHISPER | 0.07s | 0.20s |
|
||||||
|
|
||||||
|
Memory saved by only loading encoder for optimized framework:
|
||||||
|
|
||||||
|
For tiny.en, mlx whisper:
|
||||||
|
Sizes MLX whisper:
|
||||||
|
Decoder weights: 59110771 bytes
|
||||||
|
Encoder weights: 15268874 bytes
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Translation: Faster model for each system
|
||||||
|
|
||||||
|
## Benchmark Results
|
||||||
|
|
||||||
|
Testing on MacBook M3 with NLLB-200-distilled-600M model:
|
||||||
|
|
||||||
|
### Standard Transformers vs CTranslate2
|
||||||
|
|
||||||
|
| Test Text | Standard Inference Time | CTranslate2 Inference Time | Speedup |
|
||||||
|
|-----------|-------------------------|---------------------------|---------|
|
||||||
|
| UN Chief says there is no military solution in Syria | 0.9395s | 2.0472s | 0.5x |
|
||||||
|
| The rapid advancement of AI technology is transforming various industries | 0.7171s | 1.7516s | 0.4x |
|
||||||
|
| Climate change poses a significant threat to global ecosystems | 0.8533s | 1.8323s | 0.5x |
|
||||||
|
| International cooperation is essential for addressing global challenges | 0.7209s | 1.3575s | 0.5x |
|
||||||
|
| The development of renewable energy sources is crucial for a sustainable future | 0.8760s | 1.5589s | 0.6x |
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- Total Standard time: 4.1068s
|
||||||
|
- Total CTranslate2 time: 8.5476s
|
||||||
|
- CTranslate2 is slower on this system --> Use Transformers, and ideally we would have an mlx implementation.
|
||||||
|
|
||||||
|
|
||||||
|
# 3. SortFormer Diarization: 4-to-2 Speaker Constraint Algorithm
|
||||||
|
|
||||||
|
Transform a diarization model that predicts up to 4 speakers into one that predicts up to 2 speakers by mapping the output predictions.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
- Input: `self.total_preds` with shape `(x, x, 4)` - predictions for 4 speakers
|
||||||
|
- Output: Constrained predictions with shape `(x, x, 2)` - predictions for 2 speakers
|
||||||
|
|
||||||
|
#
|
||||||
|
### Initial Setup
|
||||||
|
For each time step `i`, we have a ranking of 4 speaker predictions (1-4). When only 2 speakers are present, the model will have close predictions for the 2 active speaker positions.
|
||||||
|
|
||||||
|
Instead of `np.argmax(preds_np, axis=1)`, we take the top 2 predictions and build a dynamic 4→2 mapping that can evolve over time.
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
```python
|
||||||
|
top_2_speakers = np.argsort(preds_np, axis=1)[:, -2:]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `DS_a_{i}`: Top detected speaker for prediction i
|
||||||
|
- `DS_b_{i}`: Second detected speaker for prediction i
|
||||||
|
- `AS_{i}`: Attributed speaker for prediction i
|
||||||
|
- `GTS_A`: Ground truth speaker A
|
||||||
|
- `GTS_B`: Ground truth speaker B
|
||||||
|
- `DIST(a, b)`: Distance between detected speakers a and b
|
||||||
|
|
||||||
|
3. **Attribution Logic**
|
||||||
|
|
||||||
|
```
|
||||||
|
AS_0 ← A
|
||||||
|
|
||||||
|
AS_1 ← B
|
||||||
|
|
||||||
|
IF DIST(DS_a_0, DS_a_1) < DIST(DS_a_0, DS_a_2) AND
|
||||||
|
DIST(DS_a_0, DS_a_1) < DIST(DS_a_1, DS_a_2):
|
||||||
|
# Likely that DS_a_0 = DS_a_1 (same speaker)
|
||||||
|
AS_1 ← A
|
||||||
|
AS_2 ← B
|
||||||
|
|
||||||
|
ELIF DIST(DS_a_0, DS_a_2) < DIST(DS_a_0, DS_a_1) AND
|
||||||
|
DIST(DS_a_0, DS_a_2) < DIST(DS_a_1, DS_a_2):
|
||||||
|
AS_2 ← A
|
||||||
|
|
||||||
|
ELSE:
|
||||||
|
AS_2 ← B
|
||||||
|
|
||||||
|
to finish
|
||||||
|
```
|
||||||
47
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04
|
FROM nvidia/cuda:12.9.1-cudnn-devel-ubuntu24.04
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
@@ -9,48 +9,50 @@ ARG EXTRAS
|
|||||||
ARG HF_PRECACHE_DIR
|
ARG HF_PRECACHE_DIR
|
||||||
ARG HF_TKN_FILE
|
ARG HF_TKN_FILE
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
#RUN apt-get update && \
|
|
||||||
# apt-get install -y ffmpeg git && \
|
|
||||||
# apt-get clean && \
|
|
||||||
# rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# 2) Install system dependencies + Python + pip
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
git \
|
git \
|
||||||
build-essential \
|
build-essential \
|
||||||
python3-dev && \
|
python3-dev \
|
||||||
|
ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
RUN python3 -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# timeout/retries for large torch wheels
|
||||||
|
RUN pip3 install --upgrade pip setuptools wheel && \
|
||||||
|
pip3 --disable-pip-version-check install --timeout=120 --retries=5 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu129 \
|
||||||
|
torch torchaudio \
|
||||||
|
|| (echo "Initial install failed — retrying with extended timeout..." && \
|
||||||
|
pip3 --disable-pip-version-check install --timeout=300 --retries=3 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu129 \
|
||||||
|
torch torchvision torchaudio)
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install WhisperLiveKit directly, allowing for optional dependencies
|
# Install WhisperLiveKit directly, allowing for optional dependencies
|
||||||
# Note: For gates models, need to add your HF toke. See README.md
|
|
||||||
# for more details.
|
|
||||||
RUN if [ -n "$EXTRAS" ]; then \
|
RUN if [ -n "$EXTRAS" ]; then \
|
||||||
echo "Installing with extras: [$EXTRAS]"; \
|
echo "Installing with extras: [$EXTRAS]"; \
|
||||||
pip install --no-cache-dir .[$EXTRAS]; \
|
pip install --no-cache-dir whisperlivekit[$EXTRAS]; \
|
||||||
else \
|
else \
|
||||||
echo "Installing base package only"; \
|
echo "Installing base package only"; \
|
||||||
pip install --no-cache-dir .; \
|
pip install --no-cache-dir whisperlivekit; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Enable in-container caching for Hugging Face models by:
|
# In-container caching for Hugging Face models by:
|
||||||
# Note: If running multiple containers, better to map a shared
|
|
||||||
# bucket.
|
|
||||||
#
|
|
||||||
# A) Make the cache directory persistent via an anonymous volume.
|
# A) Make the cache directory persistent via an anonymous volume.
|
||||||
# Note: This only persists for a single, named container. This is
|
# Note: This only persists for a single, named container. This is
|
||||||
# only for convenience at de/test stage.
|
# only for convenience at de/test stage.
|
||||||
# For prod, it is better to use a named volume via host mount/k8s.
|
# For prod, it is better to use a named volume via host mount/k8s.
|
||||||
VOLUME ["/root/.cache/huggingface/hub"]
|
VOLUME ["/root/.cache/huggingface/hub"]
|
||||||
|
|
||||||
|
|
||||||
# or
|
# or
|
||||||
# B) Conditionally copy a local pre-cache from the build context to the
|
# B) Conditionally copy a local pre-cache from the build context to the
|
||||||
# container's cache via the HF_PRECACHE_DIR build-arg.
|
# container's cache via the HF_PRECACHE_DIR build-arg.
|
||||||
@@ -65,8 +67,7 @@ RUN if [ -n "$HF_PRECACHE_DIR" ]; then \
|
|||||||
echo "No local Hugging Face cache specified, skipping copy"; \
|
echo "No local Hugging Face cache specified, skipping copy"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Conditionally copy a Hugging Face token if provided
|
# Conditionally copy a Hugging Face token if provided. Useful for Diart backend (pyannote audio models)
|
||||||
|
|
||||||
RUN if [ -n "$HF_TKN_FILE" ]; then \
|
RUN if [ -n "$HF_TKN_FILE" ]; then \
|
||||||
echo "Copying Hugging Face token from $HF_TKN_FILE"; \
|
echo "Copying Hugging Face token from $HF_TKN_FILE"; \
|
||||||
mkdir -p /root/.cache/huggingface && \
|
mkdir -p /root/.cache/huggingface && \
|
||||||
@@ -74,11 +75,9 @@ RUN if [ -n "$HF_TKN_FILE" ]; then \
|
|||||||
else \
|
else \
|
||||||
echo "No Hugging Face token file specified, skipping token setup"; \
|
echo "No Hugging Face token file specified, skipping token setup"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Expose port for the transcription server
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["whisperlivekit-server", "--host", "0.0.0.0"]
|
ENTRYPOINT ["whisperlivekit-server", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
# Default args
|
CMD ["--model", "medium"]
|
||||||
CMD ["--model", "tiny.en"]
|
|
||||||
|
|||||||
61
Dockerfile.cpu
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG EXTRAS
|
||||||
|
ARG HF_PRECACHE_DIR
|
||||||
|
ARG HF_TKN_FILE
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
python3-dev && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install CPU-only PyTorch
|
||||||
|
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install WhisperLiveKit directly, allowing for optional dependencies
|
||||||
|
RUN if [ -n "$EXTRAS" ]; then \
|
||||||
|
echo "Installing with extras: [$EXTRAS]"; \
|
||||||
|
pip install --no-cache-dir whisperlivekit[$EXTRAS]; \
|
||||||
|
else \
|
||||||
|
echo "Installing base package only"; \
|
||||||
|
pip install --no-cache-dir whisperlivekit; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enable in-container caching for Hugging Face models
|
||||||
|
VOLUME ["/root/.cache/huggingface/hub"]
|
||||||
|
|
||||||
|
# Conditionally copy a local pre-cache from the build context
|
||||||
|
RUN if [ -n "$HF_PRECACHE_DIR" ]; then \
|
||||||
|
echo "Copying Hugging Face cache from $HF_PRECACHE_DIR"; \
|
||||||
|
mkdir -p /root/.cache/huggingface/hub && \
|
||||||
|
cp -r $HF_PRECACHE_DIR/* /root/.cache/huggingface/hub; \
|
||||||
|
else \
|
||||||
|
echo "No local Hugging Face cache specified, skipping copy"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conditionally copy a Hugging Face token if provided
|
||||||
|
RUN if [ -n "$HF_TKN_FILE" ]; then \
|
||||||
|
echo "Copying Hugging Face token from $HF_TKN_FILE"; \
|
||||||
|
mkdir -p /root/.cache/huggingface && \
|
||||||
|
cp $HF_TKN_FILE /root/.cache/huggingface/token; \
|
||||||
|
else \
|
||||||
|
echo "No Hugging Face token file specified, skipping token setup"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Expose port for the transcription server
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["whisperlivekit-server", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
|
# Default args - you might want to use a smaller model for CPU
|
||||||
|
CMD ["--model", "tiny"]
|
||||||
226
LICENSE
@@ -1,52 +1,210 @@
|
|||||||
# License
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
## Main Software License
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
MIT License
|
1. Definitions.
|
||||||
|
|
||||||
Copyright (c) 2025 Quentin Fuxa.
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
the copyright owner that is granting the License.
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
copies or substantial portions of the Software.
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
exercising permissions granted by this License.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
## SimulStreaming Backend License
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
**When using the SimulStreaming backend (SimulWhisper), additional licensing terms apply:**
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
SimulStreaming (https://github.com/ufal/SimulStreaming) is dual-licensed:
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
### 🔹 Non-Commercial Use
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
You may use SimulStreaming under the **PolyForm Noncommercial License 1.0.0** if you obtain the code through the GitHub repository. This license is **free of charge** and comes with **no obligations** for non-commercial users.
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
### 🔸 Commercial Use
|
"Contribution" shall mean any work of authorship, including
|
||||||
Understanding who uses SimulStreaming commercially helps improve and prioritize development. Therefore, **registration is required** for those who acquire a commercial license.
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
Commercial licenses are planned to be **affordable** to SMEs and individuals. They are considering providing commercial licenses either for free or for a symbolic one-time fee, and may also provide additional support. You can share your preference via the [questionnaire](https://forms.cloud.microsoft.com/e/7tCxb4gJfB).
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
You can also leave your contact [there](https://forms.cloud.microsoft.com/e/7tCxb4gJfB) to be notified when commercial licenses become available.
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
**Contact for SimulStreaming licensing:**
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
[Dominik Macháček](https://ufal.mff.cuni.cz/dominik-machacek/), machacek@ufal.mff.cuni.cz
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2025 Quentin Fuxa
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Based on:
|
## Based on:
|
||||||
- **whisper_streaming** by ÚFAL – MIT License – https://github.com/ufal/whisper_streaming. The original work by ÚFAL. License: https://github.com/ufal/whisper_streaming/blob/main/LICENSE
|
- **SimulWhisper** by Speech and Audio Technology LAB of Tsinghua University – Apache-2.0 – https://github.com/ufal/SimulStreaming
|
||||||
- **silero-vad** by Snakers4 – MIT License – https://github.com/snakers4/silero-vad. The work by Snakers4 (silero-vad). License: https://github.com/snakers4/silero-vad/blob/f6b1294cb27590fb2452899df98fb234dfef1134/LICENSE
|
- **SimulStreaming** by ÚFAL – MIT License – https://github.com/ufal/SimulStreaming
|
||||||
- **Diart** by juanmc2005 – MIT License – https://github.com/juanmc2005/diart. The work in Diart by juanmc2005. License: https://github.com/juanmc2005/diart/blob/main/LICENSE
|
- **NeMo** by NVidia - Apache-2.0 - https://github.com/NVIDIA-NeMo/NeMo
|
||||||
- **SimulStreaming** by ÚFAL – Dual License (PolyForm Noncommercial License 1.0.0 / Commercial License) – https://github.com/ufal/SimulStreaming
|
- **whisper_streaming** by ÚFAL – MIT License – https://github.com/ufal/whisper_streaming.
|
||||||
|
- **silero-vad** by Snakers4 – MIT License – https://github.com/snakers4/silero-vad.
|
||||||
|
- **Diart** by juanmc2005 – MIT License – https://github.com/juanmc2005/diart.
|
||||||
|
|||||||
315
README.md
@@ -4,128 +4,96 @@
|
|||||||
<img src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/demo.png" alt="WhisperLiveKit Demo" width="730">
|
<img src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/demo.png" alt="WhisperLiveKit Demo" width="730">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center"><b>Real-time, Fully Local Speech-to-Text with Speaker Diarization</b></p>
|
<p align="center"><b>Real-time, Fully Local Speech-to-Text with Speaker Identification</b></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://pypi.org/project/whisperlivekit/"><img alt="PyPI Version" src="https://img.shields.io/pypi/v/whisperlivekit?color=g"></a>
|
<a href="https://pypi.org/project/whisperlivekit/"><img alt="PyPI Version" src="https://img.shields.io/pypi/v/whisperlivekit?color=g"></a>
|
||||||
<a href="https://pepy.tech/project/whisperlivekit"><img alt="PyPI Downloads" src="https://static.pepy.tech/personalized-badge/whisperlivekit?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=downloads"></a>
|
<a href="https://pepy.tech/project/whisperlivekit"><img alt="PyPI Downloads" src="https://static.pepy.tech/personalized-badge/whisperlivekit?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=installations"></a>
|
||||||
<a href="https://pypi.org/project/whisperlivekit/"><img alt="Python Versions" src="https://img.shields.io/badge/python-3.9--3.13-dark_green"></a>
|
<a href="https://pypi.org/project/whisperlivekit/"><img alt="Python Versions" src="https://img.shields.io/badge/python-3.9--3.15-dark_green"></a>
|
||||||
<a href="https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/License-MIT/Dual Licensed-dark_green"></a>
|
<a href="https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/License-Apache 2.0-dark_green"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Built on [WhisperStreaming](https://github.com/ufal/whisper_streaming) and [SimulStreaming](https://github.com/ufal/SimulStreaming), WhisperLiveKit provides real-time speech transcription in your browser, with a ready-to-use backend and a simple, customizable frontend. ✨
|
|
||||||
|
Real-time transcription directly to your browser, with a ready-to-use backend+server and a simple frontend.
|
||||||
|
|
||||||
|
#### Powered by Leading Research:
|
||||||
|
|
||||||
|
- Simul-[Whisper](https://github.com/backspacetg/simul_whisper)/[Streaming](https://github.com/ufal/SimulStreaming) (SOTA 2025) - Ultra-low latency transcription using [AlignAtt policy](https://arxiv.org/pdf/2305.11408)
|
||||||
|
- [NLLW](https://github.com/QuentinFuxa/NoLanguageLeftWaiting) (2025), based on [distilled](https://huggingface.co/entai2965/nllb-200-distilled-600M-ctranslate2) [NLLB](https://arxiv.org/abs/2207.04672) (2022, 2024) - Simulatenous translation from & to 200 languages.
|
||||||
|
- [WhisperStreaming](https://github.com/ufal/whisper_streaming) (SOTA 2023) - Low latency transcription using [LocalAgreement policy](https://www.isca-archive.org/interspeech_2020/liu20s_interspeech.pdf)
|
||||||
|
- [Streaming Sortformer](https://arxiv.org/abs/2507.18446) (SOTA 2025) - Advanced real-time speaker diarization
|
||||||
|
- [Diart](https://github.com/juanmc2005/diart) (SOTA 2021) - Real-time speaker diarization
|
||||||
|
- [Silero VAD](https://github.com/snakers4/silero-vad) (2024) - Enterprise-grade Voice Activity Detection
|
||||||
|
|
||||||
|
|
||||||
### Key Features
|
> **Why not just run a simple Whisper model on every audio batch?** Whisper is designed for complete utterances, not real-time chunks. Processing small segments loses context, cuts off words mid-syllable, and produces poor transcription. WhisperLiveKit uses state-of-the-art simultaneous speech research for intelligent buffering and incremental processing.
|
||||||
|
|
||||||
- **Real-time Transcription** - Locally (or on-prem) convert speech to text instantly as you speak
|
|
||||||
- **Speaker Diarization** - Identify different speakers in real-time using [Diart](https://github.com/juanmc2005/diart)
|
|
||||||
- **Multi-User Support** - Handle multiple users simultaneously with a single backend/server
|
|
||||||
- **Automatic Silence Chunking** – Automatically chunks when no audio is detected to limit buffer size
|
|
||||||
- **Confidence Validation** – Immediately validate high-confidence tokens for faster inference (WhisperStreaming only)
|
|
||||||
- **Buffering Preview** – Displays unvalidated transcription segments (not compatible with SimulStreaming yet)
|
|
||||||
- **Punctuation-Based Speaker Splitting [BETA]** - Align speaker changes with natural sentence boundaries for more readable transcripts
|
|
||||||
- **SimulStreaming Backend** - [Dual-licensed](https://github.com/ufal/SimulStreaming#-licence-and-contributions) - Ultra-low latency transcription using SOTA AlignAtt policy.
|
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
<img alt="Architecture" src="architecture.png" />
|
<img alt="Architecture" src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/architecture.png" />
|
||||||
|
|
||||||
|
*The backend supports multiple concurrent users. Voice Activity Detection reduces overhead when no voice is detected.*
|
||||||
|
|
||||||
## Quick Start
|
### Installation & Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install the package
|
|
||||||
pip install whisperlivekit
|
pip install whisperlivekit
|
||||||
|
|
||||||
# Start the transcription server
|
|
||||||
whisperlivekit-server --model tiny.en
|
|
||||||
|
|
||||||
# Open your browser at http://localhost:8000 to see the interface.
|
|
||||||
# Use -ssl-certfile public.crt --ssl-keyfile private.key parameters to use SSL
|
|
||||||
```
|
```
|
||||||
|
> You can also clone the repo and `pip install -e .` for the latest version.
|
||||||
|
|
||||||
That's it! Start speaking and watch your words appear on screen.
|
#### Quick Start
|
||||||
|
1. **Start the transcription server:**
|
||||||
|
```bash
|
||||||
|
wlk --model base --language en
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
2. **Open your browser** and navigate to `http://localhost:8000`. Start speaking and watch your words appear in real-time!
|
||||||
|
|
||||||
|
|
||||||
|
> - See [tokenizer.py](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/simul_whisper/whisper/tokenizer.py) for the list of all available languages.
|
||||||
|
> - For HTTPS requirements, see the **Parameters** section for SSL configuration options.
|
||||||
|
> - The CLI entry point is exposed as both `wlk` and `whisperlivekit-server`; they are equivalent.
|
||||||
|
|
||||||
|
#### Use it to capture audio from web pages.
|
||||||
|
|
||||||
|
Go to `chrome-extension` for instructions.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/chrome-extension/demo-extension.png" alt="WhisperLiveKit Demo" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### Optional Dependencies
|
||||||
|
|
||||||
|
| Optional | `pip install` |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Windows/Linux optimizations** | `faster-whisper` |
|
||||||
|
| **Apple Silicon optimizations** | `mlx-whisper` |
|
||||||
|
| **Translation** | `nllw` |
|
||||||
|
| **Speaker diarization** | `git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]` |
|
||||||
|
| OpenAI API | `openai` |
|
||||||
|
| *[Not recommanded]* Speaker diarization with Diart | `diart` |
|
||||||
|
|
||||||
|
See **Parameters & Configuration** below on how to use them.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
**Command-line Interface**: Start the transcription server with various options:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#Install from PyPI (Recommended)
|
# Large model and translate from french to danish
|
||||||
pip install whisperlivekit
|
wlk --model large-v3 --language fr --target-language da
|
||||||
|
|
||||||
#Install from Source
|
# Diarization and server listening on */80
|
||||||
git clone https://github.com/QuentinFuxa/WhisperLiveKit
|
wlk --host 0.0.0.0 --port 80 --model medium --diarization --language fr
|
||||||
cd WhisperLiveKit
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### FFmpeg Dependency
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt install ffmpeg
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
brew install ffmpeg
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
# Download from https://ffmpeg.org/download.html and add to PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Voice Activity Controller (prevents hallucinations)
|
|
||||||
pip install torch
|
|
||||||
|
|
||||||
# Sentence-based buffer trimming
|
|
||||||
pip install mosestokenizer wtpsplit
|
|
||||||
pip install tokenize_uk # If you work with Ukrainian text
|
|
||||||
|
|
||||||
# Speaker diarization
|
|
||||||
pip install diart
|
|
||||||
|
|
||||||
# Alternative Whisper backends (default is faster-whisper)
|
|
||||||
pip install whisperlivekit[whisper] # Original Whisper
|
|
||||||
pip install whisperlivekit[whisper-timestamped] # Improved timestamps
|
|
||||||
pip install whisperlivekit[mlx-whisper] # Apple Silicon optimization
|
|
||||||
pip install whisperlivekit[openai] # OpenAI API
|
|
||||||
pip install whisperlivekit[simulstreaming]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🎹 Pyannote Models Setup
|
|
||||||
|
|
||||||
For diarization, you need access to pyannote.audio models:
|
|
||||||
|
|
||||||
1. [Accept user conditions](https://huggingface.co/pyannote/segmentation) for the `pyannote/segmentation` model
|
|
||||||
2. [Accept user conditions](https://huggingface.co/pyannote/segmentation-3.0) for the `pyannote/segmentation-3.0` model
|
|
||||||
3. [Accept user conditions](https://huggingface.co/pyannote/embedding) for the `pyannote/embedding` model
|
|
||||||
4. Login with HuggingFace:
|
|
||||||
```bash
|
|
||||||
pip install huggingface_hub
|
|
||||||
huggingface-cli login
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💻 Usage Examples
|
|
||||||
|
|
||||||
### Command-line Interface
|
|
||||||
|
|
||||||
Start the transcription server with various options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Basic server with English model
|
|
||||||
whisperlivekit-server --model tiny.en
|
|
||||||
|
|
||||||
# Advanced configuration with diarization
|
|
||||||
whisperlivekit-server --host 0.0.0.0 --port 8000 --model medium --diarization --language auto
|
|
||||||
|
|
||||||
# SimulStreaming backend for ultra-low latency
|
|
||||||
whisperlivekit-server --backend simulstreaming --model large-v3 --frame-threshold 20
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Python API Integration (Backend)
|
**Python API Integration**: Check [basic_server](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/basic_server.py) for a more complete example of how to use the functions and classes.
|
||||||
Check [basic_server.py](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/basic_server.py) for a complete example.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from whisperlivekit import TranscriptionEngine, AudioProcessor, parse_args
|
from whisperlivekit import TranscriptionEngine, AudioProcessor, parse_args
|
||||||
@@ -140,14 +108,10 @@ transcription_engine = None
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global transcription_engine
|
global transcription_engine
|
||||||
transcription_engine = TranscriptionEngine(model="medium", diarization=True, lan="en")
|
transcription_engine = TranscriptionEngine(model="medium", diarization=True, lan="en")
|
||||||
# You can also load from command-line arguments using parse_args()
|
|
||||||
# args = parse_args()
|
|
||||||
# transcription_engine = TranscriptionEngine(**vars(args))
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
# Process WebSocket connections
|
|
||||||
async def handle_websocket_results(websocket: WebSocket, results_generator):
|
async def handle_websocket_results(websocket: WebSocket, results_generator):
|
||||||
async for response in results_generator:
|
async for response in results_generator:
|
||||||
await websocket.send_json(response)
|
await websocket.send_json(response)
|
||||||
@@ -167,44 +131,48 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
await audio_processor.process_audio(message)
|
await audio_processor.process_audio(message)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Implementation
|
**Frontend Implementation**: The package includes an HTML/JavaScript implementation [here](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/web/live_transcription.html). You can also import it using `from whisperlivekit import get_inline_ui_html` & `page = get_inline_ui_html()`
|
||||||
|
|
||||||
The package includes a simple HTML/JavaScript implementation that you can adapt for your project. You can find it [here](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/web/live_transcription.html), or load its content using `get_web_interface_html()` :
|
|
||||||
|
|
||||||
```python
|
## Parameters & Configuration
|
||||||
from whisperlivekit import get_web_interface_html
|
|
||||||
html_content = get_web_interface_html()
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ Configuration Reference
|
|
||||||
|
|
||||||
WhisperLiveKit offers extensive configuration options:
|
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
|
| `--model` | Whisper model size. List and recommandations [here](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/docs/available_models.md) | `small` |
|
||||||
|
| `--model-path` | Local .pt file/directory **or** Hugging Face repo ID containing the Whisper model. Overrides `--model`. Recommandations [here](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/docs/models_compatible_formats.md) | `None` |
|
||||||
|
| `--language` | List [here](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/whisper/tokenizer.py). If you use `auto`, the model attempts to detect the language automatically, but it tends to bias towards English. | `auto` |
|
||||||
|
| `--target-language` | If sets, translates using [NLLW](https://github.com/QuentinFuxa/NoLanguageLeftWaiting). [200 languages available](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/docs/supported_languages.md). If you want to translate to english, you can also use `--direct-english-translation`. The STT model will try to directly output the translation. | `None` |
|
||||||
|
| `--diarization` | Enable speaker identification | `False` |
|
||||||
|
| `--backend-policy` | Streaming strategy: `1`/`simulstreaming` uses AlignAtt SimulStreaming, `2`/`localagreement` uses the LocalAgreement policy | `simulstreaming` |
|
||||||
|
| `--backend` | Whisper implementation selector. `auto` picks MLX on macOS (if installed), otherwise Faster-Whisper, otherwise vanilla Whisper. You can also force `mlx-whisper`, `faster-whisper`, `whisper`, or `openai-api` (LocalAgreement only) | `auto` |
|
||||||
|
| `--no-vac` | Disable Voice Activity Controller | `False` |
|
||||||
|
| `--no-vad` | Disable Voice Activity Detection | `False` |
|
||||||
|
| `--warmup-file` | Audio file path for model warmup | `jfk.wav` |
|
||||||
| `--host` | Server host address | `localhost` |
|
| `--host` | Server host address | `localhost` |
|
||||||
| `--port` | Server port | `8000` |
|
| `--port` | Server port | `8000` |
|
||||||
| `--model` | Whisper model size. Caution : '.en' models do not work with Simulstreaming | `tiny` |
|
|
||||||
| `--language` | Source language code or `auto` | `en` |
|
|
||||||
| `--task` | `transcribe` or `translate` | `transcribe` |
|
|
||||||
| `--backend` | Processing backend | `faster-whisper` |
|
|
||||||
| `--diarization` | Enable speaker identification | `False` |
|
|
||||||
| `--punctuation-split` | Use punctuation to improve speaker boundaries | `True` |
|
|
||||||
| `--confidence-validation` | Use confidence scores for faster validation | `False` |
|
|
||||||
| `--min-chunk-size` | Minimum audio chunk size (seconds) | `1.0` |
|
|
||||||
| `--vac` | Use Voice Activity Controller | `False` |
|
|
||||||
| `--no-vad` | Disable Voice Activity Detection | `False` |
|
|
||||||
| `--buffer_trimming` | Buffer trimming strategy (`sentence` or `segment`) | `segment` |
|
|
||||||
| `--warmup-file` | Audio file path for model warmup | `jfk.wav` |
|
|
||||||
| `--ssl-certfile` | Path to the SSL certificate file (for HTTPS support) | `None` |
|
| `--ssl-certfile` | Path to the SSL certificate file (for HTTPS support) | `None` |
|
||||||
| `--ssl-keyfile` | Path to the SSL private key file (for HTTPS support) | `None` |
|
| `--ssl-keyfile` | Path to the SSL private key file (for HTTPS support) | `None` |
|
||||||
| `--segmentation-model` | Hugging Face model ID for pyannote.audio segmentation model. [Available models](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `pyannote/segmentation-3.0` |
|
| `--forwarded-allow-ips` | Ip or Ips allowed to reverse proxy the whisperlivekit-server. Supported types are IP Addresses (e.g. 127.0.0.1), IP Networks (e.g. 10.100.0.0/16), or Literals (e.g. /path/to/socket.sock) | `None` |
|
||||||
| `--embedding-model` | Hugging Face model ID for pyannote.audio embedding model. [Available models](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `speechbrain/spkrec-ecapa-voxceleb` |
|
| `--pcm-input` | raw PCM (s16le) data is expected as input and FFmpeg will be bypassed. Frontend will use AudioWorklet instead of MediaRecorder | `False` |
|
||||||
|
|
||||||
**SimulStreaming-specific Options:**
|
| Translation options | Description | Default |
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
|
| `--nllb-backend` | `transformers` or `ctranslate2` | `ctranslate2` |
|
||||||
|
| `--nllb-size` | `600M` or `1.3B` | `600M` |
|
||||||
|
|
||||||
|
| Diarization options | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--diarization-backend` | `diart` or `sortformer` | `sortformer` |
|
||||||
|
| `--disable-punctuation-split` | Disable punctuation based splits. See #214 | `False` |
|
||||||
|
| `--segmentation-model` | Hugging Face model ID for Diart segmentation model. [Available models](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `pyannote/segmentation-3.0` |
|
||||||
|
| `--embedding-model` | Hugging Face model ID for Diart embedding model. [Available models](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `speechbrain/spkrec-ecapa-voxceleb` |
|
||||||
|
|
||||||
|
| SimulStreaming backend options | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--disable-fast-encoder` | Disable Faster Whisper or MLX Whisper backends for the encoder (if installed). Inference can be slower but helpful when GPU memory is limited | `False` |
|
||||||
|
| `--custom-alignment-heads` | Use your own alignment heads, useful when `--model-dir` is used. Use `scripts/determine_alignment_heads.py` to extract them. <img src="scripts/alignment_heads.png" alt="WhisperLiveKit Demo" width="300">
|
||||||
|
| `None` |
|
||||||
| `--frame-threshold` | AlignAtt frame threshold (lower = faster, higher = more accurate) | `25` |
|
| `--frame-threshold` | AlignAtt frame threshold (lower = faster, higher = more accurate) | `25` |
|
||||||
| `--beams` | Number of beams for beam search (1 = greedy decoding) | `1` |
|
| `--beams` | Number of beams for beam search (1 = greedy decoding) | `1` |
|
||||||
| `--decoder` | Force decoder type (`beam` or `greedy`) | `auto` |
|
| `--decoder` | Force decoder type (`beam` or `greedy`) | `auto` |
|
||||||
@@ -215,70 +183,82 @@ WhisperLiveKit offers extensive configuration options:
|
|||||||
| `--init-prompt` | Initial prompt for the model | `None` |
|
| `--init-prompt` | Initial prompt for the model | `None` |
|
||||||
| `--static-init-prompt` | Static prompt that doesn't scroll | `None` |
|
| `--static-init-prompt` | Static prompt that doesn't scroll | `None` |
|
||||||
| `--max-context-tokens` | Maximum context tokens | `None` |
|
| `--max-context-tokens` | Maximum context tokens | `None` |
|
||||||
| `--model-path` | Direct path to .pt model file. Download it if not found | `./base.pt` |
|
| `--preload-model-count` | Optional. Number of models to preload in memory to speed up loading (set up to the expected number of concurrent users) | `1` |
|
||||||
|
|
||||||
## 🔧 How It Works
|
|
||||||
|
|
||||||
1. **Audio Capture**: Browser's MediaRecorder API captures audio in webm/opus format
|
|
||||||
2. **Streaming**: Audio chunks are sent to the server via WebSocket
|
|
||||||
3. **Processing**: Server decodes audio with FFmpeg and streams into the model for transcription
|
|
||||||
4. **Real-time Output**: Partial transcriptions appear immediately in light gray (the 'aperçu') and finalized text appears in normal color
|
|
||||||
|
|
||||||
## 🚀 Deployment Guide
|
| WhisperStreaming backend options | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--confidence-validation` | Use confidence scores for faster validation | `False` |
|
||||||
|
| `--buffer_trimming` | Buffer trimming strategy (`sentence` or `segment`) | `segment` |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
> For diarization using Diart, you need to accept user conditions [here](https://huggingface.co/pyannote/segmentation) for the `pyannote/segmentation` model, [here](https://huggingface.co/pyannote/segmentation-3.0) for the `pyannote/segmentation-3.0` model and [here](https://huggingface.co/pyannote/embedding) for the `pyannote/embedding` model. **Then**, login to HuggingFace: `huggingface-cli login`
|
||||||
|
|
||||||
|
### 🚀 Deployment Guide
|
||||||
|
|
||||||
To deploy WhisperLiveKit in production:
|
To deploy WhisperLiveKit in production:
|
||||||
|
|
||||||
1. **Server Setup** (Backend):
|
1. **Server Setup**: Install production ASGI server & launch with multiple workers
|
||||||
```bash
|
```bash
|
||||||
# Install production ASGI server
|
|
||||||
pip install uvicorn gunicorn
|
pip install uvicorn gunicorn
|
||||||
|
|
||||||
# Launch with multiple workers
|
|
||||||
gunicorn -k uvicorn.workers.UvicornWorker -w 4 your_app:app
|
gunicorn -k uvicorn.workers.UvicornWorker -w 4 your_app:app
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Frontend Integration**:
|
2. **Frontend**: Host your customized version of the `html` example & ensure WebSocket connection points correctly
|
||||||
- Host your customized version of the example HTML/JS in your web application
|
|
||||||
- Ensure WebSocket connection points to your server's address
|
|
||||||
|
|
||||||
3. **Nginx Configuration** (recommended for production):
|
3. **Nginx Configuration** (recommended for production):
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name your-domain.com;
|
server_name your-domain.com;
|
||||||
|
location / {
|
||||||
location / {
|
proxy_pass http://localhost:8000;
|
||||||
proxy_pass http://localhost:8000;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Host $host;
|
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **HTTPS Support**: For secure deployments, use "wss://" instead of "ws://" in WebSocket URL
|
4. **HTTPS Support**: For secure deployments, use "wss://" instead of "ws://" in WebSocket URL
|
||||||
|
|
||||||
### 🐋 Docker
|
## 🐋 Docker
|
||||||
|
|
||||||
A basic Dockerfile is provided which allows re-use of Python package installation options. ⚠️ For **large** models, ensure that your **docker runtime** has enough **memory** available. See below usage examples:
|
Deploy the application easily using Docker with GPU or CPU support.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker installed on your system
|
||||||
|
- For GPU support: NVIDIA Docker runtime installed
|
||||||
|
|
||||||
#### All defaults
|
### Quick Start
|
||||||
- Create a reusable image with only the basics and then run as a named container:
|
|
||||||
```bash
|
**With GPU acceleration (recommended):**
|
||||||
docker build -t whisperlivekit-defaults .
|
```bash
|
||||||
docker create --gpus all --name whisperlivekit -p 8000:8000 whisperlivekit-defaults
|
docker build -t wlk .
|
||||||
docker start -i whisperlivekit
|
docker run --gpus all -p 8000:8000 --name wlk wlk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**CPU only:**
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.cpu -t wlk .
|
||||||
|
docker run -p 8000:8000 --name wlk wlk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Usage
|
||||||
|
|
||||||
|
**Custom configuration:**
|
||||||
|
```bash
|
||||||
|
# Example with custom model and language
|
||||||
|
docker run --gpus all -p 8000:8000 --name wlk wlk --model large-v3 --language fr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Requirements
|
||||||
|
- **Large models**: Ensure your Docker runtime has sufficient memory allocated
|
||||||
|
|
||||||
> **Note**: If you're running on a system without NVIDIA GPU support (such as Mac with Apple Silicon or any system without CUDA capabilities), you need to **remove the `--gpus all` flag** from the `docker create` command. Without GPU acceleration, transcription will use CPU only, which may be significantly slower. Consider using small models for better performance on CPU-only systems.
|
|
||||||
|
|
||||||
#### Customization
|
#### Customization
|
||||||
- Customize the container options:
|
|
||||||
```bash
|
|
||||||
docker build -t whisperlivekit-defaults .
|
|
||||||
docker create --gpus all --name whisperlivekit-base -p 8000:8000 whisperlivekit-defaults --model base
|
|
||||||
docker start -i whisperlivekit-base
|
|
||||||
```
|
|
||||||
|
|
||||||
- `--build-arg` Options:
|
- `--build-arg` Options:
|
||||||
- `EXTRAS="whisper-timestamped"` - Add extras to the image's installation (no spaces). Remember to set necessary container options!
|
- `EXTRAS="whisper-timestamped"` - Add extras to the image's installation (no spaces). Remember to set necessary container options!
|
||||||
@@ -287,10 +267,3 @@ A basic Dockerfile is provided which allows re-use of Python package installatio
|
|||||||
|
|
||||||
## 🔮 Use Cases
|
## 🔮 Use Cases
|
||||||
Capture discussions in real-time for meeting transcription, help hearing-impaired users follow conversations through accessibility tools, transcribe podcasts or videos automatically for content creation, transcribe support calls with speaker identification for customer service...
|
Capture discussions in real-time for meeting transcription, help hearing-impaired users follow conversations through accessibility tools, transcribe podcasts or videos automatically for content creation, transcribe support calls with speaker identification for customer service...
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
We extend our gratitude to the original authors of:
|
|
||||||
|
|
||||||
| [Whisper Streaming](https://github.com/ufal/whisper_streaming) | [SimulStreaming](https://github.com/ufal/SimulStreaming) | [Diart](https://github.com/juanmc2005/diart) | [OpenAI Whisper](https://github.com/openai/whisper) |
|
|
||||||
| -------- | ------- | -------- | ------- |
|
|
||||||
|
|||||||
258
ReadmeJP.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<h1 align="center">WhisperLiveKit</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/demo.png" alt="WhisperLiveKit Demo" width="730">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><b>話者識別機能付き、リアルタイム、完全ローカルな音声テキスト変換</b></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pypi.org/project/whisperlivekit/"><img alt="PyPI Version" src="https://img.shields.io/pypi/v/whisperlivekit?color=g"></a>
|
||||||
|
<a href="https://pepy.tech/project/whisperlivekit"><img alt="PyPI Downloads" src="https://static.pepy.tech/personalized-badge/whisperlivekit?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=installations"></a>
|
||||||
|
<a href="https://pypi.org/project/whisperlivekit/"><img alt="Python Versions" src="https://img.shields.io/badge/python-3.9--3.13-dark_green"></a>
|
||||||
|
<a href="https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/License-MIT/Dual Licensed-dark_green"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
すぐに使えるバックエンド+サーバーとシンプルなフロントエンドで、リアルタイムの音声文字起こしをブラウザに直接提供します。✨
|
||||||
|
|
||||||
|
#### 主要な研究による技術:
|
||||||
|
|
||||||
|
- [SimulStreaming](https://github.com/ufal/SimulStreaming) (SOTA 2025) - AlignAttポリシーによる超低遅延文字起こし
|
||||||
|
- [WhisperStreaming](https://github.com/ufal/whisper_streaming) (SOTA 2023) - LocalAgreementポリシーによる低遅延文字起こし
|
||||||
|
- [Streaming Sortformer](https://arxiv.org/abs/2507.18446) (SOTA 2025) - 高度なリアルタイム話者ダイアライゼーション
|
||||||
|
- [Diart](https://github.com/juanmc2005/diart) (SOTA 2021) - リアルタイム話者ダイアライゼーション
|
||||||
|
- [Silero VAD](https://github.com/snakers4/silero-vad) (2024) - エンタープライズグレードの音声区間検出
|
||||||
|
|
||||||
|
> **なぜ各音声バッチで単純なWhisperモデルを実行しないのか?** Whisperは完全な発話向けに設計されており、リアルタイムのチャンク向けではありません。小さなセグメントを処理するとコンテキストが失われ、単語が音節の途中で途切れ、質の悪い文字起こしになります。WhisperLiveKitは、インテリジェントなバッファリングとインクリメンタルな処理のために、最先端の同時音声研究を利用しています。
|
||||||
|
|
||||||
|
### アーキテクチャ
|
||||||
|
|
||||||
|
<img alt="Architecture" src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/architecture.png" />
|
||||||
|
|
||||||
|
*バックエンドは複数の同時ユーザーをサポートします。音声が検出されない場合、音声区間検出がオーバーヘッドを削減します。*
|
||||||
|
|
||||||
|
### インストールとクイックスタート
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install whisperlivekit
|
||||||
|
```
|
||||||
|
|
||||||
|
> **FFmpegが必要です** WhisperLiveKitを使用する前にインストールする必要があります。
|
||||||
|
>
|
||||||
|
> | OS | インストール方法 |
|
||||||
|
> |-----------|-------------|
|
||||||
|
> | Ubuntu/Debian | `sudo apt install ffmpeg` |
|
||||||
|
> | MacOS | `brew install ffmpeg` |
|
||||||
|
> | Windows | https://ffmpeg.org/download.html から.exeをダウンロードし、PATHに追加 |
|
||||||
|
|
||||||
|
#### クイックスタート
|
||||||
|
1. **文字起こしサーバーを起動します:**
|
||||||
|
```bash
|
||||||
|
whisperlivekit-server --model base --language en
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **ブラウザを開き** `http://localhost:8000` にアクセスします。話し始めると、あなたの言葉がリアルタイムで表示されます!
|
||||||
|
|
||||||
|
|
||||||
|
> - 利用可能なすべての言語のリストについては、[tokenizer.py](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/simul_whisper/whisper/tokenizer.py) を参照してください。
|
||||||
|
> - HTTPSの要件については、**パラメータ**セクションのSSL設定オプションを参照してください。
|
||||||
|
|
||||||
|
#### オプションの依存関係
|
||||||
|
|
||||||
|
| オプション | `pip install` |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Sortformerによる話者ダイアライゼーション** | `git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]` |
|
||||||
|
| Diartによる話者ダイアライゼーション | `diart` |
|
||||||
|
| オリジナルのWhisperバックエンド | `whisper` |
|
||||||
|
| タイムスタンプ改善バックエンド | `whisper-timestamped` |
|
||||||
|
| Apple Silicon最適化バックエンド | `mlx-whisper` |
|
||||||
|
| OpenAI APIバックエンド | `openai` |
|
||||||
|
|
||||||
|
それらの使用方法については、以下の**パラメータと設定**を参照してください。
|
||||||
|
|
||||||
|
### 使用例
|
||||||
|
|
||||||
|
**コマンドラインインターフェース**: 様々なオプションで文字起こしサーバーを起動します:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# デフォルト(small)より良いモデルを使用
|
||||||
|
whisperlivekit-server --model large-v3
|
||||||
|
|
||||||
|
# ダイアライゼーションと言語を指定した高度な設定
|
||||||
|
whisperlivekit-server --host 0.0.0.0 --port 8000 --model medium --diarization --language fr
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python API連携**: 関数やクラスの使用方法のより完全な例については、[basic_server](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/basic_server.py) を確認してください。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from whisperlivekit import TranscriptionEngine, AudioProcessor, parse_args
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
transcription_engine = None
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global transcription_engine
|
||||||
|
transcription_engine = TranscriptionEngine(model="medium", diarization=True, lan="en")
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
async def handle_websocket_results(websocket: WebSocket, results_generator):
|
||||||
|
async for response in results_generator:
|
||||||
|
await websocket.send_json(response)
|
||||||
|
await websocket.send_json({"type": "ready_to_stop"})
|
||||||
|
|
||||||
|
@app.websocket("/asr")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
global transcription_engine
|
||||||
|
|
||||||
|
# 接続ごとに新しいAudioProcessorを作成し、共有エンジンを渡す
|
||||||
|
audio_processor = AudioProcessor(transcription_engine=transcription_engine)
|
||||||
|
results_generator = await audio_processor.create_tasks()
|
||||||
|
results_task = asyncio.create_task(handle_websocket_results(websocket, results_generator))
|
||||||
|
await websocket.accept()
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_bytes()
|
||||||
|
await audio_processor.process_audio(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
**フロントエンド実装**: パッケージにはHTML/JavaScript実装が[ここ](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/web/live_transcription.html)に含まれています。`from whisperlivekit import get_web_interface_html` & `page = get_web_interface_html()` を使ってインポートすることもできます。
|
||||||
|
|
||||||
|
|
||||||
|
## パラメータと設定
|
||||||
|
|
||||||
|
重要なパラメータのリストを変更できます。しかし、何を*変更すべき*でしょうか?
|
||||||
|
- `--model` サイズ。リストと推奨事項は[こちら](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/available_models.md)
|
||||||
|
- `--language`。リストは[こちら](https://github.com/QuentinFuxa/WhisperLiveKit/blob/main/whisperlivekit/simul_whisper/whisper/tokenizer.py)。`auto`を使用すると、モデルは自動的に言語を検出しようとしますが、英語に偏る傾向があります。
|
||||||
|
- `--backend`? `simulstreaming`が正しく動作しない場合や、デュアルライセンス要件を避けたい場合は`--backend faster-whisper`に切り替えることができます。
|
||||||
|
- `--warmup-file`、もしあれば
|
||||||
|
- `--host`, `--port`, `--ssl-certfile`, `--ssl-keyfile`、サーバーをセットアップする場合
|
||||||
|
- `--diarization`、使用したい場合。
|
||||||
|
|
||||||
|
残りは推奨しません。しかし、以下があなたのオプションです。
|
||||||
|
|
||||||
|
| パラメータ | 説明 | デフォルト |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--model` | Whisperモデルのサイズ。 | `small` |
|
||||||
|
| `--language` | ソース言語コードまたは`auto` | `auto` |
|
||||||
|
| `--task` | `transcribe`または`translate` | `transcribe` |
|
||||||
|
| `--backend` | 処理バックエンド | `simulstreaming` |
|
||||||
|
| `--min-chunk-size` | 最小音声チャンクサイズ(秒) | `1.0` |
|
||||||
|
| `--no-vac` | 音声アクティビティコントローラーを無効化 | `False` |
|
||||||
|
| `--no-vad` | 音声区間検出を無効化 | `False` |
|
||||||
|
| `--warmup-file` | モデルのウォームアップ用音声ファイルパス | `jfk.wav` |
|
||||||
|
| `--host` | サーバーホストアドレス | `localhost` |
|
||||||
|
| `--port` | サーバーポート | `8000` |
|
||||||
|
| `--ssl-certfile` | SSL証明書ファイルへのパス(HTTPSサポート用) | `None` |
|
||||||
|
| `--ssl-keyfile` | SSL秘密鍵ファイルへのパス(HTTPSサポート用) | `None` |
|
||||||
|
|
||||||
|
|
||||||
|
| WhisperStreamingバックエンドオプション | 説明 | デフォルト |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--confidence-validation` | 高速な検証のために信頼スコアを使用 | `False` |
|
||||||
|
| `--buffer_trimming` | バッファトリミング戦略(`sentence`または`segment`) | `segment` |
|
||||||
|
|
||||||
|
|
||||||
|
| SimulStreamingバックエンドオプション | 説明 | デフォルト |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--frame-threshold` | AlignAttフレームしきい値(低いほど速く、高いほど正確) | `25` |
|
||||||
|
| `--beams` | ビームサーチのビーム数(1 = 貪欲デコーディング) | `1` |
|
||||||
|
| `--decoder` | デコーダタイプを強制(`beam`または`greedy`) | `auto` |
|
||||||
|
| `--audio-max-len` | 最大音声バッファ長(秒) | `30.0` |
|
||||||
|
| `--audio-min-len` | 処理する最小音声長(秒) | `0.0` |
|
||||||
|
| `--cif-ckpt-path` | 単語境界検出用CIFモデルへのパス | `None` |
|
||||||
|
| `--never-fire` | 未完了の単語を決して切り捨てない | `False` |
|
||||||
|
| `--init-prompt` | モデルの初期プロンプト | `None` |
|
||||||
|
| `--static-init-prompt` | スクロールしない静的プロンプト | `None` |
|
||||||
|
| `--max-context-tokens` | 最大コンテキストトークン数 | `None` |
|
||||||
|
| `--model-path` | .ptモデルファイルへの直接パス。見つからない場合はダウンロード | `./base.pt` |
|
||||||
|
| `--preloaded-model-count` | オプション。メモリにプリロードするモデルの数(予想される同時ユーザー数まで設定) | `1` |
|
||||||
|
|
||||||
|
| ダイアライゼーションオプション | 説明 | デフォルト |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `--diarization` | 話者識別を有効化 | `False` |
|
||||||
|
| `--diarization-backend` | `diart`または`sortformer` | `sortformer` |
|
||||||
|
| `--segmentation-model` | DiartセグメンテーションモデルのHugging FaceモデルID。[利用可能なモデル](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `pyannote/segmentation-3.0` |
|
||||||
|
| `--embedding-model` | Diart埋め込みモデルのHugging FaceモデルID。[利用可能なモデル](https://github.com/juanmc2005/diart/tree/main?tab=readme-ov-file#pre-trained-models) | `speechbrain/spkrec-ecapa-voxceleb` |
|
||||||
|
|
||||||
|
|
||||||
|
> Diartを使用したダイアライゼーションには、pyannote.audioモデルへのアクセスが必要です:
|
||||||
|
> 1. `pyannote/segmentation`モデルの[ユーザー条件に同意](https://huggingface.co/pyannote/segmentation)
|
||||||
|
> 2. `pyannote/segmentation-3.0`モデルの[ユーザー条件に同意](https://huggingface.co/pyannote/segmentation-3.0)
|
||||||
|
> 3. `pyannote/embedding`モデルの[ユーザー条件に同意](https://huggingface.co/pyannote/embedding)
|
||||||
|
>4. HuggingFaceでログイン: `huggingface-cli login`
|
||||||
|
|
||||||
|
### 🚀 デプロイガイド
|
||||||
|
|
||||||
|
WhisperLiveKitを本番環境にデプロイするには:
|
||||||
|
|
||||||
|
1. **サーバーセットアップ**: 本番用ASGIサーバーをインストールし、複数のワーカーで起動します
|
||||||
|
```bash
|
||||||
|
pip install uvicorn gunicorn
|
||||||
|
gunicorn -k uvicorn.workers.UvicornWorker -w 4 your_app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **フロントエンド**: カスタマイズした`html`のバージョンをホストし、WebSocket接続が正しくポイントするようにします
|
||||||
|
|
||||||
|
3. **Nginx設定** (本番環境で推奨):
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **HTTPSサポート**: 安全なデプロイメントのために、WebSocket URLで "ws://" の代わりに "wss://" を使用します
|
||||||
|
|
||||||
|
## 🐋 Docker
|
||||||
|
|
||||||
|
GPUまたはCPUサポート付きでDockerを使用してアプリケーションを簡単にデプロイします。
|
||||||
|
|
||||||
|
### 前提条件
|
||||||
|
- Dockerがシステムにインストールされていること
|
||||||
|
- GPUサポートの場合: NVIDIA Dockerランタイムがインストールされていること
|
||||||
|
|
||||||
|
### クイックスタート
|
||||||
|
|
||||||
|
**GPUアクセラレーション付き (推奨):**
|
||||||
|
```bash
|
||||||
|
docker build -t wlk .
|
||||||
|
docker run --gpus all -p 8000:8000 --name wlk wlk
|
||||||
|
```
|
||||||
|
|
||||||
|
**CPUのみ:**
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.cpu -t wlk .
|
||||||
|
docker run -p 8000:8000 --name wlk wlk
|
||||||
|
```
|
||||||
|
|
||||||
|
### 高度な使用法
|
||||||
|
|
||||||
|
**カスタム設定:**
|
||||||
|
```bash
|
||||||
|
# カスタムモデルと言語の例
|
||||||
|
docker run --gpus all -p 8000:8000 --name wlk wlk --model large-v3 --language fr
|
||||||
|
```
|
||||||
|
|
||||||
|
### メモリ要件
|
||||||
|
- **大規模モデル**: Dockerランタイムに十分なメモリが割り当てられていることを確認してください
|
||||||
|
|
||||||
|
|
||||||
|
#### カスタマイズ
|
||||||
|
|
||||||
|
- `--build-arg` オプション:
|
||||||
|
- `EXTRAS="whisper-timestamped"` - イメージのインストールにエクストラを追加します(スペースなし)。必要なコンテナオプションを設定することを忘れないでください!
|
||||||
|
- `HF_PRECACHE_DIR="./.cache/"` - 初回起動を高速化するためにモデルキャッシュをプリロードします
|
||||||
|
- `HF_TKN_FILE="./token"` - ゲート付きモデルをダウンロードするためにHugging Face Hubアクセストークンを追加します
|
||||||
|
|
||||||
|
## 🔮 ユースケース
|
||||||
|
会議の文字起こしのためにリアルタイムで議論をキャプチャする、聴覚障害のあるユーザーがアクセシビリティツールを通じて会話を追うのを助ける、コンテンツ作成のためにポッドキャストやビデオを自動的に文字起こしする、カスタマーサービスのために話者識別付きでサポートコールを文字起こしする...
|
||||||
BIN
architecture.png
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 406 KiB |
19
chrome-extension/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## WhisperLiveKit Chrome Extension v0.1.1
|
||||||
|
Capture the audio of your current tab, transcribe diarize and translate it using WhisperliveKit, in Chrome and other Chromium-based browsers.
|
||||||
|
|
||||||
|
> Currently, only the tab audio is captured; your microphone audio is not recorded.
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/QuentinFuxa/WhisperLiveKit/refs/heads/main/chrome-extension/demo-extension.png" alt="WhisperLiveKit Demo" width="730">
|
||||||
|
|
||||||
|
## Running this extension
|
||||||
|
1. Run `python sync_extension.py` to copy frontend files to the `chrome-extension` directory.
|
||||||
|
2. Load the `chrome-extension` directory in Chrome as an unpacked extension.
|
||||||
|
|
||||||
|
|
||||||
|
## Devs:
|
||||||
|
- Impossible to capture audio from tabs if extension is a pannel, unfortunately:
|
||||||
|
- https://issues.chromium.org/issues/40926394
|
||||||
|
- https://groups.google.com/a/chromium.org/g/chromium-extensions/c/DET2SXCFnDg
|
||||||
|
- https://issues.chromium.org/issues/40916430
|
||||||
|
|
||||||
|
- To capture microphone in an extension, there are tricks: https://github.com/justinmann/sidepanel-audio-issue , https://medium.com/@lynchee.owo/how-to-enable-microphone-access-in-chrome-extensions-by-code-924295170080 (comments)
|
||||||
9
chrome-extension/background.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
|
if (details.reason.search(/install/g) === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chrome.tabs.create({
|
||||||
|
url: chrome.runtime.getURL("welcome.html"),
|
||||||
|
active: true
|
||||||
|
})
|
||||||
|
})
|
||||||
BIN
chrome-extension/demo-extension.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
chrome-extension/icons/icon128.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
chrome-extension/icons/icon16.png
Normal file
|
After Width: | Height: | Size: 376 B |
BIN
chrome-extension/icons/icon32.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
chrome-extension/icons/icon48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
23
chrome-extension/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "WhisperLiveKit Tab Capture",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Capture and transcribe audio from browser tabs using WhisperLiveKit.",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "WhisperLiveKit Tab Capture",
|
||||||
|
"default_popup": "live_transcription.html"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"scripting",
|
||||||
|
"tabCapture",
|
||||||
|
"offscreen",
|
||||||
|
"activeTab",
|
||||||
|
"storage"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
chrome-extension/requestPermissions.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Request Permissions</title>
|
||||||
|
<script src="requestPermissions.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
This page exists to workaround an issue with Chrome that blocks permission
|
||||||
|
requests from chrome extensions
|
||||||
|
<button id="requestMicrophone">Request Microphone</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
chrome-extension/requestPermissions.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Requests user permission for microphone access.
|
||||||
|
* @returns {Promise<void>} A Promise that resolves when permission is granted or rejects with an error.
|
||||||
|
*/
|
||||||
|
async function getUserPermission() {
|
||||||
|
console.log("Getting user permission for microphone access...");
|
||||||
|
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const micPermission = await navigator.permissions.query({
|
||||||
|
name: "microphone",
|
||||||
|
});
|
||||||
|
if (micPermission.state == "granted") {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the function to request microphone permission
|
||||||
|
getUserPermission();
|
||||||
29
chrome-extension/sidepanel.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
console.log("sidepanel.js");
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const micPermission = await navigator.permissions.query({
|
||||||
|
name: "microphone",
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById(
|
||||||
|
"audioPermission"
|
||||||
|
).innerText = `MICROPHONE: ${micPermission.state}`;
|
||||||
|
|
||||||
|
if (micPermission.state !== "granted") {
|
||||||
|
chrome.tabs.create({ url: "requestPermissions.html" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
const micPermission = await navigator.permissions.query({
|
||||||
|
name: "microphone",
|
||||||
|
});
|
||||||
|
if (micPermission.state === "granted") {
|
||||||
|
document.getElementById(
|
||||||
|
"audioPermission"
|
||||||
|
).innerText = `MICROPHONE: ${micPermission.state}`;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
BIN
demo.png
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 985 KiB |
264
docs/API.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# WhisperLiveKit WebSocket API Documentation
|
||||||
|
|
||||||
|
> !! **Note**: The new API structure described in this document is currently under deployment.
|
||||||
|
This documentation is intended for devs who want to build custom frontends.
|
||||||
|
|
||||||
|
WLK provides real-time speech transcription, speaker diarization, and translation through a WebSocket API. The server sends incremental updates as audio is processed, allowing clients to display live transcription results with minimal latency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy API (Current)
|
||||||
|
|
||||||
|
### Message Structure
|
||||||
|
|
||||||
|
The current API sends complete state snapshots on each update (several time per second)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"type": str,
|
||||||
|
"status": str,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"speaker": int,
|
||||||
|
"text": str,
|
||||||
|
"start": float,
|
||||||
|
"end": float,
|
||||||
|
"translation": str | null,
|
||||||
|
"detected_language": str
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buffer_transcription": str,
|
||||||
|
"buffer_diarization": str,
|
||||||
|
"remaining_time_transcription": float,
|
||||||
|
"remaining_time_diarization": float
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New API (Under Development)
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
Principles:
|
||||||
|
|
||||||
|
- **Incremental Updates**: Only updates and new segments are sent
|
||||||
|
- **Ephemeral Buffers**: Temporary, unvalidated data displayed in real-time but overwritten on next update, at speaker level
|
||||||
|
|
||||||
|
|
||||||
|
## Message Format
|
||||||
|
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"type": "transcript_update",
|
||||||
|
"status": "active_transcription" | "no_audio_detected",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": number,
|
||||||
|
"speaker": number,
|
||||||
|
"text": string,
|
||||||
|
"start_speaker": float,
|
||||||
|
"start": float,
|
||||||
|
"end": float,
|
||||||
|
"language": string | null,
|
||||||
|
"translation": string,
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"text": string,
|
||||||
|
"start": float,
|
||||||
|
"end": float,
|
||||||
|
"validated": {
|
||||||
|
"text": boolean,
|
||||||
|
"speaker": boolean,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buffer": {
|
||||||
|
"transcription": string,
|
||||||
|
"diarization": string,
|
||||||
|
"translation": string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"remaining_time_transcription": float,
|
||||||
|
"remaining_time_diarization": float
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Message Types
|
||||||
|
|
||||||
|
#### Config Message (sent on connection)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "config",
|
||||||
|
"useAudioWorklet": true / false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ready to Stop Message (sent after processing complete)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ready_to_stop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Descriptions
|
||||||
|
|
||||||
|
### Segment Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | `number` | Unique identifier for this segment. Used by clients to update specific segments efficiently. |
|
||||||
|
| `speaker` | `number` | Speaker ID (1, 2, 3...). Special value `-2` indicates silence. |
|
||||||
|
| `text` | `string` | Validated transcription text for this update. Should be **appended** to the segment's text on the client side. |
|
||||||
|
| `start_speaker` | `float` | Timestamp (seconds) when this speaker segment began. |
|
||||||
|
| `start` | `float` | Timestamp (seconds) of the first word in this update. |
|
||||||
|
| `end` | `float` | Timestamp (seconds) of the last word in this update. |
|
||||||
|
| `language` | `string \| null` | ISO language code (e.g., "en", "fr"). `null` until language is detected. |
|
||||||
|
| `translation` | `string` | Validated translation text for this update. Should be **appended** to the segment's translation on the client side. |
|
||||||
|
| `words` | `Array` | Array of word-level objects with timing and validation information. |
|
||||||
|
| `buffer` | `Object` | Per-segment temporary buffers, see below |
|
||||||
|
|
||||||
|
### Word Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `text` | `string` | The word text. |
|
||||||
|
| `start` | `number` | Start timestamp (seconds) of this word. |
|
||||||
|
| `end` | `number` | End timestamp (seconds) of this word. |
|
||||||
|
| `validated.text` | `boolean` | Whether the transcription text has been validated. if false, word is also in buffer: transcription |
|
||||||
|
| `validated.speaker` | `boolean` | Whether the speaker assignment has been validated. if false, word is also in buffer: diarization |
|
||||||
|
| `validated.language` | `boolean` | Whether the language detection has been validated. if false, word is also in buffer: translation |
|
||||||
|
|
||||||
|
### Buffer Object (Per-Segment)
|
||||||
|
|
||||||
|
Buffers are **ephemeral**. They should be displayed to the user but not stored permanently in the frontend. Each update may contain a completely different buffer value, and previous buffer is likely to be in the next validated text.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `transcription` | `string` | Pending transcription text. Displayed immediately but **overwritten** on next update. |
|
||||||
|
| `diarization` | `string` | Pending diarization text (text waiting for speaker assignment). Displayed immediately but **overwritten** on next update. |
|
||||||
|
| `translation` | `string` | Pending translation text. Displayed immediately but **overwritten** on next update. |
|
||||||
|
|
||||||
|
|
||||||
|
### Metadata Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `remaining_time_transcription` | `float` | Seconds of audio waiting for transcription processing. |
|
||||||
|
| `remaining_time_diarization` | `float` | Seconds of audio waiting for speaker diarization. |
|
||||||
|
|
||||||
|
### Status Values
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `active_transcription` | Normal operation, transcription is active. |
|
||||||
|
| `no_audio_detected` | No audio has been detected yet. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Behavior
|
||||||
|
|
||||||
|
### Incremental Updates
|
||||||
|
|
||||||
|
The API sends **only changed or new segments**. Clients should:
|
||||||
|
|
||||||
|
1. Maintain a local map of segments by ID
|
||||||
|
2. When receiving an update, merge/update segments by ID
|
||||||
|
3. Render only the changed segments
|
||||||
|
|
||||||
|
### Language Detection
|
||||||
|
|
||||||
|
When language is detected for a segment:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Update 1: No language yet
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{"id": 1, "speaker": 1, "text": "May see", "language": null}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 2: Same segment ID, language now detected
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{"id": 1, "speaker": 1, "text": "Merci", "language": "fr"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client behavior**: **Replace** the existing segment with the same ID.
|
||||||
|
|
||||||
|
### Buffer Behavior
|
||||||
|
|
||||||
|
Buffers are **per-segment** to handle multi-speaker scenarios correctly.
|
||||||
|
|
||||||
|
#### Example: Translation with diarization and translation
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Update 1
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"speaker": 1,
|
||||||
|
"text": "Hello world, how are",
|
||||||
|
"translation": "",
|
||||||
|
"buffer": {
|
||||||
|
"transcription": "",
|
||||||
|
"diarization": " you on",
|
||||||
|
"translation": "Bonjour le monde"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==== Frontend ====
|
||||||
|
// <SPEAKER>1</SPEAKER>
|
||||||
|
// <TRANSCRIPTION>Hello world, how are <DIARIZATION BUFFER> you on</DIARIZATION BUFFER></TRANSCRIPTION>
|
||||||
|
// <TRANSLATION><TRANSLATION BUFFER>Bonjour le monde</TRANSLATION BUFFER></TRANSLATION>
|
||||||
|
|
||||||
|
|
||||||
|
// Update 2
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"speaker": 1,
|
||||||
|
"text": " you on this",
|
||||||
|
"translation": "Bonjour tout le monde",
|
||||||
|
"buffer": {
|
||||||
|
"transcription": "",
|
||||||
|
"diarization": " beautiful day",
|
||||||
|
"translation": ",comment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==== Frontend ====
|
||||||
|
// <SPEAKER>1</SPEAKER>
|
||||||
|
// <TRANSCRIPTION>Hello world, how are you on this<DIARIZATION BUFFER> beautiful day</DIARIZATION BUFFER></TRANSCRIPTION>
|
||||||
|
// <TRANSLATION>Bonjour tout le monde<TRANSLATION BUFFER>, comment</TRANSLATION BUFFER><TRANSLATION>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Silence Segments
|
||||||
|
|
||||||
|
Silence is represented with the speaker id = `-2`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"speaker": -2,
|
||||||
|
"text": "",
|
||||||
|
"start": 10.5,
|
||||||
|
"end": 12.3
|
||||||
|
}
|
||||||
|
```
|
||||||
71
docs/alignement_principles.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
### Alignment between STT Tokens and Diarization Segments
|
||||||
|
|
||||||
|
- Example 1: The punctuation from STT and the speaker change from Diariation come in the prediction `t`
|
||||||
|
- Example 2: The punctuation from STT comes from prediction `t`, but the speaker change from Diariation come in the prediction `t-1`
|
||||||
|
- Example 3: The punctuation from STT comes from prediction `t-1`, but the speaker change from Diariation come in the prediction `t`
|
||||||
|
|
||||||
|
> `#` Is the split between the `t-1` prediction and `t` prediction.
|
||||||
|
|
||||||
|
|
||||||
|
## Example 1:
|
||||||
|
```text
|
||||||
|
punctuations_segments : __#_______.__________________!____
|
||||||
|
diarization_segments:
|
||||||
|
SPK1 __#____________
|
||||||
|
SPK2 # ___________________
|
||||||
|
-->
|
||||||
|
ALIGNED SPK1 __#_______.
|
||||||
|
ALIGNED SPK2 # __________________!____
|
||||||
|
|
||||||
|
t-1 output:
|
||||||
|
SPK1: __#
|
||||||
|
SPK2: NO
|
||||||
|
DIARIZATION BUFFER: NO
|
||||||
|
|
||||||
|
t output:
|
||||||
|
SPK1: __#__.
|
||||||
|
SPK2: __________________!____
|
||||||
|
DIARIZATION BUFFER: No
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 2:
|
||||||
|
```text
|
||||||
|
punctuations_segments : _____#__.___________
|
||||||
|
diarization_segments:
|
||||||
|
SPK1 ___ #
|
||||||
|
SPK2 __#______________
|
||||||
|
-->
|
||||||
|
ALIGNED SPK1 _____#__.
|
||||||
|
ALIGNED SPK2 # ___________
|
||||||
|
|
||||||
|
t-1 output:
|
||||||
|
SPK1: ___ #
|
||||||
|
SPK2:
|
||||||
|
DIARIZATION BUFFER: __#
|
||||||
|
|
||||||
|
t output:
|
||||||
|
SPK1: __#__.
|
||||||
|
SPK2: ___________
|
||||||
|
DIARIZATION BUFFER: No
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 3:
|
||||||
|
```text
|
||||||
|
punctuations_segments : ___.__#__________
|
||||||
|
diarization_segments:
|
||||||
|
SPK1 ______#__
|
||||||
|
SPK2 # ________
|
||||||
|
-->
|
||||||
|
ALIGNED SPK1 ___. #
|
||||||
|
ALIGNED SPK2 __#__________
|
||||||
|
|
||||||
|
t-1 output:
|
||||||
|
SPK1: ___. #
|
||||||
|
SPK2:
|
||||||
|
DIARIZATION BUFFER: __#
|
||||||
|
|
||||||
|
t output:
|
||||||
|
SPK1: #
|
||||||
|
SPK2: __#___________
|
||||||
|
DIARIZATION BUFFER: NO
|
||||||
|
```
|
||||||
109
docs/available_models.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Available Whisper model sizes:
|
||||||
|
|
||||||
|
- tiny.en (english only)
|
||||||
|
- tiny
|
||||||
|
- base.en (english only)
|
||||||
|
- base
|
||||||
|
- small.en (english only)
|
||||||
|
- small
|
||||||
|
- medium.en (english only)
|
||||||
|
- medium
|
||||||
|
- large-v1
|
||||||
|
- large-v2
|
||||||
|
- large-v3
|
||||||
|
- large-v3-turbo
|
||||||
|
|
||||||
|
## How to choose?
|
||||||
|
|
||||||
|
### Language Support
|
||||||
|
- **English only**: Use `.en` models for better accuracy and faster processing when you only need English transcription
|
||||||
|
- **Multilingual**: Do not use `.en` models.
|
||||||
|
|
||||||
|
### Resource Constraints
|
||||||
|
- **Limited GPU/CPU or need for very low latency**: Choose `small` or smaller models
|
||||||
|
- `tiny`: Fastest, lowest resource usage, acceptable quality for simple audio
|
||||||
|
- `base`: Good balance of speed and accuracy for basic use cases
|
||||||
|
- `small`: Better accuracy while still being resource-efficient
|
||||||
|
- **Good resources available**: Use `large` models for best accuracy
|
||||||
|
- `large-v2`: Excellent accuracy, good multilingual support
|
||||||
|
- `large-v3`: Best overall accuracy and language support
|
||||||
|
|
||||||
|
### Special Cases
|
||||||
|
- **No translation needed**: Use `large-v3-turbo`
|
||||||
|
- Same transcription quality as `large-v2` but significantly faster
|
||||||
|
- **Important**: Does not translate correctly, only transcribes
|
||||||
|
|
||||||
|
### Model Comparison Table
|
||||||
|
|
||||||
|
| Model | Speed | Accuracy | Multilingual | Translation | Best Use Case |
|
||||||
|
|-------|--------|----------|--------------|-------------|---------------|
|
||||||
|
| tiny(.en) | Fastest | Basic | Yes/No | Yes/No | Real-time, low resources |
|
||||||
|
| base(.en) | Fast | Good | Yes/No | Yes/No | Balanced performance |
|
||||||
|
| small(.en) | Medium | Better | Yes/No | Yes/No | Quality on limited hardware |
|
||||||
|
| medium(.en) | Slow | High | Yes/No | Yes/No | High quality, moderate resources |
|
||||||
|
| large-v2 | Slowest | Excellent | Yes | Yes | Best overall quality |
|
||||||
|
| large-v3 | Slowest | Excellent | Yes | Yes | Maximum accuracy |
|
||||||
|
| large-v3-turbo | Fast | Excellent | Yes | No | Fast, high-quality transcription |
|
||||||
|
|
||||||
|
### Additional Considerations
|
||||||
|
|
||||||
|
**Model Performance**:
|
||||||
|
- Accuracy improves significantly from tiny to large models
|
||||||
|
- English-only models are ~10-15% more accurate for English audio
|
||||||
|
- Newer versions (v2, v3) have better punctuation and formatting
|
||||||
|
|
||||||
|
**Hardware Requirements**:
|
||||||
|
- `tiny`: ~1GB VRAM
|
||||||
|
- `base`: ~1GB VRAM
|
||||||
|
- `small`: ~2GB VRAM
|
||||||
|
- `medium`: ~5GB VRAM
|
||||||
|
- `large`: ~10GB VRAM
|
||||||
|
- `large‑v3‑turbo`: ~6GB VRAM
|
||||||
|
|
||||||
|
**Audio Quality Impact**:
|
||||||
|
- Clean, clear audio: smaller models may suffice
|
||||||
|
- Noisy, accented, or technical audio: larger models recommended
|
||||||
|
- Phone/low-quality audio: use at least `small` model
|
||||||
|
|
||||||
|
### Quick Decision Tree
|
||||||
|
1. English only? → Add `.en` to your choice
|
||||||
|
2. Limited resources or need speed? → `small` or smaller
|
||||||
|
3. Good hardware and want best quality? → `large-v3`
|
||||||
|
4. Need fast, high-quality transcription without translation? → `large-v3-turbo`
|
||||||
|
5. Need translation capabilities? → `large-v2` or `large-v3` (avoid turbo)
|
||||||
|
|
||||||
|
|
||||||
|
_______________________
|
||||||
|
|
||||||
|
# Translation Models and Backend
|
||||||
|
|
||||||
|
**Language Support**: ~200 languages
|
||||||
|
|
||||||
|
## Distilled Model Sizes Available
|
||||||
|
|
||||||
|
| Model | Size | Parameters | VRAM (FP16) | VRAM (INT8) | Quality |
|
||||||
|
|-------|------|------------|-------------|-------------|---------|
|
||||||
|
| 600M | 2.46 GB | 600M | ~1.5GB | ~800MB | Good, understandable |
|
||||||
|
| 1.3B | 5.48 GB | 1.3B | ~3GB | ~1.5GB | Better accuracy, context |
|
||||||
|
|
||||||
|
**Quality Impact**: 1.3B has ~15-25% better BLEU scores vs 600M across language pairs.
|
||||||
|
|
||||||
|
## Backend Performance
|
||||||
|
|
||||||
|
| Backend | Speed vs Base | Memory Usage | Quality Loss |
|
||||||
|
|---------|---------------|--------------|--------------|
|
||||||
|
| CTranslate2 | 6-10x faster | 40-60% less | ~5% BLEU drop |
|
||||||
|
| Transformers | Baseline | High | None |
|
||||||
|
| Transformers + MPS (on Apple Silicon) | 2x faster | Medium | None |
|
||||||
|
|
||||||
|
**Metrics**:
|
||||||
|
- CTranslate2: 50-100+ tokens/sec
|
||||||
|
- Transformers: 10-30 tokens/sec
|
||||||
|
- Apple Silicon with MPS: Up to 2x faster than CTranslate2
|
||||||
|
|
||||||
|
## Quick Decision Matrix
|
||||||
|
|
||||||
|
**Choose 600M**: Limited resources, close to 0 lag
|
||||||
|
**Choose 1.3B**: Quality matters
|
||||||
|
**Choose Transformers**: On Apple Silicon
|
||||||
|
|
||||||
19
docs/models_compatible_formats.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Model Path Formats
|
||||||
|
|
||||||
|
The `--model-path` parameter accepts:
|
||||||
|
|
||||||
|
## File Path
|
||||||
|
- **`.pt` / `.bin` / `.safetensor` formats** Should be openable by pytorch/safetensor.
|
||||||
|
|
||||||
|
## Directory Path (recommended)
|
||||||
|
Must contain:
|
||||||
|
- **`.pt` / `.bin` / `.safetensor` file** (required for decoder)
|
||||||
|
|
||||||
|
May optionally contain:
|
||||||
|
- **`.bin` file** - faster-whisper model for encoder (requires faster-whisper)
|
||||||
|
- **`weights.npz`** or **`weights.safetensors`** - for encoder (requires whisper-mlx)
|
||||||
|
|
||||||
|
## Hugging Face Repo ID
|
||||||
|
- Provide the repo ID (e.g. `openai/whisper-large-v3`) and WhisperLiveKit will download and cache the snapshot automatically. For gated repos, authenticate via `huggingface-cli login` first.
|
||||||
|
|
||||||
|
To improve speed/reduce allucinations, you may want to use `scripts/determine_alignment_heads.py` to determine the alignment heads to use for your model, and use the `--custom-alignment-heads` to pass them to WLK. If not, alignement heads are set to be all the heads of the last half layer of decoder.
|
||||||
265
docs/supported_languages.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# Supported Languages
|
||||||
|
|
||||||
|
WhisperLiveKit supports translation into **201 languages** from the FLORES-200 dataset through the NLLB (No Language Left Behind) translation system.
|
||||||
|
|
||||||
|
## How to Specify Languages
|
||||||
|
|
||||||
|
You can specify languages in **three different ways**:
|
||||||
|
|
||||||
|
1. **Language Name** (case-insensitive): `"English"`, `"French"`, `"Spanish"`
|
||||||
|
2. **ISO Language Code**: `"en"`, `"fr"`, `"es"`
|
||||||
|
3. **NLLB Code** (FLORES-200): `"eng_Latn"`, `"fra_Latn"`, `"spa_Latn"`
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Command Line
|
||||||
|
```bash
|
||||||
|
# Using language name
|
||||||
|
whisperlivekit-server --target-language "French"
|
||||||
|
|
||||||
|
# Using ISO code
|
||||||
|
whisperlivekit-server --target-language fr
|
||||||
|
|
||||||
|
# Using NLLB code
|
||||||
|
whisperlivekit-server --target-language fra_Latn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
```python
|
||||||
|
from nllw.translation import get_language_info
|
||||||
|
|
||||||
|
# Get language information by name
|
||||||
|
lang_info = get_language_info("French")
|
||||||
|
print(lang_info)
|
||||||
|
# {'name': 'French', 'nllb': 'fra_Latn', 'language_code': 'fr'}
|
||||||
|
|
||||||
|
# Get language information by ISO code
|
||||||
|
lang_info = get_language_info("fr")
|
||||||
|
|
||||||
|
# Get language information by NLLB code
|
||||||
|
lang_info = get_language_info("fra_Latn")
|
||||||
|
|
||||||
|
# All three return the same result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Language List
|
||||||
|
|
||||||
|
The following table lists all 201 supported languages with their corresponding codes:
|
||||||
|
|
||||||
|
| Language Name | ISO Code | NLLB Code |
|
||||||
|
|---------------|----------|-----------|
|
||||||
|
| Acehnese (Arabic script) | ace_Arab | ace_Arab |
|
||||||
|
| Acehnese (Latin script) | ace_Latn | ace_Latn |
|
||||||
|
| Mesopotamian Arabic | acm_Arab | acm_Arab |
|
||||||
|
| Ta'izzi-Adeni Arabic | acq_Arab | acq_Arab |
|
||||||
|
| Tunisian Arabic | aeb_Arab | aeb_Arab |
|
||||||
|
| Afrikaans | af | afr_Latn |
|
||||||
|
| South Levantine Arabic | ajp_Arab | ajp_Arab |
|
||||||
|
| Akan | ak | aka_Latn |
|
||||||
|
| Tosk Albanian | als | als_Latn |
|
||||||
|
| Amharic | am | amh_Ethi |
|
||||||
|
| North Levantine Arabic | apc_Arab | apc_Arab |
|
||||||
|
| Modern Standard Arabic | ar | arb_Arab |
|
||||||
|
| Modern Standard Arabic (Romanized) | arb_Latn | arb_Latn |
|
||||||
|
| Najdi Arabic | ars_Arab | ars_Arab |
|
||||||
|
| Moroccan Arabic | ary_Arab | ary_Arab |
|
||||||
|
| Egyptian Arabic | arz_Arab | arz_Arab |
|
||||||
|
| Assamese | as | asm_Beng |
|
||||||
|
| Asturian | ast | ast_Latn |
|
||||||
|
| Awadhi | awa | awa_Deva |
|
||||||
|
| Central Aymara | ay | ayr_Latn |
|
||||||
|
| South Azerbaijani | azb | azb_Arab |
|
||||||
|
| North Azerbaijani | az | azj_Latn |
|
||||||
|
| Bashkir | ba | bak_Cyrl |
|
||||||
|
| Bambara | bm | bam_Latn |
|
||||||
|
| Balinese | ban | ban_Latn |
|
||||||
|
| Belarusian | be | bel_Cyrl |
|
||||||
|
| Bemba | bem | bem_Latn |
|
||||||
|
| Bengali | bn | ben_Beng |
|
||||||
|
| Bhojpuri | bho | bho_Deva |
|
||||||
|
| Banjar (Arabic script) | bjn_Arab | bjn_Arab |
|
||||||
|
| Banjar (Latin script) | bjn_Latn | bjn_Latn |
|
||||||
|
| Standard Tibetan | bo | bod_Tibt |
|
||||||
|
| Bosnian | bs | bos_Latn |
|
||||||
|
| Buginese | bug | bug_Latn |
|
||||||
|
| Bulgarian | bg | bul_Cyrl |
|
||||||
|
| Catalan | ca | cat_Latn |
|
||||||
|
| Cebuano | ceb | ceb_Latn |
|
||||||
|
| Czech | cs | ces_Latn |
|
||||||
|
| Chokwe | cjk | cjk_Latn |
|
||||||
|
| Central Kurdish | ckb | ckb_Arab |
|
||||||
|
| Crimean Tatar | crh | crh_Latn |
|
||||||
|
| Welsh | cy | cym_Latn |
|
||||||
|
| Danish | da | dan_Latn |
|
||||||
|
| German | de | deu_Latn |
|
||||||
|
| Southwestern Dinka | dik | dik_Latn |
|
||||||
|
| Dyula | dyu | dyu_Latn |
|
||||||
|
| Dzongkha | dz | dzo_Tibt |
|
||||||
|
| Greek | el | ell_Grek |
|
||||||
|
| English | en | eng_Latn |
|
||||||
|
| Esperanto | eo | epo_Latn |
|
||||||
|
| Estonian | et | est_Latn |
|
||||||
|
| Basque | eu | eus_Latn |
|
||||||
|
| Ewe | ee | ewe_Latn |
|
||||||
|
| Faroese | fo | fao_Latn |
|
||||||
|
| Fijian | fj | fij_Latn |
|
||||||
|
| Finnish | fi | fin_Latn |
|
||||||
|
| Fon | fon | fon_Latn |
|
||||||
|
| French | fr | fra_Latn |
|
||||||
|
| Friulian | fur-IT | fur_Latn |
|
||||||
|
| Nigerian Fulfulde | fuv | fuv_Latn |
|
||||||
|
| West Central Oromo | om | gaz_Latn |
|
||||||
|
| Scottish Gaelic | gd | gla_Latn |
|
||||||
|
| Irish | ga-IE | gle_Latn |
|
||||||
|
| Galician | gl | glg_Latn |
|
||||||
|
| Guarani | gn | grn_Latn |
|
||||||
|
| Gujarati | gu-IN | guj_Gujr |
|
||||||
|
| Haitian Creole | ht | hat_Latn |
|
||||||
|
| Hausa | ha | hau_Latn |
|
||||||
|
| Hebrew | he | heb_Hebr |
|
||||||
|
| Hindi | hi | hin_Deva |
|
||||||
|
| Chhattisgarhi | hne | hne_Deva |
|
||||||
|
| Croatian | hr | hrv_Latn |
|
||||||
|
| Hungarian | hu | hun_Latn |
|
||||||
|
| Armenian | hy-AM | hye_Armn |
|
||||||
|
| Igbo | ig | ibo_Latn |
|
||||||
|
| Ilocano | ilo | ilo_Latn |
|
||||||
|
| Indonesian | id | ind_Latn |
|
||||||
|
| Icelandic | is | isl_Latn |
|
||||||
|
| Italian | it | ita_Latn |
|
||||||
|
| Javanese | jv | jav_Latn |
|
||||||
|
| Japanese | ja | jpn_Jpan |
|
||||||
|
| Kabyle | kab | kab_Latn |
|
||||||
|
| Jingpho | kac | kac_Latn |
|
||||||
|
| Kamba | kam | kam_Latn |
|
||||||
|
| Kannada | kn | kan_Knda |
|
||||||
|
| Kashmiri (Arabic script) | kas_Arab | kas_Arab |
|
||||||
|
| Kashmiri (Devanagari script) | kas_Deva | kas_Deva |
|
||||||
|
| Georgian | ka | kat_Geor |
|
||||||
|
| Kazakh | kk | kaz_Cyrl |
|
||||||
|
| Kabiyè | kbp | kbp_Latn |
|
||||||
|
| Kabuverdianu | kea | kea_Latn |
|
||||||
|
| Halh Mongolian | mn | khk_Cyrl |
|
||||||
|
| Khmer | km | khm_Khmr |
|
||||||
|
| Kikuyu | ki | kik_Latn |
|
||||||
|
| Kinyarwanda | rw | kin_Latn |
|
||||||
|
| Kyrgyz | ky | kir_Cyrl |
|
||||||
|
| Kimbundu | kmb | kmb_Latn |
|
||||||
|
| Northern Kurdish | kmr | kmr_Latn |
|
||||||
|
| Central Kanuri (Arabic script) | knc_Arab | knc_Arab |
|
||||||
|
| Central Kanuri (Latin script) | knc_Latn | knc_Latn |
|
||||||
|
| Kikongo | kg | kon_Latn |
|
||||||
|
| Korean | ko | kor_Hang |
|
||||||
|
| Lao | lo | lao_Laoo |
|
||||||
|
| Ligurian | lij | lij_Latn |
|
||||||
|
| Limburgish | li | lim_Latn |
|
||||||
|
| Lingala | ln | lin_Latn |
|
||||||
|
| Lithuanian | lt | lit_Latn |
|
||||||
|
| Lombard | lmo | lmo_Latn |
|
||||||
|
| Latgalian | ltg | ltg_Latn |
|
||||||
|
| Luxembourgish | lb | ltz_Latn |
|
||||||
|
| Luba-Kasai | lua | lua_Latn |
|
||||||
|
| Ganda | lg | lug_Latn |
|
||||||
|
| Luo | luo | luo_Latn |
|
||||||
|
| Mizo | lus | lus_Latn |
|
||||||
|
| Standard Latvian | lv | lvs_Latn |
|
||||||
|
| Magahi | mag | mag_Deva |
|
||||||
|
| Maithili | mai | mai_Deva |
|
||||||
|
| Malayalam | ml-IN | mal_Mlym |
|
||||||
|
| Marathi | mr | mar_Deva |
|
||||||
|
| Minangkabau (Arabic script) | min_Arab | min_Arab |
|
||||||
|
| Minangkabau (Latin script) | min_Latn | min_Latn |
|
||||||
|
| Macedonian | mk | mkd_Cyrl |
|
||||||
|
| Maltese | mt | mlt_Latn |
|
||||||
|
| Meitei (Bengali script) | mni | mni_Beng |
|
||||||
|
| Mossi | mos | mos_Latn |
|
||||||
|
| Maori | mi | mri_Latn |
|
||||||
|
| Burmese | my | mya_Mymr |
|
||||||
|
| Dutch | nl | nld_Latn |
|
||||||
|
| Norwegian Nynorsk | nn-NO | nno_Latn |
|
||||||
|
| Norwegian Bokmål | nb | nob_Latn |
|
||||||
|
| Nepali | ne-NP | npi_Deva |
|
||||||
|
| Northern Sotho | nso | nso_Latn |
|
||||||
|
| Nuer | nus | nus_Latn |
|
||||||
|
| Nyanja | ny | nya_Latn |
|
||||||
|
| Occitan | oc | oci_Latn |
|
||||||
|
| Odia | or | ory_Orya |
|
||||||
|
| Pangasinan | pag | pag_Latn |
|
||||||
|
| Eastern Panjabi | pa | pan_Guru |
|
||||||
|
| Papiamento | pap | pap_Latn |
|
||||||
|
| Southern Pashto | pbt | pbt_Arab |
|
||||||
|
| Western Persian | fa | pes_Arab |
|
||||||
|
| Plateau Malagasy | mg | plt_Latn |
|
||||||
|
| Polish | pl | pol_Latn |
|
||||||
|
| Portuguese | pt-PT | por_Latn |
|
||||||
|
| Dari | fa-AF | prs_Arab |
|
||||||
|
| Ayacucho Quechua | qu | quy_Latn |
|
||||||
|
| Romanian | ro | ron_Latn |
|
||||||
|
| Rundi | rn | run_Latn |
|
||||||
|
| Russian | ru | rus_Cyrl |
|
||||||
|
| Sango | sg | sag_Latn |
|
||||||
|
| Sanskrit | sa | san_Deva |
|
||||||
|
| Santali | sat | sat_Olck |
|
||||||
|
| Sicilian | scn | scn_Latn |
|
||||||
|
| Shan | shn | shn_Mymr |
|
||||||
|
| Sinhala | si-LK | sin_Sinh |
|
||||||
|
| Slovak | sk | slk_Latn |
|
||||||
|
| Slovenian | sl | slv_Latn |
|
||||||
|
| Samoan | sm | smo_Latn |
|
||||||
|
| Shona | sn | sna_Latn |
|
||||||
|
| Sindhi | sd | snd_Arab |
|
||||||
|
| Somali | so | som_Latn |
|
||||||
|
| Southern Sotho | st | sot_Latn |
|
||||||
|
| Spanish | es-ES | spa_Latn |
|
||||||
|
| Sardinian | sc | srd_Latn |
|
||||||
|
| Serbian | sr | srp_Cyrl |
|
||||||
|
| Swati | ss | ssw_Latn |
|
||||||
|
| Sundanese | su | sun_Latn |
|
||||||
|
| Swedish | sv-SE | swe_Latn |
|
||||||
|
| Swahili | sw | swh_Latn |
|
||||||
|
| Silesian | szl | szl_Latn |
|
||||||
|
| Tamil | ta | tam_Taml |
|
||||||
|
| Tamasheq (Latin script) | taq_Latn | taq_Latn |
|
||||||
|
| Tamasheq (Tifinagh script) | taq_Tfng | taq_Tfng |
|
||||||
|
| Tatar | tt-RU | tat_Cyrl |
|
||||||
|
| Telugu | te | tel_Telu |
|
||||||
|
| Tajik | tg | tgk_Cyrl |
|
||||||
|
| Tagalog | tl | tgl_Latn |
|
||||||
|
| Thai | th | tha_Thai |
|
||||||
|
| Tigrinya | ti | tir_Ethi |
|
||||||
|
| Tok Pisin | tpi | tpi_Latn |
|
||||||
|
| Tswana | tn | tsn_Latn |
|
||||||
|
| Tsonga | ts | tso_Latn |
|
||||||
|
| Turkmen | tk | tuk_Latn |
|
||||||
|
| Tumbuka | tum | tum_Latn |
|
||||||
|
| Turkish | tr | tur_Latn |
|
||||||
|
| Twi | tw | twi_Latn |
|
||||||
|
| Central Atlas Tamazight | tzm | tzm_Tfng |
|
||||||
|
| Uyghur | ug | uig_Arab |
|
||||||
|
| Ukrainian | uk | ukr_Cyrl |
|
||||||
|
| Umbundu | umb | umb_Latn |
|
||||||
|
| Urdu | ur | urd_Arab |
|
||||||
|
| Northern Uzbek | uz | uzn_Latn |
|
||||||
|
| Venetian | vec | vec_Latn |
|
||||||
|
| Vietnamese | vi | vie_Latn |
|
||||||
|
| Waray | war | war_Latn |
|
||||||
|
| Wolof | wo | wol_Latn |
|
||||||
|
| Xhosa | xh | xho_Latn |
|
||||||
|
| Eastern Yiddish | yi | ydd_Hebr |
|
||||||
|
| Yoruba | yo | yor_Latn |
|
||||||
|
| Yue Chinese | yue | yue_Hant |
|
||||||
|
| Chinese (Simplified) | zh-CN | zho_Hans |
|
||||||
|
| Chinese (Traditional) | zh-TW | zho_Hant |
|
||||||
|
| Standard Malay | ms | zsm_Latn |
|
||||||
|
| Zulu | zu | zul_Latn |
|
||||||
|
|
||||||
|
## Special Features
|
||||||
|
|
||||||
|
### Multiple Script Support
|
||||||
|
Several languages are available in multiple scripts (e.g., Arabic and Latin):
|
||||||
|
- **Acehnese**: Arabic (`ace_Arab`) and Latin (`ace_Latn`)
|
||||||
|
- **Banjar**: Arabic (`bjn_Arab`) and Latin (`bjn_Latn`)
|
||||||
|
- **Kashmiri**: Arabic (`kas_Arab`) and Devanagari (`kas_Deva`)
|
||||||
|
- **Minangkabau**: Arabic (`min_Arab`) and Latin (`min_Latn`)
|
||||||
|
- **Tamasheq**: Latin (`taq_Latn`) and Tifinagh (`taq_Tfng`)
|
||||||
|
- **Central Kanuri**: Arabic (`knc_Arab`) and Latin (`knc_Latn`)
|
||||||
43
docs/technical_integration.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Technical Integration Guide
|
||||||
|
|
||||||
|
This document introduce how to reuse the core components when you do **not** want to ship the bundled frontend, FastAPI server, or even the provided CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Runtime Components
|
||||||
|
|
||||||
|
| Layer | File(s) | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| Transport | `whisperlivekit/basic_server.py`, any ASGI/WebSocket server | Accepts audio over WebSocket (MediaRecorder WebM or raw PCM chunks) and streams JSON updates back |
|
||||||
|
| Audio processing | `whisperlivekit/audio_processor.py` | Buffers audio, orchestrates transcription, diarization, translation, handles FFmpeg/PCM input |
|
||||||
|
| Engines | `whisperlivekit/core.py`, `whisperlivekit/simul_whisper/*`, `whisperlivekit/local_agreement/*` | Load models once (SimulStreaming or LocalAgreement), expose `TranscriptionEngine` and helpers |
|
||||||
|
| Frontends | `whisperlivekit/web/*`, `chrome-extension/*` | Optional UI layers feeding the WebSocket endpoint |
|
||||||
|
|
||||||
|
**Key idea:** The server boundary is just `AudioProcessor.process_audio()` for incoming bytes and the async generator returned by `AudioProcessor.create_tasks()` for outgoing updates (`FrontData`). Everything else is optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Running Without the Bundled Frontend
|
||||||
|
|
||||||
|
1. Start the server/engine however you like:
|
||||||
|
```bash
|
||||||
|
wlk --model small --language en --host 0.0.0.0 --port 9000
|
||||||
|
# or launch your own app that instantiates TranscriptionEngine(...)
|
||||||
|
```
|
||||||
|
2. Build your own client (browser, mobile, desktop) that:
|
||||||
|
- Opens `ws(s)://<host>:<port>/asr`
|
||||||
|
- Sends either MediaRecorder/Opus WebM blobs **or** raw PCM (`--pcm-input` on the server tells the client to use the AudioWorklet).
|
||||||
|
- Consumes the JSON payload defined in `docs/API.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Running Without FastAPI
|
||||||
|
|
||||||
|
`whisperlivekit/basic_server.py` is just an example. Any async framework works, as long as you:
|
||||||
|
|
||||||
|
1. Create a global `TranscriptionEngine` (expensive to initialize; reuse it).
|
||||||
|
2. Instantiate `AudioProcessor(transcription_engine=engine)` for each connection.
|
||||||
|
3. Call `create_tasks()` to get the async generator, `process_audio()` with incoming bytes, and ensure `cleanup()` runs when the client disconnects.
|
||||||
|
|
||||||
|
|
||||||
|
If you prefer to send compressed audio, instantiate `AudioProcessor(pcm_input=False)` and pipe encoded chunks through `FFmpegManager` transparently—just ensure `ffmpeg` is available or be ready to handle the `"ffmpeg_not_found"` error in the streamed `FrontData`.
|
||||||
70
pyproject.toml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "whisperlivekit"
|
||||||
|
version = "0.2.15"
|
||||||
|
description = "Real-time speech-to-text with speaker diarization using Whisper"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "Quentin Fuxa" }
|
||||||
|
]
|
||||||
|
license = { file = "LICENSE" }
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: 3.15",
|
||||||
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||||
|
"Topic :: Multimedia :: Sound/Audio :: Speech"
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"librosa",
|
||||||
|
"soundfile",
|
||||||
|
"uvicorn",
|
||||||
|
"websockets",
|
||||||
|
"torchaudio>=2.0.0",
|
||||||
|
"torch>=2.0.0",
|
||||||
|
"huggingface-hub>=0.25.0",
|
||||||
|
"tqdm",
|
||||||
|
"tiktoken",
|
||||||
|
'triton>=2.0.0; platform_machine == "x86_64" and (sys_platform == "linux" or sys_platform == "linux2")'
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
translation = ["nllw"]
|
||||||
|
sentence_tokenizer = ["mosestokenizer", "wtpsplit"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/QuentinFuxa/WhisperLiveKit"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
whisperlivekit-server = "whisperlivekit.basic_server:main"
|
||||||
|
wlk = "whisperlivekit.basic_server:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = [
|
||||||
|
"whisperlivekit",
|
||||||
|
"whisperlivekit.diarization",
|
||||||
|
"whisperlivekit.simul_whisper",
|
||||||
|
"whisperlivekit.whisper",
|
||||||
|
"whisperlivekit.whisper.assets",
|
||||||
|
"whisperlivekit.whisper.normalizers",
|
||||||
|
"whisperlivekit.web",
|
||||||
|
"whisperlivekit.local_agreement",
|
||||||
|
"whisperlivekit.vad_models"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
whisperlivekit = ["web/*.html", "web/*.css", "web/*.js", "web/src/*.svg"]
|
||||||
|
"whisperlivekit.whisper.assets" = ["*.tiktoken", "*.npz"]
|
||||||
|
"whisperlivekit.vad_models" = ["*.jit", "*.onnx"]
|
||||||
BIN
scripts/alignment_heads.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
153
scripts/convert_hf_whisper.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Convert a Hugging Face style Whisper checkpoint into a WhisperLiveKit .pt file.
|
||||||
|
|
||||||
|
Optionally shrink the supported audio chunk length (in seconds) by trimming the
|
||||||
|
encoder positional embeddings and updating the stored model dimensions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from whisperlivekit.whisper.audio import HOP_LENGTH, SAMPLE_RATE
|
||||||
|
from whisperlivekit.whisper.model import ModelDimensions
|
||||||
|
from whisperlivekit.whisper.utils import exact_div
|
||||||
|
from whisperlivekit.whisper import _convert_hf_state_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state_dict(repo_path: Path) -> Dict[str, torch.Tensor]:
|
||||||
|
safetensor_path = repo_path / "model.safetensors"
|
||||||
|
bin_path = repo_path / "pytorch_model.bin"
|
||||||
|
|
||||||
|
if safetensor_path.is_file():
|
||||||
|
try:
|
||||||
|
from safetensors.torch import load_file # type: ignore
|
||||||
|
except Exception as exc: # pragma: no cover - import guard
|
||||||
|
raise RuntimeError(
|
||||||
|
"Install safetensors to load model.safetensors "
|
||||||
|
"(pip install safetensors)"
|
||||||
|
) from exc
|
||||||
|
return load_file(str(safetensor_path))
|
||||||
|
|
||||||
|
if bin_path.is_file():
|
||||||
|
return torch.load(bin_path, map_location="cpu")
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Could not find model.safetensors or pytorch_model.bin under {repo_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config(repo_path: Path) -> Dict:
|
||||||
|
config_path = repo_path / "config.json"
|
||||||
|
if not config_path.is_file():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Hugging Face checkpoint at {repo_path} is missing config.json"
|
||||||
|
)
|
||||||
|
with open(config_path, "r", encoding="utf-8") as fp:
|
||||||
|
return json.load(fp)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_audio_ctx(chunk_length: float) -> Tuple[int, int]:
|
||||||
|
n_samples = int(round(chunk_length * SAMPLE_RATE))
|
||||||
|
expected_samples = chunk_length * SAMPLE_RATE
|
||||||
|
if abs(n_samples - expected_samples) > 1e-6:
|
||||||
|
raise ValueError(
|
||||||
|
"chunk_length must align with sample rate so that "
|
||||||
|
"chunk_length * SAMPLE_RATE is an integer"
|
||||||
|
)
|
||||||
|
n_frames = exact_div(n_samples, HOP_LENGTH)
|
||||||
|
n_audio_ctx = exact_div(n_frames, 2)
|
||||||
|
return n_frames, n_audio_ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dims(config: Dict, chunk_length: float) -> Dict:
|
||||||
|
base_dims = ModelDimensions(
|
||||||
|
n_mels=config["num_mel_bins"],
|
||||||
|
n_audio_ctx=config["max_source_positions"],
|
||||||
|
n_audio_state=config["d_model"],
|
||||||
|
n_audio_head=config["encoder_attention_heads"],
|
||||||
|
n_audio_layer=config.get("encoder_layers") or config["num_hidden_layers"],
|
||||||
|
n_vocab=config["vocab_size"],
|
||||||
|
n_text_ctx=config["max_target_positions"],
|
||||||
|
n_text_state=config["d_model"],
|
||||||
|
n_text_head=config["decoder_attention_heads"],
|
||||||
|
n_text_layer=config["decoder_layers"],
|
||||||
|
).__dict__.copy()
|
||||||
|
|
||||||
|
_, n_audio_ctx = _derive_audio_ctx(chunk_length)
|
||||||
|
base_dims["n_audio_ctx"] = n_audio_ctx
|
||||||
|
base_dims["chunk_length"] = chunk_length
|
||||||
|
return base_dims
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_positional_embedding(
|
||||||
|
state_dict: Dict[str, torch.Tensor], target_ctx: int
|
||||||
|
) -> None:
|
||||||
|
key = "encoder.positional_embedding"
|
||||||
|
if key not in state_dict:
|
||||||
|
raise KeyError(f"{key} missing from converted state dict")
|
||||||
|
|
||||||
|
tensor = state_dict[key]
|
||||||
|
if tensor.shape[0] < target_ctx:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot increase encoder ctx from {tensor.shape[0]} to {target_ctx}"
|
||||||
|
)
|
||||||
|
if tensor.shape[0] == target_ctx:
|
||||||
|
return
|
||||||
|
state_dict[key] = tensor[:target_ctx].contiguous()
|
||||||
|
|
||||||
|
|
||||||
|
def convert_checkpoint(hf_path: Path, output_path: Path, chunk_length: float) -> None:
|
||||||
|
state_dict = _load_state_dict(hf_path)
|
||||||
|
converted = _convert_hf_state_dict(state_dict)
|
||||||
|
|
||||||
|
config = _load_config(hf_path)
|
||||||
|
dims = _build_dims(config, chunk_length)
|
||||||
|
|
||||||
|
_trim_positional_embedding(converted, dims["n_audio_ctx"])
|
||||||
|
|
||||||
|
package = {"dims": dims, "model_state_dict": converted}
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
torch.save(package, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Convert Hugging Face Whisper checkpoint to WhisperLiveKit format."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"hf_path",
|
||||||
|
type=str,
|
||||||
|
help="Path to the cloned Hugging Face repository (e.g. whisper-tiny.en)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default="converted-whisper.pt",
|
||||||
|
help="Destination path for the .pt file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--chunk-length",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="Audio chunk length in seconds to support (default: 30)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
hf_path = Path(os.path.expanduser(args.hf_path)).resolve()
|
||||||
|
output_path = Path(os.path.expanduser(args.output)).resolve()
|
||||||
|
|
||||||
|
convert_checkpoint(hf_path, output_path, args.chunk_length)
|
||||||
|
print(f"Saved converted checkpoint to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
292
scripts/determine_alignment_heads.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Determine alignment heads for a variants, such as distilled model"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
from typing import List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from datasets import Audio as DatasetAudio, load_dataset
|
||||||
|
import soundfile as sf
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
WHISPER_ROOT = REPO_ROOT / "whisper"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
sys.path.insert(0, str(WHISPER_ROOT))
|
||||||
|
|
||||||
|
from whisper import load_model
|
||||||
|
from whisper.audio import load_audio, log_mel_spectrogram, pad_or_trim
|
||||||
|
from whisper.tokenizer import get_tokenizer
|
||||||
|
|
||||||
|
AudioInput = Union[str, pathlib.Path, np.ndarray, torch.Tensor]
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataset_clips(name, config, split, limit):
|
||||||
|
ds = load_dataset(name, config, split=split)
|
||||||
|
ds = ds.cast_column("audio", DatasetAudio(decode=False))
|
||||||
|
clips = []
|
||||||
|
for idx, row in enumerate(ds):
|
||||||
|
if limit is not None and idx >= limit:
|
||||||
|
break
|
||||||
|
audio_field = row["audio"]
|
||||||
|
transcript = row["text"]
|
||||||
|
|
||||||
|
waveform_np, _ = sf.read(io.BytesIO(audio_field["bytes"]), dtype="float32")
|
||||||
|
if waveform_np.ndim > 1:
|
||||||
|
waveform_np = waveform_np.mean(axis=1)
|
||||||
|
waveform = waveform_np
|
||||||
|
transcript = str(transcript)
|
||||||
|
|
||||||
|
clips.append((waveform, transcript))
|
||||||
|
return clips
|
||||||
|
|
||||||
|
|
||||||
|
def load_clips(args):
|
||||||
|
return load_dataset_clips(
|
||||||
|
args.dataset,
|
||||||
|
args.dataset_config,
|
||||||
|
args.dataset_split,
|
||||||
|
args.dataset_num_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _waveform_from_source(source: AudioInput) -> torch.Tensor:
|
||||||
|
waveform = torch.from_numpy(source.astype(np.float32, copy=False))
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
type=str,
|
||||||
|
default="pytorch_model.bin",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
type=str,
|
||||||
|
default="cuda" if torch.cuda.is_available() else "cpu",
|
||||||
|
help="Torch device to run on",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset",
|
||||||
|
type=str,
|
||||||
|
default="librispeech_asr"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset-config",
|
||||||
|
type=str,
|
||||||
|
default="clean"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset-split",
|
||||||
|
type=str,
|
||||||
|
default="validation[:1%]",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset-num-samples",
|
||||||
|
type=int,
|
||||||
|
default=16,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold",
|
||||||
|
type=float,
|
||||||
|
default=1.5,
|
||||||
|
help="Z score threshold for a head to be selected",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--votes",
|
||||||
|
type=float,
|
||||||
|
default=0.75,
|
||||||
|
help="percentage of clips that must vote for a head",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default="alignment_heads.b85",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--visualize-top-k",
|
||||||
|
type=int,
|
||||||
|
default=32,
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_heads(
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
clips: Sequence[Tuple[AudioInput, str]],
|
||||||
|
threshold: float,
|
||||||
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||||
|
device = model.device
|
||||||
|
votes = torch.zeros(model.dims.n_text_layer, model.dims.n_text_head, device=device)
|
||||||
|
strengths = torch.zeros_like(votes)
|
||||||
|
|
||||||
|
for audio_source, transcript in clips:
|
||||||
|
waveform = pad_or_trim(_waveform_from_source(audio_source))
|
||||||
|
mel = log_mel_spectrogram(waveform, device=device)
|
||||||
|
|
||||||
|
tokens = torch.tensor(
|
||||||
|
[
|
||||||
|
*tokenizer.sot_sequence,
|
||||||
|
tokenizer.no_timestamps,
|
||||||
|
*tokenizer.encode(transcript),
|
||||||
|
tokenizer.eot,
|
||||||
|
],
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
|
||||||
|
qks = [None] * model.dims.n_text_layer
|
||||||
|
hooks = [
|
||||||
|
block.cross_attn.register_forward_hook(
|
||||||
|
lambda _, __, outputs, index=i: qks.__setitem__(index, outputs[-1][0])
|
||||||
|
)
|
||||||
|
for i, block in enumerate(model.decoder.blocks)
|
||||||
|
]
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
model(mel.unsqueeze(0), tokens.unsqueeze(0))
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
hook.remove()
|
||||||
|
|
||||||
|
for layer_idx, tensor in enumerate(qks):
|
||||||
|
if tensor is None:
|
||||||
|
continue
|
||||||
|
tensor = tensor[:, :, : mel.shape[-1] // 2]
|
||||||
|
tensor = tensor.softmax(dim=-1)
|
||||||
|
peak = tensor.max(dim=-1).values # [heads, tokens]
|
||||||
|
strengths[layer_idx] += peak.mean(dim=-1)
|
||||||
|
zscore = (peak - peak.mean(dim=-1, keepdim=True)) / (
|
||||||
|
peak.std(dim=-1, keepdim=True, unbiased=False) + 1e-6
|
||||||
|
)
|
||||||
|
mask = (zscore > 3).any(dim=-1)
|
||||||
|
votes[layer_idx] += mask.float()
|
||||||
|
|
||||||
|
votes /= len(clips)
|
||||||
|
strengths /= len(clips)
|
||||||
|
return votes, strengths
|
||||||
|
|
||||||
|
|
||||||
|
def _select_heads_for_visualization(selection, strengths, top_k):
|
||||||
|
selected = torch.nonzero(selection, as_tuple=False)
|
||||||
|
if selected.numel() == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
(int(layer.item()), int(head.item()), float(strengths[layer, head].item()))
|
||||||
|
for layer, head in selected
|
||||||
|
]
|
||||||
|
entries.sort(key=lambda item: item[2], reverse=True)
|
||||||
|
return entries[:top_k]
|
||||||
|
|
||||||
|
def _extract_heatmaps(
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
clip: Tuple[AudioInput, str],
|
||||||
|
heads: Sequence[Tuple[int, int, float]],
|
||||||
|
) -> dict:
|
||||||
|
if not heads:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
target_map = {}
|
||||||
|
for layer, head, _ in heads:
|
||||||
|
target_map.setdefault(layer, set()).add(head)
|
||||||
|
|
||||||
|
waveform = pad_or_trim(_waveform_from_source(clip[0]))
|
||||||
|
mel = log_mel_spectrogram(waveform, device=model.device)
|
||||||
|
transcript = clip[1]
|
||||||
|
tokens = torch.tensor(
|
||||||
|
[
|
||||||
|
*tokenizer.sot_sequence,
|
||||||
|
tokenizer.no_timestamps,
|
||||||
|
*tokenizer.encode(transcript),
|
||||||
|
tokenizer.eot,
|
||||||
|
],
|
||||||
|
device=model.device,
|
||||||
|
)
|
||||||
|
|
||||||
|
QKs = [None] * model.dims.n_text_layer
|
||||||
|
hooks = [
|
||||||
|
block.cross_attn.register_forward_hook(
|
||||||
|
lambda _, __, outputs, index=i: QKs.__setitem__(index, outputs[-1][0])
|
||||||
|
)
|
||||||
|
for i, block in enumerate(model.decoder.blocks)
|
||||||
|
]
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
model(mel.unsqueeze(0), tokens.unsqueeze(0))
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
hook.remove()
|
||||||
|
|
||||||
|
heatmaps = {}
|
||||||
|
for layer_idx, tensor in enumerate(QKs):
|
||||||
|
if tensor is None or layer_idx not in target_map:
|
||||||
|
continue
|
||||||
|
tensor = tensor[:, :, : mel.shape[-1] // 2]
|
||||||
|
tensor = tensor.softmax(dim=-1).cpu()
|
||||||
|
for head_idx in target_map[layer_idx]:
|
||||||
|
heatmaps[(layer_idx, head_idx)] = tensor[head_idx]
|
||||||
|
|
||||||
|
return heatmaps
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_heatmaps(
|
||||||
|
heads, heatmaps, output_path):
|
||||||
|
cols = min(3, len(heads))
|
||||||
|
rows = math.ceil(len(heads) / cols)
|
||||||
|
fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 3.2 * rows), squeeze=False)
|
||||||
|
|
||||||
|
for idx, (layer, head, score) in enumerate(heads):
|
||||||
|
ax = axes[idx // cols][idx % cols]
|
||||||
|
mat = heatmaps.get((layer, head))
|
||||||
|
if mat is None:
|
||||||
|
ax.axis("off")
|
||||||
|
continue
|
||||||
|
im = ax.imshow(mat.to(torch.float32).numpy(), aspect="auto", origin="lower")
|
||||||
|
ax.set_title(f"L{layer} H{head} · score {score:.2f}")
|
||||||
|
ax.set_xlabel("time")
|
||||||
|
ax.set_ylabel("tokens")
|
||||||
|
|
||||||
|
for j in range(len(heads), rows * cols):
|
||||||
|
axes[j // cols][j % cols].axis("off")
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(output_path, dpi=200)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_mask(mask: torch.Tensor, output_path: str):
|
||||||
|
payload = mask.numpy().astype(np.bool_)
|
||||||
|
blob = base64.b85encode(gzip.compress(payload.tobytes()))
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(blob)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = _parse_args()
|
||||||
|
model = load_model(args.model, device=args.device)
|
||||||
|
model.eval()
|
||||||
|
tokenizer = get_tokenizer(multilingual=model.is_multilingual)
|
||||||
|
clips = load_clips(args)
|
||||||
|
|
||||||
|
votes, strengths = collect_heads(model, tokenizer, clips, args.threshold)
|
||||||
|
# selection = votes > 0.5
|
||||||
|
selection = strengths > 0.05
|
||||||
|
_dump_mask(selection.cpu(), args.output)
|
||||||
|
|
||||||
|
viz_heads = _select_heads_for_visualization(selection, strengths, args.visualize_top_k)
|
||||||
|
heatmaps = _extract_heatmaps(model, tokenizer, clips[0], viz_heads)
|
||||||
|
_plot_heatmaps(viz_heads, heatmaps, "alignment_heads.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
39
scripts/sync_extension.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Copy core files from web directory to Chrome extension directory."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def sync_extension_files():
|
||||||
|
|
||||||
|
web_dir = Path("whisperlivekit/web")
|
||||||
|
extension_dir = Path("chrome-extension")
|
||||||
|
|
||||||
|
files_to_sync = [
|
||||||
|
"live_transcription.html", "live_transcription.js", "live_transcription.css"
|
||||||
|
]
|
||||||
|
|
||||||
|
svg_files = [
|
||||||
|
"system_mode.svg",
|
||||||
|
"light_mode.svg",
|
||||||
|
"dark_mode.svg",
|
||||||
|
"settings.svg"
|
||||||
|
]
|
||||||
|
|
||||||
|
for file in files_to_sync:
|
||||||
|
src_path = web_dir / file
|
||||||
|
dest_path = extension_dir / file
|
||||||
|
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src_path, dest_path)
|
||||||
|
|
||||||
|
for svg_file in svg_files:
|
||||||
|
src_path = web_dir / "src" / svg_file
|
||||||
|
dest_path = extension_dir / "web" / "src" / svg_file
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src_path, dest_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
sync_extension_files()
|
||||||
54
setup.py
@@ -1,54 +0,0 @@
|
|||||||
from setuptools import setup, find_packages
|
|
||||||
setup(
|
|
||||||
name="whisperlivekit",
|
|
||||||
version="0.2.4.dev0",
|
|
||||||
description="Real-time, Fully Local Whisper's Speech-to-Text and Speaker Diarization",
|
|
||||||
long_description=open("README.md", "r", encoding="utf-8").read(),
|
|
||||||
long_description_content_type="text/markdown",
|
|
||||||
author="Quentin Fuxa",
|
|
||||||
url="https://github.com/QuentinFuxa/WhisperLiveKit",
|
|
||||||
packages=find_packages(),
|
|
||||||
install_requires=[
|
|
||||||
"fastapi",
|
|
||||||
"librosa",
|
|
||||||
"soundfile",
|
|
||||||
"faster-whisper",
|
|
||||||
"uvicorn",
|
|
||||||
"websockets",
|
|
||||||
],
|
|
||||||
extras_require={
|
|
||||||
"diarization": ["diart"],
|
|
||||||
"vac": ["torch"],
|
|
||||||
"sentence": ["mosestokenizer", "wtpsplit"],
|
|
||||||
"whisper": ["whisper"],
|
|
||||||
"whisper-timestamped": ["whisper-timestamped"],
|
|
||||||
"mlx-whisper": ["mlx-whisper"],
|
|
||||||
"openai": ["openai"],
|
|
||||||
"simulstreaming": [
|
|
||||||
"torch",
|
|
||||||
"tqdm",
|
|
||||||
"tiktoken",
|
|
||||||
"numpy<2.0.0",
|
|
||||||
"triton>=2.0.0,<3;platform_machine==\"x86_64\" and sys_platform==\"linux\" or sys_platform==\"linux2\"",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
package_data={
|
|
||||||
'whisperlivekit': ['web/*.html'],
|
|
||||||
'whisperlivekit.simul_whisper.whisper.assets': ['*.tiktoken', '*.npz'],
|
|
||||||
},
|
|
||||||
entry_points={
|
|
||||||
'console_scripts': [
|
|
||||||
'whisperlivekit-server=whisperlivekit.basic_server:main',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
classifiers=[
|
|
||||||
"Development Status :: 4 - Beta",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
||||||
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
||||||
],
|
|
||||||
python_requires=">=3.9",
|
|
||||||
)
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
from .download_simulstreaming_backend import download_simulstreaming_backend
|
|
||||||
from .audio_processor import AudioProcessor
|
from .audio_processor import AudioProcessor
|
||||||
from .core import TranscriptionEngine
|
from .core import TranscriptionEngine
|
||||||
from .parse_args import parse_args
|
from .parse_args import parse_args
|
||||||
from .web.web_interface import get_web_interface_html
|
from .web.web_interface import get_web_interface_html, get_inline_ui_html
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TranscriptionEngine",
|
"TranscriptionEngine",
|
||||||
"AudioProcessor",
|
"AudioProcessor",
|
||||||
"parse_args",
|
"parse_args",
|
||||||
"get_web_interface_html",
|
"get_web_interface_html",
|
||||||
|
"get_inline_ui_html",
|
||||||
"download_simulstreaming_backend",
|
"download_simulstreaming_backend",
|
||||||
]
|
]
|
||||||
|
|||||||
41
whisperlivekit/backend_support.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def module_available(module_name):
|
||||||
|
"""Return True if the given module can be imported."""
|
||||||
|
return importlib.util.find_spec(module_name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def mlx_backend_available(warn_on_missing = False):
|
||||||
|
is_macos = platform.system() == "Darwin"
|
||||||
|
is_arm = platform.machine() == "arm64"
|
||||||
|
available = (
|
||||||
|
is_macos
|
||||||
|
and is_arm
|
||||||
|
and module_available("mlx_whisper")
|
||||||
|
)
|
||||||
|
if not available and warn_on_missing and is_macos and is_arm:
|
||||||
|
logger.warning(
|
||||||
|
"=" * 50
|
||||||
|
+ "\nMLX Whisper not found but you are on Apple Silicon. "
|
||||||
|
"Consider installing mlx-whisper for better performance: "
|
||||||
|
"`pip install mlx-whisper`\n"
|
||||||
|
+ "=" * 50
|
||||||
|
)
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def faster_backend_available(warn_on_missing = False):
|
||||||
|
available = module_available("faster_whisper")
|
||||||
|
if not available and warn_on_missing and platform.system() != "Darwin":
|
||||||
|
logger.warning(
|
||||||
|
"=" * 50
|
||||||
|
+ "\nFaster-Whisper not found. Consider installing faster-whisper "
|
||||||
|
"for better performance: `pip install faster-whisper`\n"
|
||||||
|
+ "=" * 50
|
||||||
|
)
|
||||||
|
return available
|
||||||
@@ -2,7 +2,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from whisperlivekit import TranscriptionEngine, AudioProcessor, get_web_interface_html, parse_args
|
from whisperlivekit import TranscriptionEngine, AudioProcessor, get_inline_ui_html, parse_args
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ args = parse_args()
|
|||||||
transcription_engine = None
|
transcription_engine = None
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global transcription_engine
|
global transcription_engine
|
||||||
transcription_engine = TranscriptionEngine(
|
transcription_engine = TranscriptionEngine(
|
||||||
**vars(args),
|
**vars(args),
|
||||||
@@ -33,21 +33,21 @@ app.add_middleware(
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def get():
|
async def get():
|
||||||
return HTMLResponse(get_web_interface_html())
|
return HTMLResponse(get_inline_ui_html())
|
||||||
|
|
||||||
|
|
||||||
async def handle_websocket_results(websocket, results_generator):
|
async def handle_websocket_results(websocket, results_generator):
|
||||||
"""Consumes results from the audio processor and sends them via WebSocket."""
|
"""Consumes results from the audio processor and sends them via WebSocket."""
|
||||||
try:
|
try:
|
||||||
async for response in results_generator:
|
async for response in results_generator:
|
||||||
await websocket.send_json(response)
|
await websocket.send_json(response.to_dict())
|
||||||
# when the results_generator finishes it means all audio has been processed
|
# when the results_generator finishes it means all audio has been processed
|
||||||
logger.info("Results generator finished. Sending 'ready_to_stop' to client.")
|
logger.info("Results generator finished. Sending 'ready_to_stop' to client.")
|
||||||
await websocket.send_json({"type": "ready_to_stop"})
|
await websocket.send_json({"type": "ready_to_stop"})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info("WebSocket disconnected while handling results (client likely closed connection).")
|
logger.info("WebSocket disconnected while handling results (client likely closed connection).")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error in WebSocket results handler: {e}")
|
logger.exception(f"Error in WebSocket results handler: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/asr")
|
@app.websocket("/asr")
|
||||||
@@ -58,6 +58,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
)
|
)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
logger.info("WebSocket connection opened.")
|
logger.info("WebSocket connection opened.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "config", "useAudioWorklet": bool(args.pcm_input)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send config to client: {e}")
|
||||||
|
|
||||||
results_generator = await audio_processor.create_tasks()
|
results_generator = await audio_processor.create_tasks()
|
||||||
websocket_task = asyncio.create_task(handle_websocket_results(websocket, results_generator))
|
websocket_task = asyncio.create_task(handle_websocket_results(websocket, results_generator))
|
||||||
@@ -113,6 +118,8 @@ def main():
|
|||||||
|
|
||||||
if ssl_kwargs:
|
if ssl_kwargs:
|
||||||
uvicorn_kwargs = {**uvicorn_kwargs, **ssl_kwargs}
|
uvicorn_kwargs = {**uvicorn_kwargs, **ssl_kwargs}
|
||||||
|
if args.forwarded_allow_ips:
|
||||||
|
uvicorn_kwargs = { **uvicorn_kwargs, "forwarded_allow_ips" : args.forwarded_allow_ips }
|
||||||
|
|
||||||
uvicorn.run(**uvicorn_kwargs)
|
uvicorn.run(**uvicorn_kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
try:
|
from whisperlivekit.local_agreement.whisper_online import backend_factory
|
||||||
from whisperlivekit.whisper_streaming_custom.whisper_online import backend_factory, warmup_asr
|
from whisperlivekit.simul_whisper import SimulStreamingASR
|
||||||
except ImportError:
|
from whisperlivekit.local_agreement.online_asr import OnlineASRProcessor
|
||||||
from .whisper_streaming_custom.whisper_online import backend_factory, warmup_asr
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
def update_with_kwargs(_dict, kwargs):
|
||||||
|
_dict.update({
|
||||||
|
k: v for k, v in kwargs.items() if k in _dict
|
||||||
|
})
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class TranscriptionEngine:
|
class TranscriptionEngine:
|
||||||
_instance = None
|
_instance = None
|
||||||
@@ -18,75 +27,169 @@ class TranscriptionEngine:
|
|||||||
if TranscriptionEngine._initialized:
|
if TranscriptionEngine._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
defaults = {
|
global_params = {
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"warmup_file": None,
|
|
||||||
"confidence_validation": False,
|
|
||||||
"diarization": False,
|
"diarization": False,
|
||||||
"punctuation_split": False,
|
"punctuation_split": False,
|
||||||
"min_chunk_size": 0.5,
|
"target_language": "",
|
||||||
"model": "tiny",
|
"vac": True,
|
||||||
"model_cache_dir": None,
|
"vac_onnx": False,
|
||||||
"model_dir": None,
|
|
||||||
"lan": "auto",
|
|
||||||
"task": "transcribe",
|
|
||||||
"backend": "faster-whisper",
|
|
||||||
"vac": False,
|
|
||||||
"vac_chunk_size": 0.04,
|
"vac_chunk_size": 0.04,
|
||||||
"buffer_trimming": "segment",
|
|
||||||
"buffer_trimming_sec": 15,
|
|
||||||
"log_level": "DEBUG",
|
"log_level": "DEBUG",
|
||||||
"ssl_certfile": None,
|
"ssl_certfile": None,
|
||||||
"ssl_keyfile": None,
|
"ssl_keyfile": None,
|
||||||
|
"forwarded_allow_ips": None,
|
||||||
"transcription": True,
|
"transcription": True,
|
||||||
"vad": True,
|
"vad": True,
|
||||||
"segmentation_model": "pyannote/segmentation-3.0",
|
"pcm_input": False,
|
||||||
"embedding_model": "pyannote/embedding",
|
"disable_punctuation_split" : False,
|
||||||
# simulstreaming params:
|
"diarization_backend": "sortformer",
|
||||||
"frame_threshold": 25,
|
"backend_policy": "simulstreaming",
|
||||||
"beams": 1,
|
"backend": "auto",
|
||||||
"decoder_type": None,
|
|
||||||
"audio_max_len": 30.0,
|
|
||||||
"audio_min_len": 0.0,
|
|
||||||
"cif_ckpt_path": None,
|
|
||||||
"never_fire": False,
|
|
||||||
"init_prompt": None,
|
|
||||||
"static_init_prompt": None,
|
|
||||||
"max_context_tokens": None,
|
|
||||||
"model_path": './base.pt',
|
|
||||||
}
|
}
|
||||||
|
global_params = update_with_kwargs(global_params, kwargs)
|
||||||
|
|
||||||
config_dict = {**defaults, **kwargs}
|
transcription_common_params = {
|
||||||
|
"warmup_file": None,
|
||||||
|
"min_chunk_size": 0.1,
|
||||||
|
"model_size": "base",
|
||||||
|
"model_cache_dir": None,
|
||||||
|
"model_dir": None,
|
||||||
|
"model_path": None,
|
||||||
|
"lan": "auto",
|
||||||
|
"direct_english_translation": False,
|
||||||
|
}
|
||||||
|
transcription_common_params = update_with_kwargs(transcription_common_params, kwargs)
|
||||||
|
|
||||||
|
if transcription_common_params['model_size'].endswith(".en"):
|
||||||
|
transcription_common_params["lan"] = "en"
|
||||||
if 'no_transcription' in kwargs:
|
if 'no_transcription' in kwargs:
|
||||||
config_dict['transcription'] = not kwargs['no_transcription']
|
global_params['transcription'] = not global_params['no_transcription']
|
||||||
if 'no_vad' in kwargs:
|
if 'no_vad' in kwargs:
|
||||||
config_dict['vad'] = not kwargs['no_vad']
|
global_params['vad'] = not kwargs['no_vad']
|
||||||
|
if 'no_vac' in kwargs:
|
||||||
config_dict.pop('no_transcription', None)
|
global_params['vac'] = not kwargs['no_vac']
|
||||||
config_dict.pop('no_vad', None)
|
|
||||||
|
|
||||||
if 'language' in kwargs:
|
self.args = Namespace(**{**global_params, **transcription_common_params})
|
||||||
config_dict['lan'] = kwargs['language']
|
|
||||||
config_dict.pop('language', None)
|
|
||||||
|
|
||||||
self.args = Namespace(**config_dict)
|
|
||||||
|
|
||||||
self.asr = None
|
self.asr = None
|
||||||
self.tokenizer = None
|
self.tokenizer = None
|
||||||
self.diarization = None
|
self.diarization = None
|
||||||
|
self.vac_model = None
|
||||||
|
|
||||||
|
if self.args.vac:
|
||||||
|
from whisperlivekit.silero_vad_iterator import load_silero_vad
|
||||||
|
# Use ONNX if specified, otherwise use JIT (default)
|
||||||
|
use_onnx = kwargs.get('vac_onnx', False)
|
||||||
|
self.vac_model = load_silero_vad(onnx=use_onnx)
|
||||||
|
|
||||||
|
backend_policy = self.args.backend_policy
|
||||||
if self.args.transcription:
|
if self.args.transcription:
|
||||||
self.asr, self.tokenizer = backend_factory(self.args)
|
if backend_policy == "simulstreaming":
|
||||||
warmup_asr(self.asr, self.args.warmup_file)
|
simulstreaming_params = {
|
||||||
|
"disable_fast_encoder": False,
|
||||||
|
"custom_alignment_heads": None,
|
||||||
|
"frame_threshold": 25,
|
||||||
|
"beams": 1,
|
||||||
|
"decoder_type": None,
|
||||||
|
"audio_max_len": 20.0,
|
||||||
|
"audio_min_len": 0.0,
|
||||||
|
"cif_ckpt_path": None,
|
||||||
|
"never_fire": False,
|
||||||
|
"init_prompt": None,
|
||||||
|
"static_init_prompt": None,
|
||||||
|
"max_context_tokens": None,
|
||||||
|
"preload_model_count": 1,
|
||||||
|
}
|
||||||
|
simulstreaming_params = update_with_kwargs(simulstreaming_params, kwargs)
|
||||||
|
|
||||||
|
self.tokenizer = None
|
||||||
|
self.asr = SimulStreamingASR(
|
||||||
|
**transcription_common_params,
|
||||||
|
**simulstreaming_params,
|
||||||
|
backend=self.args.backend,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Using SimulStreaming policy with %s backend",
|
||||||
|
getattr(self.asr, "encoder_backend", "whisper"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
|
||||||
|
whisperstreaming_params = {
|
||||||
|
"buffer_trimming": "segment",
|
||||||
|
"confidence_validation": False,
|
||||||
|
"buffer_trimming_sec": 15,
|
||||||
|
}
|
||||||
|
whisperstreaming_params = update_with_kwargs(whisperstreaming_params, kwargs)
|
||||||
|
|
||||||
|
self.asr = backend_factory(
|
||||||
|
backend=self.args.backend,
|
||||||
|
**transcription_common_params,
|
||||||
|
**whisperstreaming_params,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Using LocalAgreement policy with %s backend",
|
||||||
|
getattr(self.asr, "backend_choice", self.asr.__class__.__name__),
|
||||||
|
)
|
||||||
|
|
||||||
if self.args.diarization:
|
if self.args.diarization:
|
||||||
from whisperlivekit.diarization.diarization_online import DiartDiarization
|
if self.args.diarization_backend == "diart":
|
||||||
self.diarization = DiartDiarization(
|
from whisperlivekit.diarization.diart_backend import DiartDiarization
|
||||||
block_duration=self.args.min_chunk_size,
|
diart_params = {
|
||||||
segmentation_model_name=self.args.segmentation_model,
|
"segmentation_model": "pyannote/segmentation-3.0",
|
||||||
embedding_model_name=self.args.embedding_model
|
"embedding_model": "pyannote/embedding",
|
||||||
)
|
}
|
||||||
|
diart_params = update_with_kwargs(diart_params, kwargs)
|
||||||
|
self.diarization_model = DiartDiarization(
|
||||||
|
block_duration=self.args.min_chunk_size,
|
||||||
|
**diart_params
|
||||||
|
)
|
||||||
|
elif self.args.diarization_backend == "sortformer":
|
||||||
|
from whisperlivekit.diarization.sortformer_backend import SortformerDiarization
|
||||||
|
self.diarization_model = SortformerDiarization()
|
||||||
|
|
||||||
|
self.translation_model = None
|
||||||
|
if self.args.target_language:
|
||||||
|
if self.args.lan == 'auto' and backend_policy != "simulstreaming":
|
||||||
|
raise Exception('Translation cannot be set with language auto when transcription backend is not simulstreaming')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from nllw import load_model
|
||||||
|
except:
|
||||||
|
raise Exception('To use translation, you must install nllw: `pip install nllw`')
|
||||||
|
translation_params = {
|
||||||
|
"nllb_backend": "transformers",
|
||||||
|
"nllb_size": "600M"
|
||||||
|
}
|
||||||
|
translation_params = update_with_kwargs(translation_params, kwargs)
|
||||||
|
self.translation_model = load_model([self.args.lan], **translation_params) #in the future we want to handle different languages for different speakers
|
||||||
TranscriptionEngine._initialized = True
|
TranscriptionEngine._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def online_factory(args, asr):
|
||||||
|
if args.backend_policy == "simulstreaming":
|
||||||
|
from whisperlivekit.simul_whisper import SimulStreamingOnlineProcessor
|
||||||
|
online = SimulStreamingOnlineProcessor(asr)
|
||||||
|
else:
|
||||||
|
online = OnlineASRProcessor(asr)
|
||||||
|
return online
|
||||||
|
|
||||||
|
|
||||||
|
def online_diarization_factory(args, diarization_backend):
|
||||||
|
if args.diarization_backend == "diart":
|
||||||
|
online = diarization_backend
|
||||||
|
# Not the best here, since several user/instances will share the same backend, but diart is not SOTA anymore and sortformer is recommended
|
||||||
|
|
||||||
|
if args.diarization_backend == "sortformer":
|
||||||
|
from whisperlivekit.diarization.sortformer_backend import SortformerDiarizationOnline
|
||||||
|
online = SortformerDiarizationOnline(shared_model=diarization_backend)
|
||||||
|
return online
|
||||||
|
|
||||||
|
|
||||||
|
def online_translation_factory(args, translation_model):
|
||||||
|
#should be at speaker level in the future:
|
||||||
|
#one shared nllb model for all speaker
|
||||||
|
#one tokenizer per speaker/language
|
||||||
|
from nllw import OnlineTranslation
|
||||||
|
return OnlineTranslation(translation_model, [args.lan], [args.target_language])
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ class DiarizationObserver(Observer):
|
|||||||
"""Observer that logs all data emitted by the diarization pipeline and stores speaker segments."""
|
"""Observer that logs all data emitted by the diarization pipeline and stores speaker segments."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.speaker_segments = []
|
self.diarization_segments = []
|
||||||
self.processed_time = 0
|
self.processed_time = 0
|
||||||
self.segment_lock = threading.Lock()
|
self.segment_lock = threading.Lock()
|
||||||
|
self.global_time_offset = 0.0
|
||||||
|
|
||||||
def on_next(self, value: Tuple[Annotation, Any]):
|
def on_next(self, value: Tuple[Annotation, Any]):
|
||||||
annotation, audio = value
|
annotation, audio = value
|
||||||
@@ -47,10 +48,10 @@ class DiarizationObserver(Observer):
|
|||||||
for speaker, label in annotation._labels.items():
|
for speaker, label in annotation._labels.items():
|
||||||
for start, end in zip(label.segments_boundaries_[:-1], label.segments_boundaries_[1:]):
|
for start, end in zip(label.segments_boundaries_[:-1], label.segments_boundaries_[1:]):
|
||||||
print(f" {speaker}: {start:.2f}s-{end:.2f}s")
|
print(f" {speaker}: {start:.2f}s-{end:.2f}s")
|
||||||
self.speaker_segments.append(SpeakerSegment(
|
self.diarization_segments.append(SpeakerSegment(
|
||||||
speaker=speaker,
|
speaker=speaker,
|
||||||
start=start,
|
start=start + self.global_time_offset,
|
||||||
end=end
|
end=end + self.global_time_offset
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
logger.debug("\nNo speakers detected in this segment")
|
logger.debug("\nNo speakers detected in this segment")
|
||||||
@@ -58,14 +59,14 @@ class DiarizationObserver(Observer):
|
|||||||
def get_segments(self) -> List[SpeakerSegment]:
|
def get_segments(self) -> List[SpeakerSegment]:
|
||||||
"""Get a copy of the current speaker segments."""
|
"""Get a copy of the current speaker segments."""
|
||||||
with self.segment_lock:
|
with self.segment_lock:
|
||||||
return self.speaker_segments.copy()
|
return self.diarization_segments.copy()
|
||||||
|
|
||||||
def clear_old_segments(self, older_than: float = 30.0):
|
def clear_old_segments(self, older_than: float = 30.0):
|
||||||
"""Clear segments older than the specified time."""
|
"""Clear segments older than the specified time."""
|
||||||
with self.segment_lock:
|
with self.segment_lock:
|
||||||
current_time = self.processed_time
|
current_time = self.processed_time
|
||||||
self.speaker_segments = [
|
self.diarization_segments = [
|
||||||
segment for segment in self.speaker_segments
|
segment for segment in self.diarization_segments
|
||||||
if current_time - segment.end < older_than
|
if current_time - segment.end < older_than
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -177,7 +178,6 @@ class DiartDiarization:
|
|||||||
|
|
||||||
self.pipeline = SpeakerDiarization(config=config)
|
self.pipeline = SpeakerDiarization(config=config)
|
||||||
self.observer = DiarizationObserver()
|
self.observer = DiarizationObserver()
|
||||||
self.lag_diart = None
|
|
||||||
|
|
||||||
if use_microphone:
|
if use_microphone:
|
||||||
self.source = MicrophoneAudioSource(block_duration=block_duration)
|
self.source = MicrophoneAudioSource(block_duration=block_duration)
|
||||||
@@ -199,6 +199,9 @@ class DiartDiarization:
|
|||||||
self.inference.attach_observers(self.observer)
|
self.inference.attach_observers(self.observer)
|
||||||
asyncio.get_event_loop().run_in_executor(None, self.inference)
|
asyncio.get_event_loop().run_in_executor(None, self.inference)
|
||||||
|
|
||||||
|
def insert_silence(self, silence_duration):
|
||||||
|
self.observer.global_time_offset += silence_duration
|
||||||
|
|
||||||
async def diarize(self, pcm_array: np.ndarray):
|
async def diarize(self, pcm_array: np.ndarray):
|
||||||
"""
|
"""
|
||||||
Process audio data for diarization.
|
Process audio data for diarization.
|
||||||
@@ -213,32 +216,6 @@ class DiartDiarization:
|
|||||||
if self.custom_source:
|
if self.custom_source:
|
||||||
self.custom_source.close()
|
self.custom_source.close()
|
||||||
|
|
||||||
def assign_speakers_to_tokens(self, tokens: list, use_punctuation_split: bool = False) -> float:
|
|
||||||
"""
|
|
||||||
Assign speakers to tokens based on timing overlap with speaker segments.
|
|
||||||
Uses the segments collected by the observer.
|
|
||||||
|
|
||||||
If use_punctuation_split is True, uses punctuation marks to refine speaker boundaries.
|
|
||||||
"""
|
|
||||||
segments = self.observer.get_segments()
|
|
||||||
|
|
||||||
# Debug logging
|
|
||||||
logger.debug(f"assign_speakers_to_tokens called with {len(tokens)} tokens")
|
|
||||||
logger.debug(f"Available segments: {len(segments)}")
|
|
||||||
for i, seg in enumerate(segments[:5]): # Show first 5 segments
|
|
||||||
logger.debug(f" Segment {i}: {seg.speaker} [{seg.start:.2f}-{seg.end:.2f}]")
|
|
||||||
|
|
||||||
if not self.lag_diart and segments and tokens:
|
|
||||||
self.lag_diart = segments[0].start - tokens[0].start
|
|
||||||
|
|
||||||
if not use_punctuation_split:
|
|
||||||
for token in tokens:
|
|
||||||
for segment in segments:
|
|
||||||
if not (segment.end <= token.start + self.lag_diart or segment.start >= token.end + self.lag_diart):
|
|
||||||
token.speaker = extract_number(segment.speaker) + 1
|
|
||||||
else:
|
|
||||||
tokens = add_speaker_to_tokens(segments, tokens)
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
def concatenate_speakers(segments):
|
def concatenate_speakers(segments):
|
||||||
segments_concatenated = [{"speaker": 1, "begin": 0.0, "end": 0.0}]
|
segments_concatenated = [{"speaker": 1, "begin": 0.0, "end": 0.0}]
|
||||||
329
whisperlivekit/diarization/sortformer_backend.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from typing import List, Optional
|
||||||
|
from queue import SimpleQueue, Empty
|
||||||
|
|
||||||
|
from whisperlivekit.timed_objects import SpeakerSegment
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nemo.collections.asr.models import SortformerEncLabelModel
|
||||||
|
from nemo.collections.asr.modules import AudioToMelSpectrogramPreprocessor
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit("""Please use `pip install "git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]"` to use the Sortformer diarization""")
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingSortformerState:
|
||||||
|
"""
|
||||||
|
This class creates a class instance that will be used to store the state of the
|
||||||
|
streaming Sortformer model.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
spkcache (torch.Tensor): Speaker cache to store embeddings from start
|
||||||
|
spkcache_lengths (torch.Tensor): Lengths of the speaker cache
|
||||||
|
spkcache_preds (torch.Tensor): The speaker predictions for the speaker cache parts
|
||||||
|
fifo (torch.Tensor): FIFO queue to save the embedding from the latest chunks
|
||||||
|
fifo_lengths (torch.Tensor): Lengths of the FIFO queue
|
||||||
|
fifo_preds (torch.Tensor): The speaker predictions for the FIFO queue parts
|
||||||
|
spk_perm (torch.Tensor): Speaker permutation information for the speaker cache
|
||||||
|
mean_sil_emb (torch.Tensor): Mean silence embedding
|
||||||
|
n_sil_frames (torch.Tensor): Number of silence frames
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.spkcache = None # Speaker cache to store embeddings from start
|
||||||
|
self.spkcache_lengths = None
|
||||||
|
self.spkcache_preds = None # speaker cache predictions
|
||||||
|
self.fifo = None # to save the embedding from the latest chunks
|
||||||
|
self.fifo_lengths = None
|
||||||
|
self.fifo_preds = None
|
||||||
|
self.spk_perm = None
|
||||||
|
self.mean_sil_emb = None
|
||||||
|
self.n_sil_frames = None
|
||||||
|
|
||||||
|
|
||||||
|
class SortformerDiarization:
|
||||||
|
def __init__(self, model_name: str = "nvidia/diar_streaming_sortformer_4spk-v2"):
|
||||||
|
"""
|
||||||
|
Stores the shared streaming Sortformer diarization model. Used when a new online_diarization is initialized.
|
||||||
|
"""
|
||||||
|
self._load_model(model_name)
|
||||||
|
|
||||||
|
def _load_model(self, model_name: str):
|
||||||
|
"""Load and configure the Sortformer model for streaming."""
|
||||||
|
try:
|
||||||
|
self.diar_model = SortformerEncLabelModel.from_pretrained(model_name)
|
||||||
|
self.diar_model.eval()
|
||||||
|
|
||||||
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
self.diar_model.to(device)
|
||||||
|
|
||||||
|
## to test
|
||||||
|
# for name, param in self.diar_model.named_parameters():
|
||||||
|
# if param.device != device:
|
||||||
|
# raise RuntimeError(f"Parameter {name} is on {param.device} but should be on {device}")
|
||||||
|
|
||||||
|
logger.info(f"Using {device.type.upper()} for Sortformer model")
|
||||||
|
|
||||||
|
self.diar_model.sortformer_modules.chunk_len = 10
|
||||||
|
self.diar_model.sortformer_modules.subsampling_factor = 10
|
||||||
|
self.diar_model.sortformer_modules.chunk_right_context = 0
|
||||||
|
self.diar_model.sortformer_modules.chunk_left_context = 10
|
||||||
|
self.diar_model.sortformer_modules.spkcache_len = 188
|
||||||
|
self.diar_model.sortformer_modules.fifo_len = 188
|
||||||
|
self.diar_model.sortformer_modules.spkcache_update_period = 144
|
||||||
|
self.diar_model.sortformer_modules.log = False
|
||||||
|
self.diar_model.sortformer_modules._check_streaming_parameters()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load Sortformer model: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
class SortformerDiarizationOnline:
|
||||||
|
def __init__(self, shared_model, sample_rate: int = 16000):
|
||||||
|
"""
|
||||||
|
Initialize the streaming Sortformer diarization system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sample_rate: Audio sample rate (default: 16000)
|
||||||
|
model_name: Pre-trained model name (default: "nvidia/diar_streaming_sortformer_4spk-v2")
|
||||||
|
"""
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self.diarization_segments = []
|
||||||
|
self.diar_segments = []
|
||||||
|
self.buffer_audio = np.array([], dtype=np.float32)
|
||||||
|
self.segment_lock = threading.Lock()
|
||||||
|
self.global_time_offset = 0.0
|
||||||
|
self.debug = False
|
||||||
|
|
||||||
|
self.diar_model = shared_model.diar_model
|
||||||
|
|
||||||
|
self.audio2mel = AudioToMelSpectrogramPreprocessor(
|
||||||
|
window_size=0.025,
|
||||||
|
normalize="NA",
|
||||||
|
n_fft=512,
|
||||||
|
features=128,
|
||||||
|
pad_to=0
|
||||||
|
)
|
||||||
|
self.audio2mel.to(self.diar_model.device)
|
||||||
|
|
||||||
|
self.chunk_duration_seconds = (
|
||||||
|
self.diar_model.sortformer_modules.chunk_len *
|
||||||
|
self.diar_model.sortformer_modules.subsampling_factor *
|
||||||
|
self.diar_model.preprocessor._cfg.window_stride
|
||||||
|
)
|
||||||
|
|
||||||
|
self._init_streaming_state()
|
||||||
|
|
||||||
|
self._previous_chunk_features = None
|
||||||
|
self._chunk_index = 0
|
||||||
|
self._len_prediction = None
|
||||||
|
|
||||||
|
# Audio buffer to store PCM chunks for debugging
|
||||||
|
self.audio_buffer = []
|
||||||
|
|
||||||
|
# Buffer for accumulating audio chunks until reaching chunk_duration_seconds
|
||||||
|
self.audio_chunk_buffer = []
|
||||||
|
self.accumulated_duration = 0.0
|
||||||
|
|
||||||
|
logger.info("SortformerDiarization initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def _init_streaming_state(self):
|
||||||
|
"""Initialize the streaming state for the model."""
|
||||||
|
batch_size = 1
|
||||||
|
device = self.diar_model.device
|
||||||
|
|
||||||
|
self.streaming_state = StreamingSortformerState()
|
||||||
|
self.streaming_state.spkcache = torch.zeros(
|
||||||
|
(batch_size, self.diar_model.sortformer_modules.spkcache_len, self.diar_model.sortformer_modules.fc_d_model),
|
||||||
|
device=device
|
||||||
|
)
|
||||||
|
self.streaming_state.spkcache_preds = torch.zeros(
|
||||||
|
(batch_size, self.diar_model.sortformer_modules.spkcache_len, self.diar_model.sortformer_modules.n_spk),
|
||||||
|
device=device
|
||||||
|
)
|
||||||
|
self.streaming_state.spkcache_lengths = torch.zeros((batch_size,), dtype=torch.long, device=device)
|
||||||
|
self.streaming_state.fifo = torch.zeros(
|
||||||
|
(batch_size, self.diar_model.sortformer_modules.fifo_len, self.diar_model.sortformer_modules.fc_d_model),
|
||||||
|
device=device
|
||||||
|
)
|
||||||
|
self.streaming_state.fifo_lengths = torch.zeros((batch_size,), dtype=torch.long, device=device)
|
||||||
|
self.streaming_state.mean_sil_emb = torch.zeros((batch_size, self.diar_model.sortformer_modules.fc_d_model), device=device)
|
||||||
|
self.streaming_state.n_sil_frames = torch.zeros((batch_size,), dtype=torch.long, device=device)
|
||||||
|
self.total_preds = torch.zeros((batch_size, 0, self.diar_model.sortformer_modules.n_spk), device=device)
|
||||||
|
|
||||||
|
def insert_silence(self, silence_duration: Optional[float]):
|
||||||
|
"""
|
||||||
|
Insert silence period by adjusting the global time offset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silence_duration: Duration of silence in seconds
|
||||||
|
"""
|
||||||
|
with self.segment_lock:
|
||||||
|
self.global_time_offset += silence_duration
|
||||||
|
logger.debug(f"Inserted silence of {silence_duration:.2f}s, new offset: {self.global_time_offset:.2f}s")
|
||||||
|
|
||||||
|
def insert_audio_chunk(self, pcm_array: np.ndarray):
|
||||||
|
if self.debug:
|
||||||
|
self.audio_buffer.append(pcm_array.copy())
|
||||||
|
self.buffer_audio = np.concatenate([self.buffer_audio, pcm_array.copy()])
|
||||||
|
|
||||||
|
|
||||||
|
async def diarize(self):
|
||||||
|
"""
|
||||||
|
Process audio data for diarization in streaming fashion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pcm_array: Audio data as numpy array
|
||||||
|
"""
|
||||||
|
|
||||||
|
threshold = int(self.chunk_duration_seconds * self.sample_rate)
|
||||||
|
|
||||||
|
if not len(self.buffer_audio) >= threshold:
|
||||||
|
return []
|
||||||
|
|
||||||
|
audio = self.buffer_audio[:threshold]
|
||||||
|
self.buffer_audio = self.buffer_audio[threshold:]
|
||||||
|
|
||||||
|
device = self.diar_model.device
|
||||||
|
audio_signal_chunk = torch.tensor(audio, device=device).unsqueeze(0)
|
||||||
|
audio_signal_length_chunk = torch.tensor([audio_signal_chunk.shape[1]], device=device)
|
||||||
|
|
||||||
|
processed_signal_chunk, processed_signal_length_chunk = self.audio2mel.get_features(
|
||||||
|
audio_signal_chunk, audio_signal_length_chunk
|
||||||
|
)
|
||||||
|
processed_signal_chunk = processed_signal_chunk.to(device)
|
||||||
|
processed_signal_length_chunk = processed_signal_length_chunk.to(device)
|
||||||
|
|
||||||
|
if self._previous_chunk_features is not None:
|
||||||
|
to_add = self._previous_chunk_features[:, :, -99:].to(device)
|
||||||
|
total_features = torch.concat([to_add, processed_signal_chunk], dim=2).to(device)
|
||||||
|
else:
|
||||||
|
total_features = processed_signal_chunk.to(device)
|
||||||
|
|
||||||
|
self._previous_chunk_features = processed_signal_chunk.to(device)
|
||||||
|
|
||||||
|
chunk_feat_seq_t = torch.transpose(total_features, 1, 2).to(device)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
left_offset = 8 if self._chunk_index > 0 else 0
|
||||||
|
right_offset = 8
|
||||||
|
|
||||||
|
self.streaming_state, self.total_preds = self.diar_model.forward_streaming_step(
|
||||||
|
processed_signal=chunk_feat_seq_t,
|
||||||
|
processed_signal_length=torch.tensor([chunk_feat_seq_t.shape[1]]).to(device),
|
||||||
|
streaming_state=self.streaming_state,
|
||||||
|
total_preds=self.total_preds,
|
||||||
|
left_offset=left_offset,
|
||||||
|
right_offset=right_offset,
|
||||||
|
)
|
||||||
|
new_segments = self._process_predictions()
|
||||||
|
|
||||||
|
self._chunk_index += 1
|
||||||
|
return new_segments
|
||||||
|
|
||||||
|
def _process_predictions(self):
|
||||||
|
"""Process model predictions and convert to speaker segments."""
|
||||||
|
preds_np = self.total_preds[0].cpu().numpy()
|
||||||
|
active_speakers = np.argmax(preds_np, axis=1)
|
||||||
|
|
||||||
|
if self._len_prediction is None:
|
||||||
|
self._len_prediction = len(active_speakers) #12
|
||||||
|
|
||||||
|
frame_duration = self.chunk_duration_seconds / self._len_prediction
|
||||||
|
current_chunk_preds = active_speakers[-self._len_prediction:]
|
||||||
|
|
||||||
|
new_segments = []
|
||||||
|
|
||||||
|
with self.segment_lock:
|
||||||
|
base_time = self._chunk_index * self.chunk_duration_seconds + self.global_time_offset
|
||||||
|
current_spk = current_chunk_preds[0]
|
||||||
|
start_time = round(base_time, 2)
|
||||||
|
for idx, spk in enumerate(current_chunk_preds):
|
||||||
|
current_time = round(base_time + idx * frame_duration, 2)
|
||||||
|
if spk != current_spk:
|
||||||
|
new_segments.append(SpeakerSegment(
|
||||||
|
speaker=current_spk,
|
||||||
|
start=start_time,
|
||||||
|
end=current_time
|
||||||
|
))
|
||||||
|
start_time = current_time
|
||||||
|
current_spk = spk
|
||||||
|
new_segments.append(
|
||||||
|
SpeakerSegment(
|
||||||
|
speaker=current_spk,
|
||||||
|
start=start_time,
|
||||||
|
end=current_time
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return new_segments
|
||||||
|
|
||||||
|
def get_segments(self) -> List[SpeakerSegment]:
|
||||||
|
"""Get a copy of the current speaker segments."""
|
||||||
|
with self.segment_lock:
|
||||||
|
return self.diarization_segments.copy()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the diarization system and clean up resources."""
|
||||||
|
logger.info("Closing SortformerDiarization")
|
||||||
|
with self.segment_lock:
|
||||||
|
self.diarization_segments.clear()
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
concatenated_audio = np.concatenate(self.audio_buffer)
|
||||||
|
audio_data_int16 = (concatenated_audio * 32767).astype(np.int16)
|
||||||
|
with wave.open("diarization_audio.wav", "wb") as wav_file:
|
||||||
|
wav_file.setnchannels(1) # mono audio
|
||||||
|
wav_file.setsampwidth(2) # 2 bytes per sample (int16)
|
||||||
|
wav_file.setframerate(self.sample_rate)
|
||||||
|
wav_file.writeframes(audio_data_int16.tobytes())
|
||||||
|
logger.info(f"Saved {len(concatenated_audio)} samples to diarization_audio.wav")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_number(s: str) -> int:
|
||||||
|
"""Extract number from speaker string (compatibility function)."""
|
||||||
|
import re
|
||||||
|
m = re.search(r'\d+', s)
|
||||||
|
return int(m.group()) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import asyncio
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""TEST ONLY."""
|
||||||
|
an4_audio = 'diarization_audio.wav'
|
||||||
|
signal, sr = librosa.load(an4_audio, sr=16000)
|
||||||
|
signal = signal[:16000*30]
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("ground truth:")
|
||||||
|
print("Speaker 0: 0:00 - 0:09")
|
||||||
|
print("Speaker 1: 0:09 - 0:19")
|
||||||
|
print("Speaker 2: 0:19 - 0:25")
|
||||||
|
print("Speaker 0: 0:25 - 0:30")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
diarization_backend = SortformerDiarization()
|
||||||
|
diarization = SortformerDiarizationOnline(shared_model = diarization_backend)
|
||||||
|
chunk_size = 1600
|
||||||
|
|
||||||
|
for i in range(0, len(signal), chunk_size):
|
||||||
|
chunk = signal[i:i+chunk_size]
|
||||||
|
new_segments = await diarization.diarize(chunk)
|
||||||
|
print(f"Processed chunk {i // chunk_size + 1}")
|
||||||
|
print(new_segments)
|
||||||
|
|
||||||
|
segments = diarization.get_segments()
|
||||||
|
print("\nDiarization results:")
|
||||||
|
for segment in segments:
|
||||||
|
print(f"Speaker {segment.speaker}: {segment.start:.2f}s - {segment.end:.2f}s")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import os
|
|
||||||
import requests
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
def get_module_path():
|
|
||||||
return os.path.dirname(inspect.getfile(inspect.currentframe()))
|
|
||||||
|
|
||||||
GITHUB_API_URL = "https://api.github.com/repos/ufal/SimulStreaming/contents/simul_whisper/whisper"
|
|
||||||
RAW_BASE_URL = "https://raw.githubusercontent.com/ufal/SimulStreaming/main/simul_whisper/whisper"
|
|
||||||
TARGET_DIR = os.path.join(get_module_path(), "simul_whisper", "whisper")
|
|
||||||
|
|
||||||
def download_files_from_github(api_url, local_dir):
|
|
||||||
os.makedirs(local_dir, exist_ok=True)
|
|
||||||
response = requests.get(api_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
items = response.json()
|
|
||||||
for item in items:
|
|
||||||
if item['type'] == 'file':
|
|
||||||
download_url = item['download_url']
|
|
||||||
file_name = item['name']
|
|
||||||
file_response = requests.get(download_url)
|
|
||||||
file_response.raise_for_status()
|
|
||||||
with open(os.path.join(local_dir, file_name), 'wb') as f:
|
|
||||||
f.write(file_response.content)
|
|
||||||
elif item['type'] == 'dir':
|
|
||||||
# Recursive call for subdirectories
|
|
||||||
download_files_from_github(item['url'], os.path.join(local_dir, item['name']))
|
|
||||||
|
|
||||||
def download_simulstreaming_backend():
|
|
||||||
print(f"Downloading files into {TARGET_DIR} ...")
|
|
||||||
download_files_from_github(GITHUB_API_URL, TARGET_DIR)
|
|
||||||
print("✅ Download of SimulStreaming backend files completed successfully.")
|
|
||||||
@@ -7,11 +7,12 @@ import contextlib
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
ERROR_INSTALL_INSTRUCTIONS = """
|
ERROR_INSTALL_INSTRUCTIONS = f"""
|
||||||
|
{'='*50}
|
||||||
FFmpeg is not installed or not found in your system's PATH.
|
FFmpeg is not installed or not found in your system's PATH.
|
||||||
Please install FFmpeg to enable audio processing.
|
Alternative Solution: You can still use WhisperLiveKit without FFmpeg by adding the --pcm-input parameter. Note that when using this option, audio will not be compressed between the frontend and backend, which may result in higher bandwidth usage.
|
||||||
|
|
||||||
Installation instructions:
|
If you want to install FFmpeg:
|
||||||
|
|
||||||
# Ubuntu/Debian:
|
# Ubuntu/Debian:
|
||||||
sudo apt update && sudo apt install ffmpeg
|
sudo apt update && sudo apt install ffmpeg
|
||||||
@@ -25,6 +26,7 @@ brew install ffmpeg
|
|||||||
# 3. Add the 'bin' directory (e.g., C:\\FFmpeg\\bin) to your system's PATH environment variable.
|
# 3. Add the 'bin' directory (e.g., C:\\FFmpeg\\bin) to your system's PATH environment variable.
|
||||||
|
|
||||||
After installation, please restart the application.
|
After installation, please restart the application.
|
||||||
|
{'='*50}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class FFmpegState(Enum):
|
class FFmpegState(Enum):
|
||||||
@@ -143,7 +145,7 @@ class FFmpegManager:
|
|||||||
try:
|
try:
|
||||||
data = await asyncio.wait_for(
|
data = await asyncio.wait_for(
|
||||||
self.process.stdout.read(size),
|
self.process.stdout.read(size),
|
||||||
timeout=5.0
|
timeout=20.0
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
@@ -183,6 +185,8 @@ class FFmpegManager:
|
|||||||
async def _drain_stderr(self):
|
async def _drain_stderr(self):
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
if not self.process or not self.process.stderr:
|
||||||
|
break
|
||||||
line = await self.process.stderr.readline()
|
line = await self.process.stderr.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
@@ -190,4 +194,4 @@ class FFmpegManager:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("FFmpeg stderr drain task cancelled.")
|
logger.info("FFmpeg stderr drain task cancelled.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error draining FFmpeg stderr: {e}")
|
logger.error(f"Error draining FFmpeg stderr: {e}")
|
||||||
|
|||||||
300
whisperlivekit/local_agreement/backends.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import io
|
||||||
|
import soundfile as sf
|
||||||
|
import math
|
||||||
|
from typing import List
|
||||||
|
import numpy as np
|
||||||
|
from whisperlivekit.timed_objects import ASRToken
|
||||||
|
from whisperlivekit.model_paths import resolve_model_path, model_path_and_type
|
||||||
|
from whisperlivekit.whisper.transcribe import transcribe as whisper_transcribe
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
class ASRBase:
|
||||||
|
sep = " " # join transcribe words with this character (" " for whisper_timestamped,
|
||||||
|
# "" for faster-whisper because it emits the spaces when needed)
|
||||||
|
|
||||||
|
def __init__(self, lan, model_size=None, cache_dir=None, model_dir=None, logfile=sys.stderr):
|
||||||
|
self.logfile = logfile
|
||||||
|
self.transcribe_kargs = {}
|
||||||
|
if lan == "auto":
|
||||||
|
self.original_language = None
|
||||||
|
else:
|
||||||
|
self.original_language = lan
|
||||||
|
self.model = self.load_model(model_size, cache_dir, model_dir)
|
||||||
|
|
||||||
|
def with_offset(self, offset: float) -> ASRToken:
|
||||||
|
# This method is kept for compatibility (typically you will use ASRToken.with_offset)
|
||||||
|
return ASRToken(self.start + offset, self.end + offset, self.text)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"ASRToken(start={self.start:.2f}, end={self.end:.2f}, text={self.text!r})"
|
||||||
|
|
||||||
|
def load_model(self, model_size, cache_dir, model_dir):
|
||||||
|
raise NotImplementedError("must be implemented in the child class")
|
||||||
|
|
||||||
|
def transcribe(self, audio, init_prompt=""):
|
||||||
|
raise NotImplementedError("must be implemented in the child class")
|
||||||
|
|
||||||
|
def use_vad(self):
|
||||||
|
raise NotImplementedError("must be implemented in the child class")
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperASR(ASRBase):
|
||||||
|
"""Uses WhisperLiveKit's built-in Whisper implementation."""
|
||||||
|
sep = " "
|
||||||
|
|
||||||
|
def load_model(self, model_size=None, cache_dir=None, model_dir=None):
|
||||||
|
from whisperlivekit.whisper import load_model as load_model
|
||||||
|
|
||||||
|
if model_dir is not None:
|
||||||
|
resolved_path = resolve_model_path(model_dir)
|
||||||
|
if resolved_path.is_dir():
|
||||||
|
pytorch_path, _, _ = model_path_and_type(resolved_path)
|
||||||
|
if pytorch_path is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No supported PyTorch checkpoint found under {resolved_path}"
|
||||||
|
)
|
||||||
|
resolved_path = pytorch_path
|
||||||
|
logger.debug(f"Loading Whisper model from custom path {resolved_path}")
|
||||||
|
return load_model(str(resolved_path))
|
||||||
|
|
||||||
|
if model_size is None:
|
||||||
|
raise ValueError("Either model_size or model_dir must be set for WhisperASR")
|
||||||
|
|
||||||
|
return load_model(model_size, download_root=cache_dir)
|
||||||
|
|
||||||
|
def transcribe(self, audio, init_prompt=""):
|
||||||
|
options = dict(self.transcribe_kargs)
|
||||||
|
options.pop("vad", None)
|
||||||
|
options.pop("vad_filter", None)
|
||||||
|
language = self.original_language if self.original_language else None
|
||||||
|
|
||||||
|
result = whisper_transcribe(
|
||||||
|
self.model,
|
||||||
|
audio,
|
||||||
|
language=language,
|
||||||
|
initial_prompt=init_prompt,
|
||||||
|
condition_on_previous_text=True,
|
||||||
|
word_timestamps=True,
|
||||||
|
**options,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def ts_words(self, r) -> List[ASRToken]:
|
||||||
|
"""
|
||||||
|
Converts the Whisper result to a list of ASRToken objects.
|
||||||
|
"""
|
||||||
|
tokens = []
|
||||||
|
for segment in r["segments"]:
|
||||||
|
for word in segment["words"]:
|
||||||
|
token = ASRToken(
|
||||||
|
word["start"],
|
||||||
|
word["end"],
|
||||||
|
word["word"],
|
||||||
|
probability=word.get("probability"),
|
||||||
|
)
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def segments_end_ts(self, res) -> List[float]:
|
||||||
|
return [segment["end"] for segment in res["segments"]]
|
||||||
|
|
||||||
|
def use_vad(self):
|
||||||
|
logger.warning("VAD is not currently supported for WhisperASR backend and will be ignored.")
|
||||||
|
|
||||||
|
class FasterWhisperASR(ASRBase):
|
||||||
|
"""Uses faster-whisper as the backend."""
|
||||||
|
sep = ""
|
||||||
|
|
||||||
|
def load_model(self, model_size=None, cache_dir=None, model_dir=None):
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
if model_dir is not None:
|
||||||
|
resolved_path = resolve_model_path(model_dir)
|
||||||
|
logger.debug(f"Loading faster-whisper model from {resolved_path}. "
|
||||||
|
f"model_size and cache_dir parameters are not used.")
|
||||||
|
model_size_or_path = str(resolved_path)
|
||||||
|
elif model_size is not None:
|
||||||
|
model_size_or_path = model_size
|
||||||
|
else:
|
||||||
|
raise ValueError("Either model_size or model_dir must be set")
|
||||||
|
device = "auto" # Allow CTranslate2 to decide available device
|
||||||
|
compute_type = "auto" # Allow CTranslate2 to decide faster compute type
|
||||||
|
|
||||||
|
|
||||||
|
model = WhisperModel(
|
||||||
|
model_size_or_path,
|
||||||
|
device=device,
|
||||||
|
compute_type=compute_type,
|
||||||
|
download_root=cache_dir,
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
def transcribe(self, audio: np.ndarray, init_prompt: str = "") -> list:
|
||||||
|
segments, info = self.model.transcribe(
|
||||||
|
audio,
|
||||||
|
language=self.original_language,
|
||||||
|
initial_prompt=init_prompt,
|
||||||
|
beam_size=5,
|
||||||
|
word_timestamps=True,
|
||||||
|
condition_on_previous_text=True,
|
||||||
|
**self.transcribe_kargs,
|
||||||
|
)
|
||||||
|
return list(segments)
|
||||||
|
|
||||||
|
def ts_words(self, segments) -> List[ASRToken]:
|
||||||
|
tokens = []
|
||||||
|
for segment in segments:
|
||||||
|
if segment.no_speech_prob > 0.9:
|
||||||
|
continue
|
||||||
|
for word in segment.words:
|
||||||
|
token = ASRToken(word.start, word.end, word.word, probability=word.probability)
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def segments_end_ts(self, segments) -> List[float]:
|
||||||
|
return [segment.end for segment in segments]
|
||||||
|
|
||||||
|
def use_vad(self):
|
||||||
|
self.transcribe_kargs["vad_filter"] = True
|
||||||
|
|
||||||
|
class MLXWhisper(ASRBase):
|
||||||
|
"""
|
||||||
|
Uses MLX Whisper optimized for Apple Silicon.
|
||||||
|
"""
|
||||||
|
sep = ""
|
||||||
|
|
||||||
|
def load_model(self, model_size=None, cache_dir=None, model_dir=None):
|
||||||
|
from mlx_whisper.transcribe import ModelHolder, transcribe
|
||||||
|
import mlx.core as mx
|
||||||
|
|
||||||
|
if model_dir is not None:
|
||||||
|
resolved_path = resolve_model_path(model_dir)
|
||||||
|
logger.debug(f"Loading MLX Whisper model from {resolved_path}. model_size parameter is not used.")
|
||||||
|
model_size_or_path = str(resolved_path)
|
||||||
|
elif model_size is not None:
|
||||||
|
model_size_or_path = self.translate_model_name(model_size)
|
||||||
|
logger.debug(f"Loading whisper model {model_size}. You use mlx whisper, so {model_size_or_path} will be used.")
|
||||||
|
else:
|
||||||
|
raise ValueError("Either model_size or model_dir must be set")
|
||||||
|
|
||||||
|
self.model_size_or_path = model_size_or_path
|
||||||
|
dtype = mx.float16
|
||||||
|
ModelHolder.get_model(model_size_or_path, dtype)
|
||||||
|
return transcribe
|
||||||
|
|
||||||
|
def translate_model_name(self, model_name):
|
||||||
|
model_mapping = {
|
||||||
|
"tiny.en": "mlx-community/whisper-tiny.en-mlx",
|
||||||
|
"tiny": "mlx-community/whisper-tiny-mlx",
|
||||||
|
"base.en": "mlx-community/whisper-base.en-mlx",
|
||||||
|
"base": "mlx-community/whisper-base-mlx",
|
||||||
|
"small.en": "mlx-community/whisper-small.en-mlx",
|
||||||
|
"small": "mlx-community/whisper-small-mlx",
|
||||||
|
"medium.en": "mlx-community/whisper-medium.en-mlx",
|
||||||
|
"medium": "mlx-community/whisper-medium-mlx",
|
||||||
|
"large-v1": "mlx-community/whisper-large-v1-mlx",
|
||||||
|
"large-v2": "mlx-community/whisper-large-v2-mlx",
|
||||||
|
"large-v3": "mlx-community/whisper-large-v3-mlx",
|
||||||
|
"large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
|
||||||
|
"large": "mlx-community/whisper-large-mlx",
|
||||||
|
}
|
||||||
|
mlx_model_path = model_mapping.get(model_name)
|
||||||
|
if mlx_model_path:
|
||||||
|
return mlx_model_path
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Model name '{model_name}' is not recognized or not supported.")
|
||||||
|
|
||||||
|
def transcribe(self, audio, init_prompt=""):
|
||||||
|
if self.transcribe_kargs:
|
||||||
|
logger.warning("Transcribe kwargs (vad, task) are not compatible with MLX Whisper and will be ignored.")
|
||||||
|
segments = self.model(
|
||||||
|
audio,
|
||||||
|
language=self.original_language,
|
||||||
|
initial_prompt=init_prompt,
|
||||||
|
word_timestamps=True,
|
||||||
|
condition_on_previous_text=True,
|
||||||
|
path_or_hf_repo=self.model_size_or_path,
|
||||||
|
)
|
||||||
|
return segments.get("segments", [])
|
||||||
|
|
||||||
|
def ts_words(self, segments) -> List[ASRToken]:
|
||||||
|
tokens = []
|
||||||
|
for segment in segments:
|
||||||
|
if segment.get("no_speech_prob", 0) > 0.9:
|
||||||
|
continue
|
||||||
|
for word in segment.get("words", []):
|
||||||
|
probability=word["probability"]
|
||||||
|
token = ASRToken(word["start"], word["end"], word["word"])
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def segments_end_ts(self, res) -> List[float]:
|
||||||
|
return [s["end"] for s in res]
|
||||||
|
|
||||||
|
def use_vad(self):
|
||||||
|
self.transcribe_kargs["vad_filter"] = True
|
||||||
|
|
||||||
|
class OpenaiApiASR(ASRBase):
|
||||||
|
"""Uses OpenAI's Whisper API for transcription."""
|
||||||
|
def __init__(self, lan=None, temperature=0, logfile=sys.stderr):
|
||||||
|
self.logfile = logfile
|
||||||
|
self.modelname = "whisper-1"
|
||||||
|
self.original_language = None if lan == "auto" else lan
|
||||||
|
self.response_format = "verbose_json"
|
||||||
|
self.temperature = temperature
|
||||||
|
self.load_model()
|
||||||
|
self.use_vad_opt = False
|
||||||
|
self.direct_english_translation = False
|
||||||
|
|
||||||
|
def load_model(self, *args, **kwargs):
|
||||||
|
from openai import OpenAI
|
||||||
|
self.client = OpenAI()
|
||||||
|
self.transcribed_seconds = 0
|
||||||
|
|
||||||
|
def ts_words(self, segments) -> List[ASRToken]:
|
||||||
|
"""
|
||||||
|
Converts OpenAI API response words into ASRToken objects while
|
||||||
|
optionally skipping words that fall into no-speech segments.
|
||||||
|
"""
|
||||||
|
no_speech_segments = []
|
||||||
|
if self.use_vad_opt:
|
||||||
|
for segment in segments.segments:
|
||||||
|
if segment.no_speech_prob > 0.8:
|
||||||
|
no_speech_segments.append((segment.start, segment.end))
|
||||||
|
tokens = []
|
||||||
|
for word in segments.words:
|
||||||
|
start = word.start
|
||||||
|
end = word.end
|
||||||
|
if any(s[0] <= start <= s[1] for s in no_speech_segments):
|
||||||
|
continue
|
||||||
|
tokens.append(ASRToken(start, end, word.word))
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def segments_end_ts(self, res) -> List[float]:
|
||||||
|
return [s.end for s in res.words]
|
||||||
|
|
||||||
|
def transcribe(self, audio_data, prompt=None, *args, **kwargs):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
buffer.name = "temp.wav"
|
||||||
|
sf.write(buffer, audio_data, samplerate=16000, format="WAV", subtype="PCM_16")
|
||||||
|
buffer.seek(0)
|
||||||
|
self.transcribed_seconds += math.ceil(len(audio_data) / 16000)
|
||||||
|
params = {
|
||||||
|
"model": self.modelname,
|
||||||
|
"file": buffer,
|
||||||
|
"response_format": self.response_format,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"timestamp_granularities": ["word", "segment"],
|
||||||
|
}
|
||||||
|
if not self.direct_english_translation and self.original_language:
|
||||||
|
params["language"] = self.original_language
|
||||||
|
if prompt:
|
||||||
|
params["prompt"] = prompt
|
||||||
|
proc = self.client.audio.translations if self.task == "translate" else self.client.audio.transcriptions
|
||||||
|
transcript = proc.create(**params)
|
||||||
|
logger.debug(f"OpenAI API processed accumulated {self.transcribed_seconds} seconds")
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
def use_vad(self):
|
||||||
|
self.use_vad_opt = True
|
||||||
@@ -6,18 +6,6 @@ from whisperlivekit.timed_objects import ASRToken, Sentence, Transcript
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# simulStreaming imports - we check if the files are here
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
from whisperlivekit.simul_whisper.config import AlignAttConfig
|
|
||||||
SIMULSTREAMING_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("SimulStreaming dependencies not available for online processor.")
|
|
||||||
SIMULSTREAMING_AVAILABLE = False
|
|
||||||
OnlineProcessorInterface = None
|
|
||||||
torch = None
|
|
||||||
|
|
||||||
|
|
||||||
class HypothesisBuffer:
|
class HypothesisBuffer:
|
||||||
"""
|
"""
|
||||||
Buffer to store and process ASR hypothesis tokens.
|
Buffer to store and process ASR hypothesis tokens.
|
||||||
@@ -118,9 +106,6 @@ class OnlineASRProcessor:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
asr,
|
asr,
|
||||||
tokenize_method: Optional[callable] = None,
|
|
||||||
buffer_trimming: Tuple[str, float] = ("segment", 15),
|
|
||||||
confidence_validation = False,
|
|
||||||
logfile=sys.stderr,
|
logfile=sys.stderr,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -131,12 +116,14 @@ class OnlineASRProcessor:
|
|||||||
buffer_trimming: A tuple (option, seconds), where option is either "sentence" or "segment".
|
buffer_trimming: A tuple (option, seconds), where option is either "sentence" or "segment".
|
||||||
"""
|
"""
|
||||||
self.asr = asr
|
self.asr = asr
|
||||||
self.tokenize = tokenize_method
|
self.tokenize = asr.tokenizer
|
||||||
self.logfile = logfile
|
self.logfile = logfile
|
||||||
self.confidence_validation = confidence_validation
|
self.confidence_validation = asr.confidence_validation
|
||||||
|
self.global_time_offset = 0.0
|
||||||
self.init()
|
self.init()
|
||||||
|
|
||||||
self.buffer_trimming_way, self.buffer_trimming_sec = buffer_trimming
|
self.buffer_trimming_way = asr.buffer_trimming
|
||||||
|
self.buffer_trimming_sec = asr.buffer_trimming_sec
|
||||||
|
|
||||||
if self.buffer_trimming_way not in ["sentence", "segment"]:
|
if self.buffer_trimming_way not in ["sentence", "segment"]:
|
||||||
raise ValueError("buffer_trimming must be either 'sentence' or 'segment'")
|
raise ValueError("buffer_trimming must be either 'sentence' or 'segment'")
|
||||||
@@ -164,6 +151,32 @@ class OnlineASRProcessor:
|
|||||||
"""Append an audio chunk (a numpy array) to the current audio buffer."""
|
"""Append an audio chunk (a numpy array) to the current audio buffer."""
|
||||||
self.audio_buffer = np.append(self.audio_buffer, audio)
|
self.audio_buffer = np.append(self.audio_buffer, audio)
|
||||||
|
|
||||||
|
def start_silence(self):
|
||||||
|
if self.audio_buffer.size == 0:
|
||||||
|
return [], self.get_audio_buffer_end_time()
|
||||||
|
return self.process_iter()
|
||||||
|
|
||||||
|
def end_silence(self, silence_duration: Optional[float], offset: float):
|
||||||
|
if not silence_duration or silence_duration <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
long_silence = silence_duration >= 5
|
||||||
|
if not long_silence:
|
||||||
|
gap_samples = int(self.SAMPLING_RATE * silence_duration)
|
||||||
|
if gap_samples > 0:
|
||||||
|
gap_silence = np.zeros(gap_samples, dtype=np.float32)
|
||||||
|
self.insert_audio_chunk(gap_silence)
|
||||||
|
else:
|
||||||
|
self.init(offset=silence_duration + offset)
|
||||||
|
|
||||||
|
self.global_time_offset += silence_duration
|
||||||
|
|
||||||
|
def insert_silence(self, silence_duration, offset):
|
||||||
|
"""
|
||||||
|
Backwards compatibility shim for legacy callers that still use insert_silence.
|
||||||
|
"""
|
||||||
|
self.end_silence(silence_duration, offset)
|
||||||
|
|
||||||
def prompt(self) -> Tuple[str, str]:
|
def prompt(self) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Returns a tuple: (prompt, context), where:
|
Returns a tuple: (prompt, context), where:
|
||||||
@@ -242,6 +255,9 @@ class OnlineASRProcessor:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Length of audio buffer now: {len(self.audio_buffer)/self.SAMPLING_RATE:.2f} seconds"
|
f"Length of audio buffer now: {len(self.audio_buffer)/self.SAMPLING_RATE:.2f} seconds"
|
||||||
)
|
)
|
||||||
|
if self.global_time_offset:
|
||||||
|
for token in committed_tokens:
|
||||||
|
token = token.with_offset(self.global_time_offset)
|
||||||
return committed_tokens, current_audio_processed_upto
|
return committed_tokens, current_audio_processed_upto
|
||||||
|
|
||||||
def chunk_completed_sentence(self):
|
def chunk_completed_sentence(self):
|
||||||
@@ -395,337 +411,11 @@ class OnlineASRProcessor:
|
|||||||
) -> Transcript:
|
) -> Transcript:
|
||||||
sep = sep if sep is not None else self.asr.sep
|
sep = sep if sep is not None else self.asr.sep
|
||||||
text = sep.join(token.text for token in tokens)
|
text = sep.join(token.text for token in tokens)
|
||||||
probability = sum(token.probability for token in tokens if token.probability) / len(tokens) if tokens else None
|
# probability = sum(token.probability for token in tokens if token.probability) / len(tokens) if tokens else None
|
||||||
if tokens:
|
if tokens:
|
||||||
start = offset + tokens[0].start
|
start = offset + tokens[0].start
|
||||||
end = offset + tokens[-1].end
|
end = offset + tokens[-1].end
|
||||||
else:
|
else:
|
||||||
start = None
|
start = None
|
||||||
end = None
|
end = None
|
||||||
return Transcript(start, end, text, probability=probability)
|
return Transcript(start, end, text)
|
||||||
|
|
||||||
|
|
||||||
class VACOnlineASRProcessor:
|
|
||||||
"""
|
|
||||||
Wraps an OnlineASRProcessor with a Voice Activity Controller (VAC).
|
|
||||||
|
|
||||||
It receives small chunks of audio, applies VAD (e.g. with Silero),
|
|
||||||
and when the system detects a pause in speech (or end of an utterance)
|
|
||||||
it finalizes the utterance immediately.
|
|
||||||
"""
|
|
||||||
SAMPLING_RATE = 16000
|
|
||||||
|
|
||||||
def __init__(self, online_chunk_size: float, *args, **kwargs):
|
|
||||||
self.online_chunk_size = online_chunk_size
|
|
||||||
self.online = OnlineASRProcessor(*args, **kwargs)
|
|
||||||
self.asr = self.online.asr
|
|
||||||
|
|
||||||
# Load a VAD model (e.g. Silero VAD)
|
|
||||||
import torch
|
|
||||||
model, _ = torch.hub.load(repo_or_dir="snakers4/silero-vad", model="silero_vad")
|
|
||||||
from .silero_vad_iterator import FixedVADIterator
|
|
||||||
|
|
||||||
self.vac = FixedVADIterator(model)
|
|
||||||
self.logfile = self.online.logfile
|
|
||||||
self.last_input_audio_stream_end_time: float = 0.0
|
|
||||||
self.init()
|
|
||||||
|
|
||||||
def init(self):
|
|
||||||
self.online.init()
|
|
||||||
self.vac.reset_states()
|
|
||||||
self.current_online_chunk_buffer_size = 0
|
|
||||||
self.last_input_audio_stream_end_time = self.online.buffer_time_offset
|
|
||||||
self.is_currently_final = False
|
|
||||||
self.status: Optional[str] = None # "voice" or "nonvoice"
|
|
||||||
self.audio_buffer = np.array([], dtype=np.float32)
|
|
||||||
self.buffer_offset = 0 # in frames
|
|
||||||
|
|
||||||
def get_audio_buffer_end_time(self) -> float:
|
|
||||||
"""Returns the absolute end time of the audio processed by the underlying OnlineASRProcessor."""
|
|
||||||
return self.online.get_audio_buffer_end_time()
|
|
||||||
|
|
||||||
def clear_buffer(self):
|
|
||||||
self.buffer_offset += len(self.audio_buffer)
|
|
||||||
self.audio_buffer = np.array([], dtype=np.float32)
|
|
||||||
|
|
||||||
def insert_audio_chunk(self, audio: np.ndarray, audio_stream_end_time: float):
|
|
||||||
"""
|
|
||||||
Process an incoming small audio chunk:
|
|
||||||
- run VAD on the chunk,
|
|
||||||
- decide whether to send the audio to the online ASR processor immediately,
|
|
||||||
- and/or to mark the current utterance as finished.
|
|
||||||
"""
|
|
||||||
self.last_input_audio_stream_end_time = audio_stream_end_time
|
|
||||||
res = self.vac(audio)
|
|
||||||
self.audio_buffer = np.append(self.audio_buffer, audio)
|
|
||||||
|
|
||||||
if res is not None:
|
|
||||||
# VAD returned a result; adjust the frame number
|
|
||||||
frame = list(res.values())[0] - self.buffer_offset
|
|
||||||
if "start" in res and "end" not in res:
|
|
||||||
self.status = "voice"
|
|
||||||
send_audio = self.audio_buffer[frame:]
|
|
||||||
self.online.init(offset=(frame + self.buffer_offset) / self.SAMPLING_RATE)
|
|
||||||
self.online.insert_audio_chunk(send_audio)
|
|
||||||
self.current_online_chunk_buffer_size += len(send_audio)
|
|
||||||
self.clear_buffer()
|
|
||||||
elif "end" in res and "start" not in res:
|
|
||||||
self.status = "nonvoice"
|
|
||||||
send_audio = self.audio_buffer[:frame]
|
|
||||||
self.online.insert_audio_chunk(send_audio)
|
|
||||||
self.current_online_chunk_buffer_size += len(send_audio)
|
|
||||||
self.is_currently_final = True
|
|
||||||
self.clear_buffer()
|
|
||||||
else:
|
|
||||||
beg = res["start"] - self.buffer_offset
|
|
||||||
end = res["end"] - self.buffer_offset
|
|
||||||
self.status = "nonvoice"
|
|
||||||
send_audio = self.audio_buffer[beg:end]
|
|
||||||
self.online.init(offset=(beg + self.buffer_offset) / self.SAMPLING_RATE)
|
|
||||||
self.online.insert_audio_chunk(send_audio)
|
|
||||||
self.current_online_chunk_buffer_size += len(send_audio)
|
|
||||||
self.is_currently_final = True
|
|
||||||
self.clear_buffer()
|
|
||||||
else:
|
|
||||||
if self.status == "voice":
|
|
||||||
self.online.insert_audio_chunk(self.audio_buffer)
|
|
||||||
self.current_online_chunk_buffer_size += len(self.audio_buffer)
|
|
||||||
self.clear_buffer()
|
|
||||||
else:
|
|
||||||
# Keep 1 second worth of audio in case VAD later detects voice,
|
|
||||||
# but trim to avoid unbounded memory usage.
|
|
||||||
self.buffer_offset += max(0, len(self.audio_buffer) - self.SAMPLING_RATE)
|
|
||||||
self.audio_buffer = self.audio_buffer[-self.SAMPLING_RATE:]
|
|
||||||
|
|
||||||
def process_iter(self) -> Tuple[List[ASRToken], float]:
|
|
||||||
"""
|
|
||||||
Depending on the VAD status and the amount of accumulated audio,
|
|
||||||
process the current audio chunk.
|
|
||||||
Returns a tuple: (list of committed ASRToken objects, float representing the audio processed up to time).
|
|
||||||
"""
|
|
||||||
if self.is_currently_final:
|
|
||||||
return self.finish()
|
|
||||||
elif self.current_online_chunk_buffer_size > self.SAMPLING_RATE * self.online_chunk_size:
|
|
||||||
self.current_online_chunk_buffer_size = 0
|
|
||||||
return self.online.process_iter()
|
|
||||||
else:
|
|
||||||
logger.debug("No online update, only VAD")
|
|
||||||
return [], self.last_input_audio_stream_end_time
|
|
||||||
|
|
||||||
def finish(self) -> Tuple[List[ASRToken], float]:
|
|
||||||
"""
|
|
||||||
Finish processing by flushing any remaining text.
|
|
||||||
Returns a tuple: (list of remaining ASRToken objects, float representing the final audio processed up to time).
|
|
||||||
"""
|
|
||||||
result_tokens, processed_upto = self.online.finish()
|
|
||||||
self.current_online_chunk_buffer_size = 0
|
|
||||||
self.is_currently_final = False
|
|
||||||
return result_tokens, processed_upto
|
|
||||||
|
|
||||||
def get_buffer(self):
|
|
||||||
"""
|
|
||||||
Get the unvalidated buffer in string format.
|
|
||||||
"""
|
|
||||||
return self.online.concatenate_tokens(self.online.transcript_buffer.buffer)
|
|
||||||
|
|
||||||
|
|
||||||
class SimulStreamingOnlineProcessor:
|
|
||||||
SAMPLING_RATE = 16000
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
asr,
|
|
||||||
tokenize_method: Optional[callable] = None,
|
|
||||||
buffer_trimming: Tuple[str, float] = ("segment", 15),
|
|
||||||
confidence_validation = False,
|
|
||||||
logfile=sys.stderr,
|
|
||||||
):
|
|
||||||
if not SIMULSTREAMING_AVAILABLE:
|
|
||||||
raise ImportError("SimulStreaming dependencies are not available.")
|
|
||||||
|
|
||||||
self.asr = asr
|
|
||||||
self.tokenize = tokenize_method
|
|
||||||
self.logfile = logfile
|
|
||||||
self.confidence_validation = confidence_validation
|
|
||||||
self.init()
|
|
||||||
|
|
||||||
# buffer does not work yet
|
|
||||||
self.buffer_trimming_way, self.buffer_trimming_sec = buffer_trimming
|
|
||||||
|
|
||||||
def init(self, offset: Optional[float] = None):
|
|
||||||
"""Initialize or reset the processing state."""
|
|
||||||
self.audio_chunks = []
|
|
||||||
self.offset = offset if offset is not None else 0.0
|
|
||||||
self.is_last = False
|
|
||||||
self.beg = self.offset
|
|
||||||
self.end = self.offset
|
|
||||||
self.cumulative_audio_duration = 0.0
|
|
||||||
self.last_audio_stream_end_time = self.offset
|
|
||||||
|
|
||||||
self.committed: List[ASRToken] = []
|
|
||||||
self.last_result_tokens: List[ASRToken] = []
|
|
||||||
self.buffer_content = ""
|
|
||||||
self.processed_audio_duration = 0.0
|
|
||||||
|
|
||||||
def get_audio_buffer_end_time(self) -> float:
|
|
||||||
"""Returns the absolute end time of the current audio buffer."""
|
|
||||||
return self.end
|
|
||||||
|
|
||||||
def insert_audio_chunk(self, audio: np.ndarray, audio_stream_end_time: Optional[float] = None):
|
|
||||||
"""Append an audio chunk to be processed by SimulStreaming."""
|
|
||||||
if torch is None:
|
|
||||||
raise ImportError("PyTorch is required for SimulStreaming but not available")
|
|
||||||
|
|
||||||
# Convert numpy array to torch tensor
|
|
||||||
audio_tensor = torch.from_numpy(audio).float()
|
|
||||||
self.audio_chunks.append(audio_tensor)
|
|
||||||
|
|
||||||
# Update timing
|
|
||||||
chunk_duration = len(audio) / self.SAMPLING_RATE
|
|
||||||
self.cumulative_audio_duration += chunk_duration
|
|
||||||
|
|
||||||
if audio_stream_end_time is not None:
|
|
||||||
self.last_audio_stream_end_time = audio_stream_end_time
|
|
||||||
self.end = audio_stream_end_time
|
|
||||||
else:
|
|
||||||
self.end = self.offset + self.cumulative_audio_duration
|
|
||||||
|
|
||||||
def prompt(self) -> Tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Returns a tuple: (prompt, context).
|
|
||||||
SimulStreaming handles prompting internally, so we return empty strings.
|
|
||||||
"""
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
def get_buffer(self):
|
|
||||||
"""
|
|
||||||
Get the unvalidated buffer content.
|
|
||||||
"""
|
|
||||||
buffer_end = self.end if hasattr(self, 'end') else None
|
|
||||||
return Transcript(
|
|
||||||
start=None,
|
|
||||||
end=buffer_end,
|
|
||||||
text=self.buffer_content,
|
|
||||||
probability=None
|
|
||||||
)
|
|
||||||
|
|
||||||
def timestamped_text(self, tokens, generation):
|
|
||||||
# From the simulstreaming repo. self.model to self.asr.model
|
|
||||||
pr = generation["progress"]
|
|
||||||
if "result" not in generation:
|
|
||||||
split_words, split_tokens = self.asr.model.tokenizer.split_to_word_tokens(tokens)
|
|
||||||
else:
|
|
||||||
split_words, split_tokens = generation["result"]["split_words"], generation["result"]["split_tokens"]
|
|
||||||
|
|
||||||
frames = [p["most_attended_frames"][0] for p in pr]
|
|
||||||
tokens = tokens.copy()
|
|
||||||
ret = []
|
|
||||||
for sw,st in zip(split_words,split_tokens):
|
|
||||||
b = None
|
|
||||||
for stt in st:
|
|
||||||
t,f = tokens.pop(0), frames.pop(0)
|
|
||||||
if t != stt:
|
|
||||||
raise ValueError(f"Token mismatch: {t} != {stt} at frame {f}.")
|
|
||||||
if b is None:
|
|
||||||
b = f
|
|
||||||
e = f
|
|
||||||
out = (b*0.02, e*0.02, sw)
|
|
||||||
ret.append(out)
|
|
||||||
logger.debug(f"TS-WORD:\t{' '.join(map(str, out))}")
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def process_iter(self) -> Tuple[List[ASRToken], float]:
|
|
||||||
"""
|
|
||||||
Process accumulated audio chunks using SimulStreaming.
|
|
||||||
|
|
||||||
Returns a tuple: (list of committed ASRToken objects, float representing the audio processed up to time).
|
|
||||||
"""
|
|
||||||
if not self.audio_chunks:
|
|
||||||
return [], self.end
|
|
||||||
|
|
||||||
try:
|
|
||||||
# concatenate all audio chunks
|
|
||||||
if len(self.audio_chunks) == 1:
|
|
||||||
audio = self.audio_chunks[0]
|
|
||||||
else:
|
|
||||||
audio = torch.cat(self.audio_chunks, dim=0)
|
|
||||||
|
|
||||||
audio_duration = audio.shape[0] / self.SAMPLING_RATE if audio.shape[0] > 0 else 0
|
|
||||||
self.processed_audio_duration += audio_duration
|
|
||||||
|
|
||||||
self.audio_chunks = []
|
|
||||||
|
|
||||||
logger.debug(f"SimulStreaming processing audio shape: {audio.shape}, duration: {audio_duration:.2f}s")
|
|
||||||
logger.debug(f"Current end time: {self.end:.2f}s, last stream time: {self.last_audio_stream_end_time:.2f}s")
|
|
||||||
|
|
||||||
self.asr.model.insert_audio(audio)
|
|
||||||
tokens, generation_progress = self.asr.model.infer(is_last=self.is_last)
|
|
||||||
ts_words = self.timestamped_text(tokens, generation_progress)
|
|
||||||
text = self.asr.model.tokenizer.decode(tokens)
|
|
||||||
|
|
||||||
new_tokens = []
|
|
||||||
for ts_word in ts_words:
|
|
||||||
|
|
||||||
start, end, word = ts_word
|
|
||||||
token = ASRToken(
|
|
||||||
start=start,
|
|
||||||
end=end,
|
|
||||||
text=word,
|
|
||||||
probability=0.95 # fake prob. Maybe we can extract it from the model?
|
|
||||||
)
|
|
||||||
new_tokens.append(token)
|
|
||||||
self.committed.extend(new_tokens)
|
|
||||||
|
|
||||||
return new_tokens, self.end
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"SimulStreaming processing error: {e}")
|
|
||||||
return [], self.end
|
|
||||||
|
|
||||||
def finish(self) -> Tuple[List[ASRToken], float]:
|
|
||||||
logger.debug("SimulStreaming finish() called")
|
|
||||||
self.is_last = True
|
|
||||||
final_tokens, final_time = self.process_iter()
|
|
||||||
self.is_last = False
|
|
||||||
return final_tokens, final_time
|
|
||||||
|
|
||||||
def concatenate_tokens(
|
|
||||||
self,
|
|
||||||
tokens: List[ASRToken],
|
|
||||||
sep: Optional[str] = None,
|
|
||||||
offset: float = 0
|
|
||||||
) -> Transcript:
|
|
||||||
"""Concatenate tokens into a Transcript object."""
|
|
||||||
sep = sep if sep is not None else self.asr.sep
|
|
||||||
text = sep.join(token.text for token in tokens)
|
|
||||||
probability = sum(token.probability for token in tokens if token.probability) / len(tokens) if tokens else None
|
|
||||||
if tokens:
|
|
||||||
start = offset + tokens[0].start
|
|
||||||
end = offset + tokens[-1].end
|
|
||||||
else:
|
|
||||||
start = None
|
|
||||||
end = None
|
|
||||||
return Transcript(start, end, text, probability=probability)
|
|
||||||
|
|
||||||
def chunk_at(self, time: float):
|
|
||||||
"""
|
|
||||||
useless but kept for compatibility
|
|
||||||
"""
|
|
||||||
logger.debug(f"SimulStreaming chunk_at({time:.2f}) - handled internally")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def words_to_sentences(self, tokens: List[ASRToken]) -> List[Sentence]:
|
|
||||||
"""
|
|
||||||
Create simple sentences.
|
|
||||||
"""
|
|
||||||
if not tokens:
|
|
||||||
return []
|
|
||||||
|
|
||||||
full_text = " ".join(token.text for token in tokens)
|
|
||||||
sentence = Sentence(
|
|
||||||
start=tokens[0].start,
|
|
||||||
end=tokens[-1].end,
|
|
||||||
text=full_text
|
|
||||||
)
|
|
||||||
return [sentence]
|
|
||||||
199
whisperlivekit/local_agreement/whisper_online.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import librosa
|
||||||
|
from functools import lru_cache
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
from .backends import FasterWhisperASR, MLXWhisper, WhisperASR, OpenaiApiASR
|
||||||
|
from whisperlivekit.warmup import warmup_asr
|
||||||
|
from whisperlivekit.model_paths import resolve_model_path, model_path_and_type
|
||||||
|
from whisperlivekit.backend_support import (
|
||||||
|
mlx_backend_available,
|
||||||
|
faster_backend_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
WHISPER_LANG_CODES = "af,am,ar,as,az,ba,be,bg,bn,bo,br,bs,ca,cs,cy,da,de,el,en,es,et,eu,fa,fi,fo,fr,gl,gu,ha,haw,he,hi,hr,ht,hu,hy,id,is,it,ja,jw,ka,kk,km,kn,ko,la,lb,ln,lo,lt,lv,mg,mi,mk,ml,mn,mr,ms,mt,my,ne,nl,nn,no,oc,pa,pl,ps,pt,ro,ru,sa,sd,si,sk,sl,sn,so,sq,sr,su,sv,sw,ta,te,tg,th,tk,tl,tr,tt,uk,ur,uz,vi,yi,yo,zh".split(
|
||||||
|
","
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tokenizer(lan):
|
||||||
|
"""returns an object that has split function that works like the one of MosesTokenizer"""
|
||||||
|
|
||||||
|
assert (
|
||||||
|
lan in WHISPER_LANG_CODES
|
||||||
|
), "language must be Whisper's supported lang code: " + " ".join(WHISPER_LANG_CODES)
|
||||||
|
|
||||||
|
if lan == "uk":
|
||||||
|
import tokenize_uk
|
||||||
|
|
||||||
|
class UkrainianTokenizer:
|
||||||
|
def split(self, text):
|
||||||
|
return tokenize_uk.tokenize_sents(text)
|
||||||
|
|
||||||
|
return UkrainianTokenizer()
|
||||||
|
|
||||||
|
# supported by fast-mosestokenizer
|
||||||
|
if (
|
||||||
|
lan
|
||||||
|
in "as bn ca cs de el en es et fi fr ga gu hi hu is it kn lt lv ml mni mr nl or pa pl pt ro ru sk sl sv ta te yue zh".split()
|
||||||
|
):
|
||||||
|
from mosestokenizer import MosesSentenceSplitter
|
||||||
|
|
||||||
|
return MosesSentenceSplitter(lan)
|
||||||
|
|
||||||
|
# the following languages are in Whisper, but not in wtpsplit:
|
||||||
|
if (
|
||||||
|
lan
|
||||||
|
in "as ba bo br bs fo haw hr ht jw lb ln lo mi nn oc sa sd sn so su sw tk tl tt".split()
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f"{lan} code is not supported by wtpsplit. Going to use None lang_code option."
|
||||||
|
)
|
||||||
|
lan = None
|
||||||
|
|
||||||
|
from wtpsplit import WtP
|
||||||
|
|
||||||
|
# downloads the model from huggingface on the first use
|
||||||
|
wtp = WtP("wtp-canine-s-12l-no-adapters")
|
||||||
|
|
||||||
|
class WtPtok:
|
||||||
|
def split(self, sent):
|
||||||
|
return wtp.split(sent, lang_code=lan)
|
||||||
|
|
||||||
|
return WtPtok()
|
||||||
|
|
||||||
|
|
||||||
|
def backend_factory(
|
||||||
|
backend,
|
||||||
|
lan,
|
||||||
|
model_size,
|
||||||
|
model_cache_dir,
|
||||||
|
model_dir,
|
||||||
|
model_path,
|
||||||
|
direct_english_translation,
|
||||||
|
buffer_trimming,
|
||||||
|
buffer_trimming_sec,
|
||||||
|
confidence_validation,
|
||||||
|
warmup_file=None,
|
||||||
|
min_chunk_size=None,
|
||||||
|
):
|
||||||
|
backend_choice = backend
|
||||||
|
custom_reference = model_path or model_dir
|
||||||
|
resolved_root = None
|
||||||
|
pytorch_checkpoint = None
|
||||||
|
has_mlx_weights = False
|
||||||
|
has_fw_weights = False
|
||||||
|
|
||||||
|
if custom_reference:
|
||||||
|
resolved_root = resolve_model_path(custom_reference)
|
||||||
|
if resolved_root.is_dir():
|
||||||
|
pytorch_checkpoint, has_mlx_weights, has_fw_weights = model_path_and_type(resolved_root)
|
||||||
|
else:
|
||||||
|
pytorch_checkpoint = resolved_root
|
||||||
|
|
||||||
|
if backend_choice == "openai-api":
|
||||||
|
logger.debug("Using OpenAI API.")
|
||||||
|
asr = OpenaiApiASR(lan=lan)
|
||||||
|
else:
|
||||||
|
backend_choice = _normalize_backend_choice(
|
||||||
|
backend_choice,
|
||||||
|
resolved_root,
|
||||||
|
has_mlx_weights,
|
||||||
|
has_fw_weights,
|
||||||
|
)
|
||||||
|
|
||||||
|
if backend_choice == "faster-whisper":
|
||||||
|
asr_cls = FasterWhisperASR
|
||||||
|
if resolved_root is not None and not resolved_root.is_dir():
|
||||||
|
raise ValueError("Faster-Whisper backend expects a directory with CTranslate2 weights.")
|
||||||
|
model_override = str(resolved_root) if resolved_root is not None else None
|
||||||
|
elif backend_choice == "mlx-whisper":
|
||||||
|
asr_cls = MLXWhisper
|
||||||
|
if resolved_root is not None and not resolved_root.is_dir():
|
||||||
|
raise ValueError("MLX Whisper backend expects a directory containing MLX weights.")
|
||||||
|
model_override = str(resolved_root) if resolved_root is not None else None
|
||||||
|
else:
|
||||||
|
asr_cls = WhisperASR
|
||||||
|
model_override = str(pytorch_checkpoint) if pytorch_checkpoint is not None else None
|
||||||
|
if custom_reference and model_override is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No PyTorch checkpoint found under {resolved_root or custom_reference}"
|
||||||
|
)
|
||||||
|
|
||||||
|
t = time.time()
|
||||||
|
logger.info(f"Loading Whisper {model_size} model for language {lan} using backend {backend_choice}...")
|
||||||
|
asr = asr_cls(
|
||||||
|
model_size=model_size,
|
||||||
|
lan=lan,
|
||||||
|
cache_dir=model_cache_dir,
|
||||||
|
model_dir=model_override,
|
||||||
|
)
|
||||||
|
e = time.time()
|
||||||
|
logger.info(f"done. It took {round(e-t,2)} seconds.")
|
||||||
|
|
||||||
|
if direct_english_translation:
|
||||||
|
tgt_language = "en" # Whisper translates into English
|
||||||
|
else:
|
||||||
|
tgt_language = lan # Whisper transcribes in this language
|
||||||
|
|
||||||
|
# Create the tokenizer
|
||||||
|
if buffer_trimming == "sentence":
|
||||||
|
tokenizer = create_tokenizer(tgt_language)
|
||||||
|
else:
|
||||||
|
tokenizer = None
|
||||||
|
|
||||||
|
warmup_asr(asr, warmup_file)
|
||||||
|
|
||||||
|
asr.confidence_validation = confidence_validation
|
||||||
|
asr.tokenizer = tokenizer
|
||||||
|
asr.buffer_trimming = buffer_trimming
|
||||||
|
asr.buffer_trimming_sec = buffer_trimming_sec
|
||||||
|
asr.backend_choice = backend_choice
|
||||||
|
return asr
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_backend_choice(
|
||||||
|
preferred_backend,
|
||||||
|
resolved_root,
|
||||||
|
has_mlx_weights,
|
||||||
|
has_fw_weights,
|
||||||
|
):
|
||||||
|
backend_choice = preferred_backend
|
||||||
|
|
||||||
|
if backend_choice == "auto":
|
||||||
|
if mlx_backend_available(warn_on_missing=True) and (resolved_root is None or has_mlx_weights):
|
||||||
|
return "mlx-whisper"
|
||||||
|
if faster_backend_available(warn_on_missing=True) and (resolved_root is None or has_fw_weights):
|
||||||
|
return "faster-whisper"
|
||||||
|
return "whisper"
|
||||||
|
|
||||||
|
if backend_choice == "mlx-whisper":
|
||||||
|
if not mlx_backend_available():
|
||||||
|
raise RuntimeError("mlx-whisper backend requested but mlx-whisper is not installed.")
|
||||||
|
if resolved_root is not None and not has_mlx_weights:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"mlx-whisper backend requested but no MLX weights were found under {resolved_root}"
|
||||||
|
)
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
logger.warning("mlx-whisper backend requested on a non-macOS system; this may fail.")
|
||||||
|
return backend_choice
|
||||||
|
|
||||||
|
if backend_choice == "faster-whisper":
|
||||||
|
if not faster_backend_available():
|
||||||
|
raise RuntimeError("faster-whisper backend requested but faster-whisper is not installed.")
|
||||||
|
if resolved_root is not None and not has_fw_weights:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"faster-whisper backend requested but no Faster-Whisper weights were found under {resolved_root}"
|
||||||
|
)
|
||||||
|
return backend_choice
|
||||||
|
|
||||||
|
if backend_choice == "whisper":
|
||||||
|
return backend_choice
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown backend '{preferred_backend}' for LocalAgreement.")
|
||||||
69
whisperlivekit/model_paths.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
def model_path_and_type(model_path: Union[str, Path]) -> Tuple[Optional[Path], bool, bool]:
|
||||||
|
"""
|
||||||
|
Inspect the provided path and determine which model formats are available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pytorch_path: Path to a PyTorch checkpoint (if present).
|
||||||
|
compatible_whisper_mlx: True if MLX weights exist in this folder.
|
||||||
|
compatible_faster_whisper: True if Faster-Whisper (ctranslate2) weights exist.
|
||||||
|
"""
|
||||||
|
path = Path(model_path)
|
||||||
|
|
||||||
|
compatible_whisper_mlx = False
|
||||||
|
compatible_faster_whisper = False
|
||||||
|
pytorch_path: Optional[Path] = None
|
||||||
|
|
||||||
|
if path.is_file() and path.suffix.lower() in [".pt", ".safetensors", ".bin"]:
|
||||||
|
pytorch_path = path
|
||||||
|
return pytorch_path, compatible_whisper_mlx, compatible_faster_whisper
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
for file in path.iterdir():
|
||||||
|
if not file.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = file.name.lower()
|
||||||
|
suffix = file.suffix.lower()
|
||||||
|
|
||||||
|
if filename in {"weights.npz", "weights.safetensors"}:
|
||||||
|
compatible_whisper_mlx = True
|
||||||
|
elif filename in {"model.bin", "encoder.bin", "decoder.bin"}:
|
||||||
|
compatible_faster_whisper = True
|
||||||
|
elif suffix in {".pt", ".safetensors"}:
|
||||||
|
pytorch_path = file
|
||||||
|
elif filename == "pytorch_model.bin":
|
||||||
|
pytorch_path = file
|
||||||
|
|
||||||
|
if pytorch_path is None:
|
||||||
|
fallback = path / "pytorch_model.bin"
|
||||||
|
if fallback.exists():
|
||||||
|
pytorch_path = fallback
|
||||||
|
|
||||||
|
return pytorch_path, compatible_whisper_mlx, compatible_faster_whisper
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_path(model_path: Union[str, Path]) -> Path:
|
||||||
|
"""
|
||||||
|
Return a local path for the provided model reference.
|
||||||
|
|
||||||
|
If the path does not exist locally, it is treated as a Hugging Face repo id
|
||||||
|
and downloaded via snapshot_download.
|
||||||
|
"""
|
||||||
|
path = Path(model_path).expanduser()
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
except ImportError as exc: # pragma: no cover - optional dependency guard
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Model path '{model_path}' does not exist locally and huggingface_hub "
|
||||||
|
"is not installed to download it."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
downloaded_path = Path(snapshot_download(repo_id=str(model_path)))
|
||||||
|
return downloaded_path
|
||||||
@@ -20,7 +20,7 @@ def parse_args():
|
|||||||
help="""
|
help="""
|
||||||
The path to a speech audio wav file to warm up Whisper so that the very first chunk processing is fast.
|
The path to a speech audio wav file to warm up Whisper so that the very first chunk processing is fast.
|
||||||
If not set, uses https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav.
|
If not set, uses https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav.
|
||||||
If False, no warmup is performed.
|
If empty, no warmup is performed.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,23 +58,38 @@ def parse_args():
|
|||||||
help="Hugging Face model ID for pyannote.audio embedding model.",
|
help="Hugging Face model ID for pyannote.audio embedding model.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--diarization-backend",
|
||||||
|
type=str,
|
||||||
|
default="sortformer",
|
||||||
|
choices=["sortformer", "diart"],
|
||||||
|
help="The diarization backend to use.",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-transcription",
|
"--no-transcription",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable transcription to only see live diarization results.",
|
help="Disable transcription to only see live diarization results.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--disable-punctuation-split",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable the split parameter.",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--min-chunk-size",
|
"--min-chunk-size",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.5,
|
default=0.1,
|
||||||
help="Minimum audio chunk size in seconds. It waits up to this time to do processing. If the processing takes shorter time, it waits, otherwise it processes the whole segment that was received by this time.",
|
help="Minimum audio chunk size in seconds. It waits up to this time to do processing. If the processing takes shorter time, it waits, otherwise it processes the whole segment that was received by this time.",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--model",
|
"--model",
|
||||||
type=str,
|
type=str,
|
||||||
default="tiny",
|
default="base",
|
||||||
|
dest='model_size',
|
||||||
help="Name size of the Whisper model to use (default: tiny). Suggested values: tiny.en,tiny,base.en,base,small.en,small,medium.en,medium,large-v1,large-v2,large-v3,large,large-v3-turbo. The model is automatically downloaded from the model hub if not present in model cache dir.",
|
help="Name size of the Whisper model to use (default: tiny). Suggested values: tiny.en,tiny,base.en,base,small.en,small,medium.en,medium,large-v1,large-v2,large-v3,large,large-v3-turbo. The model is automatically downloaded from the model hub if not present in model cache dir.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,27 +110,43 @@ def parse_args():
|
|||||||
"--language",
|
"--language",
|
||||||
type=str,
|
type=str,
|
||||||
default="auto",
|
default="auto",
|
||||||
|
dest='lan',
|
||||||
help="Source language code, e.g. en,de,cs, or 'auto' for language detection.",
|
help="Source language code, e.g. en,de,cs, or 'auto' for language detection.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--task",
|
"--direct-english-translation",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Use Whisper to directly translate to english.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--target-language",
|
||||||
type=str,
|
type=str,
|
||||||
default="transcribe",
|
default="",
|
||||||
choices=["transcribe", "translate"],
|
dest="target_language",
|
||||||
help="Transcribe or translate.",
|
help="Target language for translation. Not functional yet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend-policy",
|
||||||
|
type=str,
|
||||||
|
default="simulstreaming",
|
||||||
|
choices=["1", "2", "simulstreaming", "localagreement"],
|
||||||
|
help="Select the streaming policy: 1 or 'simulstreaming' for AlignAtt, 2 or 'localagreement' for LocalAgreement.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
type=str,
|
type=str,
|
||||||
default="faster-whisper",
|
default="auto",
|
||||||
choices=["faster-whisper", "whisper_timestamped", "mlx-whisper", "openai-api", "simulstreaming"],
|
choices=["auto", "mlx-whisper", "faster-whisper", "whisper", "openai-api"],
|
||||||
help="Load only this backend for Whisper processing.",
|
help="Select the Whisper backend implementation (auto: prefer MLX on macOS, otherwise Faster-Whisper, else Whisper). Use 'openai-api' with --backend-policy localagreement to call OpenAI's API.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--vac",
|
"--no-vac",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Use VAC = voice activity controller. Recommended. Requires torch.",
|
help="Disable VAC = voice activity controller.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--vac-chunk-size", type=float, default=0.04, help="VAC sample size in seconds."
|
"--vac-chunk-size", type=float, default=0.04, help="VAC sample size in seconds."
|
||||||
@@ -150,9 +181,30 @@ def parse_args():
|
|||||||
)
|
)
|
||||||
parser.add_argument("--ssl-certfile", type=str, help="Path to the SSL certificate file.", default=None)
|
parser.add_argument("--ssl-certfile", type=str, help="Path to the SSL certificate file.", default=None)
|
||||||
parser.add_argument("--ssl-keyfile", type=str, help="Path to the SSL private key file.", default=None)
|
parser.add_argument("--ssl-keyfile", type=str, help="Path to the SSL private key file.", default=None)
|
||||||
|
parser.add_argument("--forwarded-allow-ips", type=str, help="Allowed ips for reverse proxying.", default=None)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pcm-input",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="If set, raw PCM (s16le) data is expected as input and FFmpeg will be bypassed. Frontend will use AudioWorklet instead of MediaRecorder."
|
||||||
|
)
|
||||||
# SimulStreaming-specific arguments
|
# SimulStreaming-specific arguments
|
||||||
simulstreaming_group = parser.add_argument_group('SimulStreaming arguments (only used with --backend simulstreaming)')
|
simulstreaming_group = parser.add_argument_group('SimulStreaming arguments (only used with --backend simulstreaming)')
|
||||||
|
|
||||||
|
simulstreaming_group.add_argument(
|
||||||
|
"--disable-fast-encoder",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
dest="disable_fast_encoder",
|
||||||
|
help="Disable Faster Whisper or MLX Whisper backends for encoding (if installed). Slower but helpful when GPU memory is limited",
|
||||||
|
)
|
||||||
|
|
||||||
|
simulstreaming_group.add_argument(
|
||||||
|
"--custom-alignment-heads",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Use your own alignment heads, useful when `--model-dir` is used",
|
||||||
|
)
|
||||||
|
|
||||||
simulstreaming_group.add_argument(
|
simulstreaming_group.add_argument(
|
||||||
"--frame-threshold",
|
"--frame-threshold",
|
||||||
@@ -242,6 +294,28 @@ def parse_args():
|
|||||||
dest="model_path",
|
dest="model_path",
|
||||||
help="Direct path to the SimulStreaming Whisper .pt model file. Overrides --model for SimulStreaming backend.",
|
help="Direct path to the SimulStreaming Whisper .pt model file. Overrides --model for SimulStreaming backend.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
simulstreaming_group.add_argument(
|
||||||
|
"--preload-model-count",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
dest="preload_model_count",
|
||||||
|
help="Optional. Number of models to preload in memory to speed up loading (set up to the expected number of concurrent instances).",
|
||||||
|
)
|
||||||
|
|
||||||
|
simulstreaming_group.add_argument(
|
||||||
|
"--nllb-backend",
|
||||||
|
type=str,
|
||||||
|
default="transformers",
|
||||||
|
help="transformers or ctranslate2",
|
||||||
|
)
|
||||||
|
|
||||||
|
simulstreaming_group.add_argument(
|
||||||
|
"--nllb-size",
|
||||||
|
type=str,
|
||||||
|
default="600M",
|
||||||
|
help="600M or 1.3B",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -249,5 +323,10 @@ def parse_args():
|
|||||||
args.vad = not args.no_vad
|
args.vad = not args.no_vad
|
||||||
delattr(args, 'no_transcription')
|
delattr(args, 'no_transcription')
|
||||||
delattr(args, 'no_vad')
|
delattr(args, 'no_vad')
|
||||||
|
|
||||||
|
if args.backend_policy == "1":
|
||||||
|
args.backend_policy = "simulstreaming"
|
||||||
|
elif args.backend_policy == "2":
|
||||||
|
args.backend_policy = "localagreement"
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
294
whisperlivekit/silero_vad_iterator.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
"""
|
||||||
|
Code is adapted from silero-vad v6: https://github.com/snakers4/silero-vad
|
||||||
|
"""
|
||||||
|
|
||||||
|
def init_jit_model(model_path: str, device=torch.device('cpu')):
|
||||||
|
"""Load a JIT model from file."""
|
||||||
|
model = torch.jit.load(model_path, map_location=device)
|
||||||
|
model.eval()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
class OnnxWrapper():
|
||||||
|
"""ONNX Runtime wrapper for Silero VAD model."""
|
||||||
|
|
||||||
|
def __init__(self, path, force_onnx_cpu=False):
|
||||||
|
global np
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime
|
||||||
|
|
||||||
|
opts = onnxruntime.SessionOptions()
|
||||||
|
opts.inter_op_num_threads = 1
|
||||||
|
opts.intra_op_num_threads = 1
|
||||||
|
|
||||||
|
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, sess_options=opts)
|
||||||
|
|
||||||
|
self.reset_states()
|
||||||
|
if '16k' in path:
|
||||||
|
warnings.warn('This model support only 16000 sampling rate!')
|
||||||
|
self.sample_rates = [16000]
|
||||||
|
else:
|
||||||
|
self.sample_rates = [8000, 16000]
|
||||||
|
|
||||||
|
def _validate_input(self, x, sr: int):
|
||||||
|
if x.dim() == 1:
|
||||||
|
x = x.unsqueeze(0)
|
||||||
|
if x.dim() > 2:
|
||||||
|
raise ValueError(f"Too many dimensions for input audio chunk {x.dim()}")
|
||||||
|
|
||||||
|
if sr != 16000 and (sr % 16000 == 0):
|
||||||
|
step = sr // 16000
|
||||||
|
x = x[:,::step]
|
||||||
|
sr = 16000
|
||||||
|
|
||||||
|
if sr not in self.sample_rates:
|
||||||
|
raise ValueError(f"Supported sampling rates: {self.sample_rates} (or multiply of 16000)")
|
||||||
|
if sr / x.shape[1] > 31.25:
|
||||||
|
raise ValueError("Input audio chunk is too short")
|
||||||
|
|
||||||
|
return x, sr
|
||||||
|
|
||||||
|
def reset_states(self, batch_size=1):
|
||||||
|
self._state = torch.zeros((2, batch_size, 128)).float()
|
||||||
|
self._context = torch.zeros(0)
|
||||||
|
self._last_sr = 0
|
||||||
|
self._last_batch_size = 0
|
||||||
|
|
||||||
|
def __call__(self, x, sr: int):
|
||||||
|
|
||||||
|
x, sr = self._validate_input(x, sr)
|
||||||
|
num_samples = 512 if sr == 16000 else 256
|
||||||
|
|
||||||
|
if x.shape[-1] != num_samples:
|
||||||
|
raise ValueError(f"Provided number of samples is {x.shape[-1]} (Supported values: 256 for 8000 sample rate, 512 for 16000)")
|
||||||
|
|
||||||
|
batch_size = x.shape[0]
|
||||||
|
context_size = 64 if sr == 16000 else 32
|
||||||
|
|
||||||
|
if not self._last_batch_size:
|
||||||
|
self.reset_states(batch_size)
|
||||||
|
if (self._last_sr) and (self._last_sr != sr):
|
||||||
|
self.reset_states(batch_size)
|
||||||
|
if (self._last_batch_size) and (self._last_batch_size != batch_size):
|
||||||
|
self.reset_states(batch_size)
|
||||||
|
|
||||||
|
if not len(self._context):
|
||||||
|
self._context = torch.zeros(batch_size, context_size)
|
||||||
|
|
||||||
|
x = torch.cat([self._context, x], dim=1)
|
||||||
|
if sr in [8000, 16000]:
|
||||||
|
ort_inputs = {'input': x.numpy(), 'state': self._state.numpy(), 'sr': np.array(sr, dtype='int64')}
|
||||||
|
ort_outs = self.session.run(None, ort_inputs)
|
||||||
|
out, state = ort_outs
|
||||||
|
self._state = torch.from_numpy(state)
|
||||||
|
else:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
self._context = x[..., -context_size:]
|
||||||
|
self._last_sr = sr
|
||||||
|
self._last_batch_size = batch_size
|
||||||
|
|
||||||
|
out = torch.from_numpy(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_silero_vad(model_path: str = None, onnx: bool = False, opset_version: int = 16):
|
||||||
|
"""
|
||||||
|
Load Silero VAD model (JIT or ONNX).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model_path : str, optional
|
||||||
|
Path to model file. If None, uses default bundled model.
|
||||||
|
onnx : bool, default False
|
||||||
|
Whether to use ONNX runtime (requires onnxruntime package).
|
||||||
|
opset_version : int, default 16
|
||||||
|
ONNX opset version (15 or 16). Only used if onnx=True.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
model
|
||||||
|
Loaded VAD model (JIT or ONNX wrapper)
|
||||||
|
"""
|
||||||
|
available_ops = [15, 16]
|
||||||
|
if onnx and opset_version not in available_ops:
|
||||||
|
raise Exception(f'Available ONNX opset_version: {available_ops}')
|
||||||
|
if model_path is None:
|
||||||
|
current_dir = Path(__file__).parent
|
||||||
|
data_dir = current_dir / 'vad_models'
|
||||||
|
|
||||||
|
if onnx:
|
||||||
|
if opset_version == 16:
|
||||||
|
model_name = 'silero_vad.onnx'
|
||||||
|
else:
|
||||||
|
model_name = f'silero_vad_16k_op{opset_version}.onnx'
|
||||||
|
else:
|
||||||
|
model_name = 'silero_vad.jit'
|
||||||
|
|
||||||
|
model_path = data_dir / model_name
|
||||||
|
|
||||||
|
if not model_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Model file not found: {model_path}\n"
|
||||||
|
f"Please ensure the whisperlivekit/vad_models/ directory contains the model files."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
model_path = Path(model_path)
|
||||||
|
if onnx:
|
||||||
|
try:
|
||||||
|
model = OnnxWrapper(str(model_path), force_onnx_cpu=True)
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"ONNX runtime not available. Install with: pip install onnxruntime\n"
|
||||||
|
"Or use JIT model by setting onnx=False"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
model = init_jit_model(str(model_path))
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
class VADIterator:
|
||||||
|
"""
|
||||||
|
Voice Activity Detection iterator for streaming audio.
|
||||||
|
|
||||||
|
This is the Silero VAD v6 implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
model,
|
||||||
|
threshold: float = 0.5,
|
||||||
|
sampling_rate: int = 16000,
|
||||||
|
min_silence_duration_ms: int = 100,
|
||||||
|
speech_pad_ms: int = 30
|
||||||
|
):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Class for stream imitation
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model: preloaded .jit/.onnx silero VAD model
|
||||||
|
|
||||||
|
threshold: float (default - 0.5)
|
||||||
|
Speech threshold. Silero VAD outputs speech probabilities for each audio chunk, probabilities ABOVE this value are considered as SPEECH.
|
||||||
|
It is better to tune this parameter for each dataset separately, but "lazy" 0.5 is pretty good for most datasets.
|
||||||
|
|
||||||
|
sampling_rate: int (default - 16000)
|
||||||
|
Currently silero VAD models support 8000 and 16000 sample rates
|
||||||
|
|
||||||
|
min_silence_duration_ms: int (default - 100 milliseconds)
|
||||||
|
In the end of each speech chunk wait for min_silence_duration_ms before separating it
|
||||||
|
|
||||||
|
speech_pad_ms: int (default - 30 milliseconds)
|
||||||
|
Final speech chunks are padded by speech_pad_ms each side
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.model = model
|
||||||
|
self.threshold = threshold
|
||||||
|
self.sampling_rate = sampling_rate
|
||||||
|
|
||||||
|
if sampling_rate not in [8000, 16000]:
|
||||||
|
raise ValueError('VADIterator does not support sampling rates other than [8000, 16000]')
|
||||||
|
|
||||||
|
self.min_silence_samples = sampling_rate * min_silence_duration_ms / 1000
|
||||||
|
self.speech_pad_samples = sampling_rate * speech_pad_ms / 1000
|
||||||
|
self.reset_states()
|
||||||
|
|
||||||
|
def reset_states(self):
|
||||||
|
|
||||||
|
self.model.reset_states()
|
||||||
|
self.triggered = False
|
||||||
|
self.temp_end = 0
|
||||||
|
self.current_sample = 0
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def __call__(self, x, return_seconds=False, time_resolution: int = 1):
|
||||||
|
"""
|
||||||
|
x: torch.Tensor
|
||||||
|
audio chunk (see examples in repo)
|
||||||
|
|
||||||
|
return_seconds: bool (default - False)
|
||||||
|
whether return timestamps in seconds (default - samples)
|
||||||
|
|
||||||
|
time_resolution: int (default - 1)
|
||||||
|
time resolution of speech coordinates when requested as seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not torch.is_tensor(x):
|
||||||
|
try:
|
||||||
|
x = torch.Tensor(x)
|
||||||
|
except:
|
||||||
|
raise TypeError("Audio cannot be casted to tensor. Cast it manually")
|
||||||
|
|
||||||
|
window_size_samples = len(x[0]) if x.dim() == 2 else len(x)
|
||||||
|
self.current_sample += window_size_samples
|
||||||
|
|
||||||
|
speech_prob = self.model(x, self.sampling_rate).item()
|
||||||
|
|
||||||
|
if (speech_prob >= self.threshold) and self.temp_end:
|
||||||
|
self.temp_end = 0
|
||||||
|
|
||||||
|
if (speech_prob >= self.threshold) and not self.triggered:
|
||||||
|
self.triggered = True
|
||||||
|
speech_start = max(0, self.current_sample - self.speech_pad_samples - window_size_samples)
|
||||||
|
return {'start': int(speech_start) if not return_seconds else round(speech_start / self.sampling_rate, time_resolution)}
|
||||||
|
|
||||||
|
if (speech_prob < self.threshold - 0.15) and self.triggered:
|
||||||
|
if not self.temp_end:
|
||||||
|
self.temp_end = self.current_sample
|
||||||
|
if self.current_sample - self.temp_end < self.min_silence_samples:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
speech_end = self.temp_end + self.speech_pad_samples - window_size_samples
|
||||||
|
self.temp_end = 0
|
||||||
|
self.triggered = False
|
||||||
|
return {'end': int(speech_end) if not return_seconds else round(speech_end / self.sampling_rate, time_resolution)}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FixedVADIterator(VADIterator):
|
||||||
|
"""
|
||||||
|
Fixed VAD Iterator that handles variable-length audio chunks, not only exactly 512 frames at once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def reset_states(self):
|
||||||
|
super().reset_states()
|
||||||
|
self.buffer = np.array([], dtype=np.float32)
|
||||||
|
|
||||||
|
def __call__(self, x, return_seconds=False):
|
||||||
|
self.buffer = np.append(self.buffer, x)
|
||||||
|
ret = None
|
||||||
|
while len(self.buffer) >= 512:
|
||||||
|
r = super().__call__(self.buffer[:512], return_seconds=return_seconds)
|
||||||
|
self.buffer = self.buffer[512:]
|
||||||
|
if ret is None:
|
||||||
|
ret = r
|
||||||
|
elif r is not None:
|
||||||
|
if "end" in r:
|
||||||
|
ret["end"] = r["end"]
|
||||||
|
if "start" in r and "end" in ret:
|
||||||
|
del ret["end"]
|
||||||
|
return ret if ret != {} else None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
model = load_silero_vad(onnx=False)
|
||||||
|
vad = FixedVADIterator(model)
|
||||||
|
|
||||||
|
audio_buffer = np.array([0] * 512, dtype=np.float32)
|
||||||
|
result = vad(audio_buffer)
|
||||||
|
print(f" 512 samples: {result}")
|
||||||
|
|
||||||
|
# test with 511 samples
|
||||||
|
audio_buffer = np.array([0] * 511, dtype=np.float32)
|
||||||
|
result = vad(audio_buffer)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .backend import SimulStreamingASR, SimulStreamingOnlineProcessor
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SimulStreamingASR",
|
||||||
|
"SimulStreamingOnlineProcessor",
|
||||||
|
]
|
||||||
|
|||||||
355
whisperlivekit/simul_whisper/backend.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
import platform
|
||||||
|
from whisperlivekit.timed_objects import ASRToken, Transcript, ChangeSpeaker
|
||||||
|
from whisperlivekit.warmup import load_file
|
||||||
|
from whisperlivekit.whisper import load_model, tokenizer
|
||||||
|
from whisperlivekit.whisper.audio import TOKENS_PER_SECOND
|
||||||
|
import os
|
||||||
|
import gc
|
||||||
|
from pathlib import Path
|
||||||
|
from whisperlivekit.model_paths import model_path_and_type, resolve_model_path
|
||||||
|
from whisperlivekit.backend_support import (
|
||||||
|
mlx_backend_available,
|
||||||
|
faster_backend_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from whisperlivekit.simul_whisper.config import AlignAttConfig
|
||||||
|
from whisperlivekit.simul_whisper.simul_whisper import AlignAtt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
HAS_MLX_WHISPER = mlx_backend_available(warn_on_missing=True)
|
||||||
|
if HAS_MLX_WHISPER:
|
||||||
|
from .mlx_encoder import mlx_model_mapping, load_mlx_encoder
|
||||||
|
else:
|
||||||
|
mlx_model_mapping = {}
|
||||||
|
HAS_FASTER_WHISPER = faster_backend_available(warn_on_missing=not HAS_MLX_WHISPER)
|
||||||
|
if HAS_FASTER_WHISPER:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
else:
|
||||||
|
WhisperModel = None
|
||||||
|
|
||||||
|
MIN_DURATION_REAL_SILENCE = 5
|
||||||
|
|
||||||
|
class SimulStreamingOnlineProcessor:
|
||||||
|
SAMPLING_RATE = 16000
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
asr,
|
||||||
|
logfile=sys.stderr,
|
||||||
|
):
|
||||||
|
self.asr = asr
|
||||||
|
self.logfile = logfile
|
||||||
|
self.end = 0.0
|
||||||
|
self.buffer = []
|
||||||
|
self.committed: List[ASRToken] = []
|
||||||
|
self.last_result_tokens: List[ASRToken] = []
|
||||||
|
self.load_new_backend()
|
||||||
|
|
||||||
|
#can be moved
|
||||||
|
if asr.tokenizer:
|
||||||
|
self.model.tokenizer = asr.tokenizer
|
||||||
|
|
||||||
|
def load_new_backend(self):
|
||||||
|
model = self.asr.get_new_model_instance()
|
||||||
|
self.model = AlignAtt(
|
||||||
|
cfg=self.asr.cfg,
|
||||||
|
loaded_model=model,
|
||||||
|
mlx_encoder=self.asr.mlx_encoder,
|
||||||
|
fw_encoder=self.asr.fw_encoder,
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_silence(self):
|
||||||
|
tokens, processed_upto = self.process_iter(is_last=True)
|
||||||
|
return tokens, processed_upto
|
||||||
|
|
||||||
|
def end_silence(self, silence_duration, offset):
|
||||||
|
"""
|
||||||
|
If silences are > MIN_DURATION_REAL_SILENCE, we do a complete context clear. Otherwise, we just insert a small silence and shift the last_attend_frame
|
||||||
|
"""
|
||||||
|
self.end += silence_duration
|
||||||
|
long_silence = silence_duration >= MIN_DURATION_REAL_SILENCE
|
||||||
|
if not long_silence:
|
||||||
|
gap_len = int(16000 * silence_duration)
|
||||||
|
if gap_len > 0:
|
||||||
|
gap_silence = torch.zeros(gap_len)
|
||||||
|
self.model.insert_audio(gap_silence)
|
||||||
|
if long_silence:
|
||||||
|
self.model.refresh_segment(complete=True)
|
||||||
|
self.model.global_time_offset = silence_duration + offset
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def insert_audio_chunk(self, audio: np.ndarray, audio_stream_end_time):
|
||||||
|
"""Append an audio chunk to be processed by SimulStreaming."""
|
||||||
|
|
||||||
|
# Convert numpy array to torch tensor
|
||||||
|
audio_tensor = torch.from_numpy(audio).float()
|
||||||
|
self.end = audio_stream_end_time #Only to be aligned with what happens in whisperstreaming backend.
|
||||||
|
self.model.insert_audio(audio_tensor)
|
||||||
|
|
||||||
|
def new_speaker(self, change_speaker: ChangeSpeaker):
|
||||||
|
self.process_iter(is_last=True)
|
||||||
|
self.model.refresh_segment(complete=True)
|
||||||
|
self.model.speaker = change_speaker.speaker
|
||||||
|
self.global_time_offset = change_speaker.start
|
||||||
|
|
||||||
|
def get_buffer(self):
|
||||||
|
concat_buffer = Transcript.from_tokens(tokens= self.buffer, sep='')
|
||||||
|
return concat_buffer
|
||||||
|
|
||||||
|
def process_iter(self, is_last=False) -> Tuple[List[ASRToken], float]:
|
||||||
|
"""
|
||||||
|
Process accumulated audio chunks using SimulStreaming.
|
||||||
|
|
||||||
|
Returns a tuple: (list of committed ASRToken objects, float representing the audio processed up to time).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
timestamped_words = self.model.infer(is_last=is_last)
|
||||||
|
if self.model.cfg.language == "auto" and timestamped_words and timestamped_words[0].detected_language == None:
|
||||||
|
self.buffer.extend(timestamped_words)
|
||||||
|
return [], self.end
|
||||||
|
|
||||||
|
self.committed.extend(timestamped_words)
|
||||||
|
self.buffer = []
|
||||||
|
return timestamped_words, self.end
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"SimulStreaming processing error: {e}")
|
||||||
|
return [], self.end
|
||||||
|
|
||||||
|
def warmup(self, audio, init_prompt=""):
|
||||||
|
"""Warmup the SimulStreaming model."""
|
||||||
|
try:
|
||||||
|
self.model.insert_audio(audio)
|
||||||
|
self.model.infer(True)
|
||||||
|
self.model.refresh_segment(complete=True)
|
||||||
|
logger.info("SimulStreaming model warmed up successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"SimulStreaming warmup failed: {e}")
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# free the model and add a new model to stack.
|
||||||
|
# del self.model
|
||||||
|
gc.collect()
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
# self.asr.new_model_to_stack()
|
||||||
|
self.model.remove_hooks()
|
||||||
|
|
||||||
|
class SimulStreamingASR():
|
||||||
|
"""SimulStreaming backend with AlignAtt policy."""
|
||||||
|
sep = ""
|
||||||
|
|
||||||
|
def __init__(self, logfile=sys.stderr, **kwargs):
|
||||||
|
self.logfile = logfile
|
||||||
|
self.transcribe_kargs = {}
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
if self.decoder_type is None:
|
||||||
|
self.decoder_type = 'greedy' if self.beams == 1 else 'beam'
|
||||||
|
|
||||||
|
self.fast_encoder = False
|
||||||
|
self._resolved_model_path = None
|
||||||
|
self.encoder_backend = "whisper"
|
||||||
|
preferred_backend = getattr(self, "backend", "auto")
|
||||||
|
self.pytorch_path, compatible_whisper_mlx, compatible_faster_whisper = None, True, True
|
||||||
|
if self.model_path:
|
||||||
|
resolved_model_path = resolve_model_path(self.model_path)
|
||||||
|
self._resolved_model_path = resolved_model_path
|
||||||
|
self.model_path = str(resolved_model_path)
|
||||||
|
self.pytorch_path, compatible_whisper_mlx, compatible_faster_whisper = model_path_and_type(resolved_model_path)
|
||||||
|
if self.pytorch_path:
|
||||||
|
self.model_name = self.pytorch_path.stem
|
||||||
|
else:
|
||||||
|
self.model_name = Path(self.model_path).stem
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No PyTorch checkpoint (.pt/.bin/.safetensors) found under {self.model_path}"
|
||||||
|
)
|
||||||
|
elif self.model_size is not None:
|
||||||
|
model_mapping = {
|
||||||
|
'tiny': './tiny.pt',
|
||||||
|
'base': './base.pt',
|
||||||
|
'small': './small.pt',
|
||||||
|
'medium': './medium.pt',
|
||||||
|
'medium.en': './medium.en.pt',
|
||||||
|
'large-v1': './large-v1.pt',
|
||||||
|
'base.en': './base.en.pt',
|
||||||
|
'small.en': './small.en.pt',
|
||||||
|
'tiny.en': './tiny.en.pt',
|
||||||
|
'large-v2': './large-v2.pt',
|
||||||
|
'large-v3': './large-v3.pt',
|
||||||
|
'large': './large-v3.pt'
|
||||||
|
}
|
||||||
|
self.model_name = self.model_size
|
||||||
|
else:
|
||||||
|
raise ValueError("Either model_size or model_path must be specified for SimulStreaming.")
|
||||||
|
|
||||||
|
is_multilingual = not self.model_name.endswith(".en")
|
||||||
|
|
||||||
|
self.encoder_backend = self._resolve_encoder_backend(
|
||||||
|
preferred_backend,
|
||||||
|
compatible_whisper_mlx,
|
||||||
|
compatible_faster_whisper,
|
||||||
|
)
|
||||||
|
self.fast_encoder = self.encoder_backend in ("mlx-whisper", "faster-whisper")
|
||||||
|
if self.encoder_backend == "whisper":
|
||||||
|
self.disable_fast_encoder = True
|
||||||
|
|
||||||
|
self.cfg = AlignAttConfig(
|
||||||
|
tokenizer_is_multilingual= is_multilingual,
|
||||||
|
segment_length=self.min_chunk_size,
|
||||||
|
frame_threshold=self.frame_threshold,
|
||||||
|
language=self.lan,
|
||||||
|
audio_max_len=self.audio_max_len,
|
||||||
|
audio_min_len=self.audio_min_len,
|
||||||
|
cif_ckpt_path=self.cif_ckpt_path,
|
||||||
|
decoder_type="beam",
|
||||||
|
beam_size=self.beams,
|
||||||
|
task=self.direct_english_translation,
|
||||||
|
never_fire=self.never_fire,
|
||||||
|
init_prompt=self.init_prompt,
|
||||||
|
max_context_tokens=self.max_context_tokens,
|
||||||
|
static_init_prompt=self.static_init_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up tokenizer for translation if needed
|
||||||
|
if self.direct_english_translation:
|
||||||
|
self.tokenizer = self.set_translate_task()
|
||||||
|
else:
|
||||||
|
self.tokenizer = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
self.mlx_encoder, self.fw_encoder = None, None
|
||||||
|
if self.encoder_backend == "mlx-whisper":
|
||||||
|
print('Simulstreaming will use MLX whisper to increase encoding speed.')
|
||||||
|
if self._resolved_model_path is not None:
|
||||||
|
mlx_model = str(self._resolved_model_path)
|
||||||
|
else:
|
||||||
|
mlx_model = mlx_model_mapping.get(self.model_name)
|
||||||
|
if not mlx_model:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"MLX Whisper backend requested but no compatible weights found for model '{self.model_name}'."
|
||||||
|
)
|
||||||
|
self.mlx_encoder = load_mlx_encoder(path_or_hf_repo=mlx_model)
|
||||||
|
elif self.encoder_backend == "faster-whisper":
|
||||||
|
print('Simulstreaming will use Faster Whisper for the encoder.')
|
||||||
|
if self._resolved_model_path is not None:
|
||||||
|
fw_model = str(self._resolved_model_path)
|
||||||
|
else:
|
||||||
|
fw_model = self.model_name
|
||||||
|
self.fw_encoder = WhisperModel(
|
||||||
|
fw_model,
|
||||||
|
device='auto',
|
||||||
|
compute_type='auto',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.models = [self.load_model() for i in range(self.preload_model_count)]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_encoder_backend(self, preferred_backend, compatible_whisper_mlx, compatible_faster_whisper):
|
||||||
|
choice = preferred_backend or "auto"
|
||||||
|
if self.disable_fast_encoder:
|
||||||
|
return "whisper"
|
||||||
|
if choice == "whisper":
|
||||||
|
return "whisper"
|
||||||
|
if choice == "mlx-whisper":
|
||||||
|
if not self._can_use_mlx(compatible_whisper_mlx):
|
||||||
|
raise RuntimeError("mlx-whisper backend requested but MLX Whisper is unavailable or incompatible with the provided model.")
|
||||||
|
return "mlx-whisper"
|
||||||
|
if choice == "faster-whisper":
|
||||||
|
if not self._can_use_faster(compatible_faster_whisper):
|
||||||
|
raise RuntimeError("faster-whisper backend requested but Faster-Whisper is unavailable or incompatible with the provided model.")
|
||||||
|
return "faster-whisper"
|
||||||
|
if choice == "openai-api":
|
||||||
|
raise ValueError("openai-api backend is only supported with the LocalAgreement policy.")
|
||||||
|
# auto mode
|
||||||
|
if platform.system() == "Darwin" and self._can_use_mlx(compatible_whisper_mlx):
|
||||||
|
return "mlx-whisper"
|
||||||
|
if self._can_use_faster(compatible_faster_whisper):
|
||||||
|
return "faster-whisper"
|
||||||
|
return "whisper"
|
||||||
|
|
||||||
|
def _has_custom_model_path(self):
|
||||||
|
return self._resolved_model_path is not None
|
||||||
|
|
||||||
|
def _can_use_mlx(self, compatible_whisper_mlx):
|
||||||
|
if not HAS_MLX_WHISPER:
|
||||||
|
return False
|
||||||
|
if self._has_custom_model_path():
|
||||||
|
return compatible_whisper_mlx
|
||||||
|
return self.model_name in mlx_model_mapping
|
||||||
|
|
||||||
|
def _can_use_faster(self, compatible_faster_whisper):
|
||||||
|
if not HAS_FASTER_WHISPER:
|
||||||
|
return False
|
||||||
|
if self._has_custom_model_path():
|
||||||
|
return compatible_faster_whisper
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_model(self):
|
||||||
|
whisper_model = load_model(
|
||||||
|
name=self.pytorch_path if self.pytorch_path else self.model_name,
|
||||||
|
download_root=self.model_path,
|
||||||
|
decoder_only=self.fast_encoder,
|
||||||
|
custom_alignment_heads=self.custom_alignment_heads
|
||||||
|
)
|
||||||
|
warmup_audio = load_file(self.warmup_file)
|
||||||
|
if warmup_audio is not None:
|
||||||
|
warmup_audio = torch.from_numpy(warmup_audio).float()
|
||||||
|
if self.fast_encoder:
|
||||||
|
temp_model = AlignAtt(
|
||||||
|
cfg=self.cfg,
|
||||||
|
loaded_model=whisper_model,
|
||||||
|
mlx_encoder=self.mlx_encoder,
|
||||||
|
fw_encoder=self.fw_encoder,
|
||||||
|
)
|
||||||
|
temp_model.warmup(warmup_audio)
|
||||||
|
temp_model.remove_hooks()
|
||||||
|
else:
|
||||||
|
# For standard encoder, use the original transcribe warmup
|
||||||
|
warmup_audio = load_file(self.warmup_file)
|
||||||
|
whisper_model.transcribe(warmup_audio, language=self.lan if self.lan != 'auto' else None)
|
||||||
|
return whisper_model
|
||||||
|
|
||||||
|
def get_new_model_instance(self):
|
||||||
|
"""
|
||||||
|
SimulStreaming cannot share the same backend because it uses global forward hooks on the attention layers.
|
||||||
|
Therefore, each user requires a separate model instance, which can be memory-intensive. To maintain speed, we preload the models into memory.
|
||||||
|
"""
|
||||||
|
if len(self.models) == 0:
|
||||||
|
self.models.append(self.load_model())
|
||||||
|
new_model = self.models.pop()
|
||||||
|
return new_model
|
||||||
|
# self.models[0]
|
||||||
|
|
||||||
|
def new_model_to_stack(self):
|
||||||
|
self.models.append(self.load_model())
|
||||||
|
|
||||||
|
|
||||||
|
def set_translate_task(self):
|
||||||
|
"""Set up translation task."""
|
||||||
|
if self.cfg.language == 'auto':
|
||||||
|
raise Exception('Translation cannot be done with language = auto')
|
||||||
|
return tokenizer.get_tokenizer(
|
||||||
|
multilingual=True,
|
||||||
|
language=self.cfg.language,
|
||||||
|
num_languages=99,
|
||||||
|
task="translate"
|
||||||
|
)
|
||||||
|
|
||||||
|
def transcribe(self, audio):
|
||||||
|
"""
|
||||||
|
Warmup is done directly in load_model
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .whisper.decoding import PyTorchInference
|
from whisperlivekit.whisper.decoding import PyTorchInference
|
||||||
|
|
||||||
# extention of PyTorchInference for beam search
|
# extention of PyTorchInference for beam search
|
||||||
class BeamPyTorchInference(PyTorchInference):
|
class BeamPyTorchInference(PyTorchInference):
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
# This code was originally in simul_whisper/transcriber/simul_whisper.py . It is adapted a lot for SimulStreaming.
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SimulWhisperConfig:
|
class AlignAttConfig():
|
||||||
'''Options that are common for all simul policies that could be implemented in SimulWhisper.'''
|
|
||||||
model_path: str
|
|
||||||
language: str = field(default="zh")
|
|
||||||
nonspeech_prob: float = 1.0
|
|
||||||
audio_min_len: float = 1.0
|
|
||||||
decoder_type: Literal["greedy","beam"] = "greedy"
|
|
||||||
beam_size: int = 5
|
|
||||||
task: Literal["transcribe","translate"] = "transcribe"
|
|
||||||
init_prompt: str = field(default=None)
|
|
||||||
static_init_prompt: str = field(default=None)
|
|
||||||
max_context_tokens: int = field(default=None)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AlignAttConfig(SimulWhisperConfig):
|
|
||||||
'''Options specific to the AlignAtt policy.'''
|
|
||||||
eval_data_path: str = "tmp"
|
eval_data_path: str = "tmp"
|
||||||
segment_length: float = field(default=1.0, metadata = {"help": "in second"})
|
segment_length: float = field(default=1.0, metadata = {"help": "in second"})
|
||||||
frame_threshold: int = 4
|
frame_threshold: int = 4
|
||||||
rewind_threshold: int = 200
|
rewind_threshold: int = 200
|
||||||
audio_max_len: float = 30.0
|
audio_max_len: float = 20.0
|
||||||
cif_ckpt_path: str = ""
|
cif_ckpt_path: str = ""
|
||||||
never_fire: bool = False
|
never_fire: bool = False
|
||||||
|
language: str = field(default="zh")
|
||||||
|
nonspeech_prob: float = 0.5
|
||||||
|
audio_min_len: float = 1.0
|
||||||
|
decoder_type: Literal["greedy","beam"] = "greedy"
|
||||||
|
beam_size: int = 5
|
||||||
|
task: Literal["transcribe","translate"] = "transcribe"
|
||||||
|
tokenizer_is_multilingual: bool = False
|
||||||
|
init_prompt: str = field(default=None)
|
||||||
|
static_init_prompt: str = field(default=None)
|
||||||
|
max_context_tokens: int = field(default=None)
|
||||||
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
class Tokens:
|
|
||||||
def __init__(self, tokens):
|
|
||||||
self.tokens = tokens
|
|
||||||
|
|
||||||
# def clone(self):
|
|
||||||
# return Tokens(self.tokens.clone())
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return str(self.tokens.tolist())
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
class BeamTokens(Tokens):
|
|
||||||
def __init__(self, tokens, beam_size):
|
|
||||||
self.tokens = tokens
|
|
||||||
self.beam_size = beam_size
|
|
||||||
|
|
||||||
def clone(self):
|
|
||||||
return BeamTokens(self.tokens.clone())
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"BeamTokens({self.tokens.tolist()}, beam_size={self.beam_size})"
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def as_text(self, tokenizer):
|
|
||||||
return tokenizer.decode(self.tokens)
|
|
||||||
|
|
||||||
class Logits(Tokens):
|
|
||||||
def __init__(self, logits):
|
|
||||||
super().__init__(logits)
|
|
||||||
|
|
||||||
# def clone(self):
|
|
||||||
# return Logits(self.tokens.clone(), self.beam_size)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
# return "abc"
|
|
||||||
return f"Logits({self.tokens.shape})"
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.__str__()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
SIMULSTREAMING_LICENSE = f"""
|
|
||||||
{"*"*80}
|
|
||||||
SimulStreaming (https://github.com/ufal/SimulStreaming) is dual-licensed:
|
|
||||||
|
|
||||||
🔹 Non-Commercial Use
|
|
||||||
You may use SimulStreaming under the PolyForm Noncommercial License 1.0.0 if you obtain the code through the GitHub repository. This license is free of charge and comes with no obligations for non-commercial users.
|
|
||||||
|
|
||||||
🔸 Commercial Use
|
|
||||||
Understanding who uses SimulStreaming commercially helps us improve and
|
|
||||||
prioritize development. Therefore, we want to require registration of those who acquire a commercial licence.
|
|
||||||
We plan to make the commercial licenceses affordable to SMEs and individuals. We are considering to provide commercial licenses either for free or for symbolic one-time fee, and maybe also provide additional support. You can share your preference via the questionnaire https://forms.cloud.microsoft/e/7tCxb4gJfB.
|
|
||||||
You can also leave your contact there: https://forms.cloud.microsoft/e/7tCxb4gJfB to be notified when the commercial licenses become
|
|
||||||
available.
|
|
||||||
|
|
||||||
✉️ Contact
|
|
||||||
Dominik Macháček (https://ufal.mff.cuni.cz/dominik-machacek/), machacek@ufal.mff.cuni.cz
|
|
||||||
{"*"*80}
|
|
||||||
"""
|
|
||||||
72
whisperlivekit/simul_whisper/mlx_encoder.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import mlx.core as mx
|
||||||
|
import mlx.nn as nn
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from mlx.utils import tree_unflatten
|
||||||
|
|
||||||
|
from mlx_whisper import whisper
|
||||||
|
|
||||||
|
mlx_model_mapping = {
|
||||||
|
"tiny.en": "mlx-community/whisper-tiny.en-mlx",
|
||||||
|
"tiny": "mlx-community/whisper-tiny-mlx",
|
||||||
|
"base.en": "mlx-community/whisper-base.en-mlx",
|
||||||
|
"base": "mlx-community/whisper-base-mlx",
|
||||||
|
"small.en": "mlx-community/whisper-small.en-mlx",
|
||||||
|
"small": "mlx-community/whisper-small-mlx",
|
||||||
|
"medium.en": "mlx-community/whisper-medium.en-mlx",
|
||||||
|
"medium": "mlx-community/whisper-medium-mlx",
|
||||||
|
"large-v1": "mlx-community/whisper-large-v1-mlx",
|
||||||
|
"large-v2": "mlx-community/whisper-large-v2-mlx",
|
||||||
|
"large-v3": "mlx-community/whisper-large-v3-mlx",
|
||||||
|
"large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
|
||||||
|
"large": "mlx-community/whisper-large-mlx",
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_mlx_encoder(
|
||||||
|
path_or_hf_repo: str,
|
||||||
|
dtype: mx.Dtype = mx.float32,
|
||||||
|
) -> whisper.Whisper:
|
||||||
|
model_path = Path(path_or_hf_repo)
|
||||||
|
if not model_path.exists():
|
||||||
|
model_path = Path(snapshot_download(repo_id=path_or_hf_repo))
|
||||||
|
|
||||||
|
with open(str(model_path / "config.json"), "r") as f:
|
||||||
|
config = json.loads(f.read())
|
||||||
|
config.pop("model_type", None)
|
||||||
|
quantization = config.pop("quantization", None)
|
||||||
|
|
||||||
|
model_args = whisper.ModelDimensions(**config)
|
||||||
|
|
||||||
|
wf = model_path / "weights.safetensors"
|
||||||
|
if not wf.exists():
|
||||||
|
wf = model_path / "weights.npz"
|
||||||
|
weights = mx.load(str(wf))
|
||||||
|
|
||||||
|
model = whisper.Whisper(model_args, dtype)
|
||||||
|
|
||||||
|
if quantization is not None:
|
||||||
|
class_predicate = (
|
||||||
|
lambda p, m: isinstance(m, (nn.Linear, nn.Embedding))
|
||||||
|
and f"{p}.scales" in weights
|
||||||
|
)
|
||||||
|
nn.quantize(model, **quantization, class_predicate=class_predicate)
|
||||||
|
|
||||||
|
weights = tree_unflatten(list(weights.items()))
|
||||||
|
|
||||||
|
# we only want to load the encoder weights here.
|
||||||
|
# Size examples: for tiny.en,
|
||||||
|
# Decoder weights: 59110771 bytes
|
||||||
|
# Encoder weights: 15268874 bytes
|
||||||
|
|
||||||
|
|
||||||
|
encoder_weights = {}
|
||||||
|
encoder_weights['encoder'] = weights['encoder']
|
||||||
|
del(weights)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
model.update(encoder_weights)
|
||||||
|
mx.eval(model.parameters())
|
||||||
|
return model
|
||||||
@@ -1,58 +1,96 @@
|
|||||||
# This code was originally in simul_whisper/transcriber/simul_whisper.py . It is adapted a lot for SimulStreaming.
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
import torch.nn.functional as F
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from .whisper import load_model, DecodingOptions, tokenizer
|
from whisperlivekit.whisper import DecodingOptions, tokenizer
|
||||||
from .config import AlignAttConfig
|
from .config import AlignAttConfig
|
||||||
from .whisper.audio import log_mel_spectrogram, TOKENS_PER_SECOND, pad_or_trim, N_SAMPLES, N_FRAMES
|
from whisperlivekit.timed_objects import ASRToken
|
||||||
from .whisper.timing import median_filter
|
from whisperlivekit.whisper.audio import log_mel_spectrogram, TOKENS_PER_SECOND, pad_or_trim, N_SAMPLES, N_FRAMES
|
||||||
from .whisper.decoding import GreedyDecoder, BeamSearchDecoder, SuppressTokens, detect_language
|
from whisperlivekit.whisper.timing import median_filter
|
||||||
|
from whisperlivekit.whisper.decoding import GreedyDecoder, BeamSearchDecoder, SuppressTokens
|
||||||
from .beam import BeamPyTorchInference
|
from .beam import BeamPyTorchInference
|
||||||
from .eow_detection import fire_at_boundary, load_cif
|
from .eow_detection import fire_at_boundary, load_cif
|
||||||
import os
|
import os
|
||||||
|
from time import time
|
||||||
|
from .token_buffer import TokenBuffer
|
||||||
|
from whisperlivekit.backend_support import (
|
||||||
|
mlx_backend_available,
|
||||||
|
faster_backend_available,
|
||||||
|
)
|
||||||
|
|
||||||
from token_buffer import TokenBuffer
|
from ..timed_objects import PUNCTUATION_MARKS
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from .generation_progress import *
|
|
||||||
|
|
||||||
DEC_PAD = 50257
|
DEC_PAD = 50257
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
import sys
|
if mlx_backend_available():
|
||||||
import wave
|
from mlx_whisper.audio import log_mel_spectrogram as mlx_log_mel_spectrogram
|
||||||
|
from mlx_whisper.transcribe import pad_or_trim as mlx_pad_or_trim
|
||||||
|
|
||||||
# New features added to the original version of Simul-Whisper:
|
if faster_backend_available():
|
||||||
# - large-v3 model support
|
from faster_whisper.audio import pad_or_trim as fw_pad_or_trim
|
||||||
# - translation support
|
from faster_whisper.feature_extractor import FeatureExtractor
|
||||||
# - beam search
|
|
||||||
# - prompt -- static vs. non-static
|
USE_MLCORE = False
|
||||||
# - context
|
|
||||||
class PaddedAlignAttWhisper:
|
|
||||||
def __init__(self, cfg: AlignAttConfig) -> None:
|
def load_coreml_encoder():
|
||||||
|
try:
|
||||||
|
from coremltools.models import MLModel
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("coremltools is not installed")
|
||||||
|
return None
|
||||||
|
COREML_ENCODER_PATH = os.environ.get("MLCORE_ENCODER_PATH", "whisperlivekit/whisper/whisper_encoder.mlpackage")
|
||||||
|
_coreml_encoder = MLModel(COREML_ENCODER_PATH)
|
||||||
|
spec = _coreml_encoder.get_spec()
|
||||||
|
_coreml_input_name = spec.description.input[0].name if spec.description.input else "mel"
|
||||||
|
_coreml_output_name = spec.description.output[0].name if spec.description.output else None
|
||||||
|
return _coreml_encoder, _coreml_input_name, _coreml_output_name
|
||||||
|
|
||||||
|
|
||||||
|
class AlignAtt:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cfg: AlignAttConfig,
|
||||||
|
loaded_model=None,
|
||||||
|
mlx_encoder=None,
|
||||||
|
fw_encoder=None,
|
||||||
|
) -> None:
|
||||||
self.log_segments = 0
|
self.log_segments = 0
|
||||||
model_name = os.path.basename(cfg.model_path).replace(".pt", "")
|
|
||||||
model_path = os.path.dirname(os.path.abspath(cfg.model_path))
|
self.model = loaded_model
|
||||||
self.model = load_model(name=model_name, download_root=model_path)
|
self.mlx_encoder = mlx_encoder
|
||||||
|
self.fw_encoder = fw_encoder
|
||||||
|
if fw_encoder:
|
||||||
|
self.fw_feature_extractor = FeatureExtractor(feature_size=self.model.dims.n_mels)
|
||||||
|
self.coreml_encoder_tuple = None
|
||||||
|
if USE_MLCORE:
|
||||||
|
self.coreml_encoder_tuple = load_coreml_encoder()
|
||||||
|
self.use_mlcore = self.coreml_encoder_tuple is not None
|
||||||
|
|
||||||
|
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||||
|
|
||||||
logger.info(f"Model dimensions: {self.model.dims}")
|
logger.info(f"Model dimensions: {self.model.dims}")
|
||||||
|
self.speaker = -1
|
||||||
self.decode_options = DecodingOptions(
|
self.decode_options = DecodingOptions(
|
||||||
language = cfg.language,
|
language = cfg.language,
|
||||||
without_timestamps = True,
|
without_timestamps = True,
|
||||||
task=cfg.task
|
task=cfg.task
|
||||||
)
|
)
|
||||||
self.tokenizer_is_multilingual = not model_name.endswith(".en")
|
self.tokenizer_is_multilingual = cfg.tokenizer_is_multilingual
|
||||||
self.create_tokenizer(cfg.language if cfg.language != "auto" else None)
|
self.create_tokenizer(cfg.language if cfg.language != "auto" else None)
|
||||||
|
# self.create_tokenizer('en')
|
||||||
self.detected_language = cfg.language if cfg.language != "auto" else None
|
self.detected_language = cfg.language if cfg.language != "auto" else None
|
||||||
|
self.global_time_offset = 0.0
|
||||||
|
self.reset_tokenizer_to_auto_next_call = False
|
||||||
|
|
||||||
self.max_text_len = self.model.dims.n_text_ctx
|
self.max_text_len = self.model.dims.n_text_ctx
|
||||||
self.num_decoder_layers = len(self.model.decoder.blocks)
|
self.num_decoder_layers = len(self.model.decoder.blocks)
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self.l_hooks = []
|
||||||
|
|
||||||
# model to detect end-of-word boundary at the end of the segment
|
# model to detect end-of-word boundary at the end of the segment
|
||||||
self.CIFLinear, self.always_fire, self.never_fire = load_cif(cfg,
|
self.CIFLinear, self.always_fire, self.never_fire = load_cif(cfg,
|
||||||
@@ -66,7 +104,8 @@ class PaddedAlignAttWhisper:
|
|||||||
t = F.softmax(net_output[1], dim=-1)
|
t = F.softmax(net_output[1], dim=-1)
|
||||||
self.dec_attns.append(t.squeeze(0))
|
self.dec_attns.append(t.squeeze(0))
|
||||||
for b in self.model.decoder.blocks:
|
for b in self.model.decoder.blocks:
|
||||||
b.cross_attn.register_forward_hook(layer_hook)
|
hook = b.cross_attn.register_forward_hook(layer_hook)
|
||||||
|
self.l_hooks.append(hook)
|
||||||
|
|
||||||
self.kv_cache = {}
|
self.kv_cache = {}
|
||||||
def kv_hook(module: torch.nn.Linear, _, net_output: torch.Tensor):
|
def kv_hook(module: torch.nn.Linear, _, net_output: torch.Tensor):
|
||||||
@@ -79,10 +118,13 @@ class PaddedAlignAttWhisper:
|
|||||||
return self.kv_cache[module.cache_id]
|
return self.kv_cache[module.cache_id]
|
||||||
|
|
||||||
for i,b in enumerate(self.model.decoder.blocks):
|
for i,b in enumerate(self.model.decoder.blocks):
|
||||||
b.attn.key.register_forward_hook(kv_hook)
|
hooks = [
|
||||||
b.attn.value.register_forward_hook(kv_hook)
|
b.attn.key.register_forward_hook(kv_hook),
|
||||||
b.cross_attn.key.register_forward_hook(kv_hook)
|
b.attn.value.register_forward_hook(kv_hook),
|
||||||
b.cross_attn.value.register_forward_hook(kv_hook)
|
b.cross_attn.key.register_forward_hook(kv_hook),
|
||||||
|
b.cross_attn.value.register_forward_hook(kv_hook),
|
||||||
|
]
|
||||||
|
self.l_hooks.extend(hooks)
|
||||||
|
|
||||||
self.align_source = {}
|
self.align_source = {}
|
||||||
self.num_align_heads = 0
|
self.num_align_heads = 0
|
||||||
@@ -117,6 +159,8 @@ class PaddedAlignAttWhisper:
|
|||||||
self.init_tokens()
|
self.init_tokens()
|
||||||
|
|
||||||
self.last_attend_frame = -self.cfg.rewind_threshold
|
self.last_attend_frame = -self.cfg.rewind_threshold
|
||||||
|
self.cumulative_time_offset = 0.0
|
||||||
|
self.first_timestamp = None
|
||||||
|
|
||||||
if self.cfg.max_context_tokens is None:
|
if self.cfg.max_context_tokens is None:
|
||||||
self.max_context_tokens = self.max_text_len
|
self.max_context_tokens = self.max_text_len
|
||||||
@@ -137,6 +181,22 @@ class PaddedAlignAttWhisper:
|
|||||||
|
|
||||||
self.token_decoder = BeamSearchDecoder(inference=self.inference, eot=self.tokenizer.eot, beam_size=cfg.beam_size)
|
self.token_decoder = BeamSearchDecoder(inference=self.inference, eot=self.tokenizer.eot, beam_size=cfg.beam_size)
|
||||||
|
|
||||||
|
# Tokens to carry over to next chunk for incomplete UTF-8 characters
|
||||||
|
self.pending_incomplete_tokens = []
|
||||||
|
|
||||||
|
def remove_hooks(self):
|
||||||
|
for hook in self.l_hooks:
|
||||||
|
hook.remove()
|
||||||
|
|
||||||
|
def warmup(self, audio):
|
||||||
|
try:
|
||||||
|
self.insert_audio(audio)
|
||||||
|
self.infer(is_last=True)
|
||||||
|
self.refresh_segment(complete=True)
|
||||||
|
logger.info("Model warmed up successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Model warmup failed: {e}")
|
||||||
|
|
||||||
def create_tokenizer(self, language=None):
|
def create_tokenizer(self, language=None):
|
||||||
self.tokenizer = tokenizer.get_tokenizer(
|
self.tokenizer = tokenizer.get_tokenizer(
|
||||||
multilingual=self.tokenizer_is_multilingual,
|
multilingual=self.tokenizer_is_multilingual,
|
||||||
@@ -206,17 +266,18 @@ class PaddedAlignAttWhisper:
|
|||||||
logger.debug("Refreshing segment:")
|
logger.debug("Refreshing segment:")
|
||||||
self.init_tokens()
|
self.init_tokens()
|
||||||
self.last_attend_frame = -self.cfg.rewind_threshold
|
self.last_attend_frame = -self.cfg.rewind_threshold
|
||||||
self.detected_language = None
|
# self.detected_language = None
|
||||||
|
self.cumulative_time_offset = 0.0
|
||||||
self.init_context()
|
self.init_context()
|
||||||
logger.debug(f"Context: {self.context}")
|
logger.debug(f"Context: {self.context}")
|
||||||
if not complete and len(self.segments) > 2:
|
if not complete and len(self.segments) > 2:
|
||||||
logger.debug("keeping last two segments because they are and it is not complete.")
|
|
||||||
self.segments = self.segments[-2:]
|
self.segments = self.segments[-2:]
|
||||||
else:
|
else:
|
||||||
logger.debug("removing all segments.")
|
logger.debug("removing all segments.")
|
||||||
self.segments = []
|
self.segments = []
|
||||||
self.log_segments += 1
|
self.log_segments += 1
|
||||||
|
|
||||||
|
self.pending_incomplete_tokens = []
|
||||||
|
|
||||||
def fire_at_boundary(self, chunked_encoder_feature: torch.Tensor):
|
def fire_at_boundary(self, chunked_encoder_feature: torch.Tensor):
|
||||||
if self.always_fire: return True
|
if self.always_fire: return True
|
||||||
@@ -274,10 +335,11 @@ class PaddedAlignAttWhisper:
|
|||||||
removed_len = self.segments[0].shape[0] / 16000
|
removed_len = self.segments[0].shape[0] / 16000
|
||||||
segments_len -= removed_len
|
segments_len -= removed_len
|
||||||
self.last_attend_frame -= int(TOKENS_PER_SECOND*removed_len)
|
self.last_attend_frame -= int(TOKENS_PER_SECOND*removed_len)
|
||||||
|
self.cumulative_time_offset += removed_len # Track cumulative time removed
|
||||||
self.segments = self.segments[1:]
|
self.segments = self.segments[1:]
|
||||||
logger.debug(f"remove segments: {len(self.segments)} {len(self.tokens)}")
|
logger.debug(f"remove segments: {len(self.segments)} {len(self.tokens)}, cumulative offset: {self.cumulative_time_offset:.2f}s")
|
||||||
if len(self.tokens) > 1:
|
if len(self.tokens) > 1:
|
||||||
self.context.append_token_ids(self.tokens[1][0,:])
|
self.context.append_token_ids(self.tokens[1][0,:].tolist())
|
||||||
self.tokens = [self.initial_tokens] + self.tokens[2:]
|
self.tokens = [self.initial_tokens] + self.tokens[2:]
|
||||||
return removed_len
|
return removed_len
|
||||||
|
|
||||||
@@ -331,11 +393,11 @@ class PaddedAlignAttWhisper:
|
|||||||
new_segment = True
|
new_segment = True
|
||||||
if len(self.segments) == 0:
|
if len(self.segments) == 0:
|
||||||
logger.debug("No segments, nothing to do")
|
logger.debug("No segments, nothing to do")
|
||||||
return [], {}
|
return []
|
||||||
if not self._apply_minseglen():
|
if not self._apply_minseglen():
|
||||||
logger.debug(f"applied minseglen {self.cfg.audio_min_len} > {self.segments_len()}.")
|
logger.debug(f"applied minseglen {self.cfg.audio_min_len} > {self.segments_len()}.")
|
||||||
input_segments = torch.cat(self.segments, dim=0)
|
input_segments = torch.cat(self.segments, dim=0)
|
||||||
return [], {}
|
return []
|
||||||
|
|
||||||
# input_segments is concatenation of audio, it's one array
|
# input_segments is concatenation of audio, it's one array
|
||||||
if len(self.segments) > 1:
|
if len(self.segments) > 1:
|
||||||
@@ -343,72 +405,90 @@ class PaddedAlignAttWhisper:
|
|||||||
else:
|
else:
|
||||||
input_segments = self.segments[0]
|
input_segments = self.segments[0]
|
||||||
|
|
||||||
|
beg_encode = time()
|
||||||
|
if self.use_mlcore:
|
||||||
# mel + padding to 30s
|
coreml_encoder, coreml_input_name, coreml_output_name = self.coreml_encoder_tuple
|
||||||
mel_padded = log_mel_spectrogram(input_segments, n_mels=self.model.dims.n_mels, padding=N_SAMPLES,
|
mel_padded = log_mel_spectrogram(
|
||||||
device=self.model.device).unsqueeze(0)
|
input_segments,
|
||||||
# trim to 3000
|
n_mels=self.model.dims.n_mels,
|
||||||
mel = pad_or_trim(mel_padded, N_FRAMES)
|
padding=N_SAMPLES,
|
||||||
|
device="cpu",
|
||||||
# the len of actual audio
|
).unsqueeze(0)
|
||||||
content_mel_len = int((mel_padded.shape[2] - mel.shape[2])/2)
|
mel = pad_or_trim(mel_padded, N_FRAMES)
|
||||||
|
content_mel_len = int((mel_padded.shape[2] - mel.shape[2]) / 2)
|
||||||
# encode
|
mel_np = np.ascontiguousarray(mel.numpy())
|
||||||
encoder_feature = self.model.encoder(mel)
|
ml_inputs = {coreml_input_name or "mel": mel_np}
|
||||||
|
coreml_outputs = coreml_encoder.predict(ml_inputs)
|
||||||
# logger.debug(f"Encoder feature shape: {encoder_feature.shape}")
|
if coreml_output_name and coreml_output_name in coreml_outputs:
|
||||||
# if mel.shape[-2:] != (self.model.dims.n_audio_ctx, self.model.dims.n_audio_state):
|
encoder_feature_np = coreml_outputs[coreml_output_name]
|
||||||
# logger.debug("mel ")
|
else:
|
||||||
if self.cfg.language == "auto" and self.detected_language is None:
|
encoder_feature_np = next(iter(coreml_outputs.values()))
|
||||||
language_tokens, language_probs = self.lang_id(encoder_feature)
|
encoder_feature = torch.as_tensor(
|
||||||
logger.debug(f"Language tokens: {language_tokens}, probs: {language_probs}")
|
np.array(encoder_feature_np),
|
||||||
top_lan, p = max(language_probs[0].items(), key=lambda x: x[1])
|
device=self.device,
|
||||||
logger.info(f"Detected language: {top_lan} with p={p:.4f}")
|
)
|
||||||
#self.tokenizer.language = top_lan
|
if self.mlx_encoder:
|
||||||
#self.tokenizer.__post_init__()
|
mlx_mel_padded = mlx_log_mel_spectrogram(audio=input_segments.detach(), n_mels=self.model.dims.n_mels, padding=N_SAMPLES)
|
||||||
self.create_tokenizer(top_lan)
|
mlx_mel = mlx_pad_or_trim(mlx_mel_padded, N_FRAMES, axis=-2)
|
||||||
self.detected_language = top_lan
|
mlx_encoder_feature = self.mlx_encoder.encoder(mlx_mel[None])
|
||||||
self.init_tokens()
|
encoder_feature = torch.as_tensor(mlx_encoder_feature)
|
||||||
logger.info(f"Tokenizer language: {self.tokenizer.language}, {self.tokenizer.sot_sequence_including_notimestamps}")
|
content_mel_len = int((mlx_mel_padded.shape[0] - mlx_mel.shape[0])/2)
|
||||||
|
elif self.fw_encoder:
|
||||||
|
audio_length_seconds = len(input_segments) / 16000
|
||||||
|
content_mel_len = int(audio_length_seconds * 100)//2
|
||||||
|
mel_padded_2 = self.fw_feature_extractor(waveform=input_segments.numpy(), padding=N_SAMPLES)[None, :]
|
||||||
|
mel = fw_pad_or_trim(mel_padded_2, N_FRAMES, axis=-1)
|
||||||
|
encoder_feature_ctranslate = self.fw_encoder.encode(mel)
|
||||||
|
if self.device == 'cpu': #it seems that on gpu, passing StorageView to torch.as_tensor fails and wrapping in the array works
|
||||||
|
encoder_feature_ctranslate = np.array(encoder_feature_ctranslate)
|
||||||
|
try:
|
||||||
|
encoder_feature = torch.as_tensor(encoder_feature_ctranslate, device=self.device)
|
||||||
|
except TypeError: # Normally the cpu condition should prevent having exceptions, but just in case:
|
||||||
|
encoder_feature = torch.as_tensor(np.array(encoder_feature_ctranslate), device=self.device)
|
||||||
|
else:
|
||||||
|
# mel + padding to 30s
|
||||||
|
mel_padded = log_mel_spectrogram(input_segments, n_mels=self.model.dims.n_mels, padding=N_SAMPLES,
|
||||||
|
device=self.device).unsqueeze(0)
|
||||||
|
# trim to 3000
|
||||||
|
mel = pad_or_trim(mel_padded, N_FRAMES)
|
||||||
|
# the len of actual audio
|
||||||
|
content_mel_len = int((mel_padded.shape[2] - mel.shape[2])/2)
|
||||||
|
encoder_feature = self.model.encoder(mel)
|
||||||
|
end_encode = time()
|
||||||
|
# print('Encoder duration:', end_encode-beg_encode)
|
||||||
|
|
||||||
|
if self.cfg.language == "auto" and self.detected_language is None and self.first_timestamp:
|
||||||
|
seconds_since_start = self.segments_len() - self.first_timestamp
|
||||||
|
if seconds_since_start >= 2.0:
|
||||||
|
language_tokens, language_probs = self.lang_id(encoder_feature)
|
||||||
|
top_lan, p = max(language_probs[0].items(), key=lambda x: x[1])
|
||||||
|
print(f"Detected language: {top_lan} with p={p:.4f}")
|
||||||
|
self.create_tokenizer(top_lan)
|
||||||
|
self.last_attend_frame = -self.cfg.rewind_threshold
|
||||||
|
self.cumulative_time_offset = 0.0
|
||||||
|
self.init_tokens()
|
||||||
|
self.init_context()
|
||||||
|
self.detected_language = top_lan
|
||||||
|
logger.info(f"Tokenizer language: {self.tokenizer.language}, {self.tokenizer.sot_sequence_including_notimestamps}")
|
||||||
|
|
||||||
self.trim_context()
|
self.trim_context()
|
||||||
current_tokens = self._current_tokens()
|
current_tokens = self._current_tokens()
|
||||||
#
|
|
||||||
fire_detected = self.fire_at_boundary(encoder_feature[:, :content_mel_len, :])
|
fire_detected = self.fire_at_boundary(encoder_feature[:, :content_mel_len, :])
|
||||||
|
|
||||||
|
|
||||||
####################### Decoding loop
|
sum_logprobs = torch.zeros(self.cfg.beam_size, device=self.device)
|
||||||
logger.info("Decoding loop starts\n")
|
|
||||||
|
|
||||||
sum_logprobs = torch.zeros(self.cfg.beam_size, device=mel.device)
|
|
||||||
completed = False
|
completed = False
|
||||||
|
# punctuation_stop = False
|
||||||
|
|
||||||
attn_of_alignment_heads = None
|
attn_of_alignment_heads = None
|
||||||
most_attended_frame = None
|
most_attended_frame = None
|
||||||
|
|
||||||
token_len_before_decoding = current_tokens.shape[1]
|
token_len_before_decoding = current_tokens.shape[1]
|
||||||
|
|
||||||
generation_progress = []
|
l_absolute_timestamps = []
|
||||||
generation = {
|
|
||||||
"starting_tokens": BeamTokens(current_tokens[0,:].clone(), self.cfg.beam_size),
|
|
||||||
"token_len_before_decoding": token_len_before_decoding,
|
|
||||||
#"fire_detected": fire_detected,
|
|
||||||
"frames_len": content_mel_len,
|
|
||||||
"frames_threshold": 4 if is_last else self.cfg.frame_threshold,
|
|
||||||
|
|
||||||
# to be filled later
|
|
||||||
"logits_starting": None,
|
|
||||||
|
|
||||||
# to be filled later
|
|
||||||
"no_speech_prob": None,
|
|
||||||
"no_speech": False,
|
|
||||||
|
|
||||||
# to be filled in the loop
|
|
||||||
"progress": generation_progress,
|
|
||||||
}
|
|
||||||
while not completed and current_tokens.shape[1] < self.max_text_len: # bos is 3 tokens
|
while not completed and current_tokens.shape[1] < self.max_text_len: # bos is 3 tokens
|
||||||
generation_progress_loop = []
|
|
||||||
|
|
||||||
if new_segment:
|
if new_segment:
|
||||||
tokens_for_logits = current_tokens
|
tokens_for_logits = current_tokens
|
||||||
@@ -417,50 +497,26 @@ class PaddedAlignAttWhisper:
|
|||||||
tokens_for_logits = current_tokens[:,-1:]
|
tokens_for_logits = current_tokens[:,-1:]
|
||||||
|
|
||||||
logits = self.logits(tokens_for_logits, encoder_feature) # B, len(tokens), token dict size
|
logits = self.logits(tokens_for_logits, encoder_feature) # B, len(tokens), token dict size
|
||||||
if new_segment:
|
|
||||||
generation["logits_starting"] = Logits(logits[:,:,:])
|
|
||||||
|
|
||||||
if new_segment and self.tokenizer.no_speech is not None:
|
if new_segment and self.tokenizer.no_speech is not None:
|
||||||
probs_at_sot = logits[:, self.sot_index, :].float().softmax(dim=-1)
|
probs_at_sot = logits[:, self.sot_index, :].float().softmax(dim=-1)
|
||||||
no_speech_probs = probs_at_sot[:, self.tokenizer.no_speech].tolist()
|
no_speech_probs = probs_at_sot[:, self.tokenizer.no_speech].tolist()
|
||||||
generation["no_speech_prob"] = no_speech_probs[0]
|
|
||||||
if no_speech_probs[0] > self.cfg.nonspeech_prob:
|
if no_speech_probs[0] > self.cfg.nonspeech_prob:
|
||||||
generation["no_speech"] = True
|
|
||||||
logger.info("no speech, stop")
|
logger.info("no speech, stop")
|
||||||
break
|
break
|
||||||
|
|
||||||
logits = logits[:, -1, :] # logits for the last token
|
logits = logits[:, -1, :] # logits for the last token
|
||||||
generation_progress_loop.append(("logits_before_suppress",Logits(logits)))
|
|
||||||
|
|
||||||
# supress blank tokens only at the beginning of the segment
|
# supress blank tokens only at the beginning of the segment
|
||||||
if new_segment:
|
if new_segment:
|
||||||
logits[:, self.tokenizer.encode(" ") + [self.tokenizer.eot]] = -np.inf
|
logits[:, self.tokenizer.encode(" ") + [self.tokenizer.eot]] = -np.inf
|
||||||
new_segment = False
|
new_segment = False
|
||||||
self.suppress_tokens(logits)
|
self.suppress_tokens(logits)
|
||||||
#generation_progress_loop.append(("logits_after_suppres",BeamLogits(logits[0,:].clone(), self.cfg.beam_size)))
|
|
||||||
generation_progress_loop.append(("logits_after_suppress",Logits(logits)))
|
|
||||||
|
|
||||||
current_tokens, completed = self.token_decoder.update(current_tokens, logits, sum_logprobs)
|
current_tokens, completed = self.token_decoder.update(current_tokens, logits, sum_logprobs)
|
||||||
generation_progress_loop.append(("beam_tokens",Tokens(current_tokens[:,-1].clone())))
|
|
||||||
generation_progress_loop.append(("sum_logprobs",sum_logprobs.tolist()))
|
|
||||||
generation_progress_loop.append(("completed",completed))
|
|
||||||
|
|
||||||
logger.debug(f"Decoding completed: {completed}, sum_logprobs: {sum_logprobs.tolist()}, tokens: ")
|
logger.debug(f"Decoding completed: {completed}, sum_logprobs: {sum_logprobs.tolist()}, tokens: ")
|
||||||
self.debug_print_tokens(current_tokens)
|
self.debug_print_tokens(current_tokens)
|
||||||
|
|
||||||
|
|
||||||
# if self.decoder_type == "beam":
|
|
||||||
# logger.debug(f"Finished sequences: {self.token_decoder.finished_sequences}")
|
|
||||||
|
|
||||||
# logprobs = F.log_softmax(logits.float(), dim=-1)
|
|
||||||
# idx = 0
|
|
||||||
# logger.debug(f"Beam search topk: {logprobs[idx].topk(self.cfg.beam_size + 1)}")
|
|
||||||
# logger.debug(f"Greedy search argmax: {logits.argmax(dim=-1)}")
|
|
||||||
# if completed:
|
|
||||||
# self.debug_print_tokens(current_tokens)
|
|
||||||
|
|
||||||
# logger.debug("decode stopped because decoder completed")
|
|
||||||
|
|
||||||
attn_of_alignment_heads = [[] for _ in range(self.num_align_heads)]
|
attn_of_alignment_heads = [[] for _ in range(self.num_align_heads)]
|
||||||
for i, attn_mat in enumerate(self.dec_attns):
|
for i, attn_mat in enumerate(self.dec_attns):
|
||||||
layer_rank = int(i % len(self.model.decoder.blocks))
|
layer_rank = int(i % len(self.model.decoder.blocks))
|
||||||
@@ -479,24 +535,24 @@ class PaddedAlignAttWhisper:
|
|||||||
t = torch.cat(mat, dim=1)
|
t = torch.cat(mat, dim=1)
|
||||||
tmp.append(t)
|
tmp.append(t)
|
||||||
attn_of_alignment_heads = torch.stack(tmp, dim=1)
|
attn_of_alignment_heads = torch.stack(tmp, dim=1)
|
||||||
# logger.debug(str(attn_of_alignment_heads.shape) + " tttady")
|
|
||||||
std, mean = torch.std_mean(attn_of_alignment_heads, dim=-2, keepdim=True, unbiased=False)
|
std, mean = torch.std_mean(attn_of_alignment_heads, dim=-2, keepdim=True, unbiased=False)
|
||||||
attn_of_alignment_heads = (attn_of_alignment_heads - mean) / std
|
attn_of_alignment_heads = (attn_of_alignment_heads - mean) / std
|
||||||
attn_of_alignment_heads = median_filter(attn_of_alignment_heads, 7) # from whisper.timing
|
attn_of_alignment_heads = median_filter(attn_of_alignment_heads, 7) # from whisper.timing
|
||||||
attn_of_alignment_heads = attn_of_alignment_heads.mean(dim=1)
|
attn_of_alignment_heads = attn_of_alignment_heads.mean(dim=1)
|
||||||
# logger.debug(str(attn_of_alignment_heads.shape) + " po mean")
|
|
||||||
attn_of_alignment_heads = attn_of_alignment_heads[:,:, :content_mel_len]
|
attn_of_alignment_heads = attn_of_alignment_heads[:,:, :content_mel_len]
|
||||||
# logger.debug(str(attn_of_alignment_heads.shape) + " pak ")
|
|
||||||
|
|
||||||
# for each beam, the most attended frame is:
|
# for each beam, the most attended frame is:
|
||||||
most_attended_frames = torch.argmax(attn_of_alignment_heads[:,-1,:], dim=-1)
|
most_attended_frames = torch.argmax(attn_of_alignment_heads[:,-1,:], dim=-1)
|
||||||
generation_progress_loop.append(("most_attended_frames",most_attended_frames.clone().tolist()))
|
|
||||||
|
# Calculate absolute timestamps accounting for cumulative offset
|
||||||
|
absolute_timestamps = [(frame * 0.02 + self.cumulative_time_offset) for frame in most_attended_frames.tolist()]
|
||||||
|
|
||||||
logger.debug(str(most_attended_frames.tolist()) + " most att frames")
|
logger.debug(str(most_attended_frames.tolist()) + " most att frames")
|
||||||
|
logger.debug(f"Absolute timestamps: {absolute_timestamps} (offset: {self.cumulative_time_offset:.2f}s)")
|
||||||
|
|
||||||
most_attended_frame = most_attended_frames[0].item()
|
most_attended_frame = most_attended_frames[0].item()
|
||||||
|
l_absolute_timestamps.append(absolute_timestamps[0])
|
||||||
|
|
||||||
|
|
||||||
generation_progress.append(dict(generation_progress_loop))
|
|
||||||
logger.debug("current tokens" + str(current_tokens.shape))
|
logger.debug("current tokens" + str(current_tokens.shape))
|
||||||
if completed:
|
if completed:
|
||||||
# # stripping the last token, the eot
|
# # stripping the last token, the eot
|
||||||
@@ -534,66 +590,71 @@ class PaddedAlignAttWhisper:
|
|||||||
self.tokenizer.decode([current_tokens[i, -1].item()])
|
self.tokenizer.decode([current_tokens[i, -1].item()])
|
||||||
))
|
))
|
||||||
|
|
||||||
# for k,v in generation.items():
|
|
||||||
# print(k,v,file=sys.stderr)
|
|
||||||
# for x in generation_progress:
|
|
||||||
# for y in x.items():
|
|
||||||
# print("\t\t",*y,file=sys.stderr)
|
|
||||||
# print("\t","----", file=sys.stderr)
|
|
||||||
# print("\t", "end of generation_progress_loop", file=sys.stderr)
|
|
||||||
# sys.exit(1)
|
|
||||||
####################### End of decoding loop
|
|
||||||
|
|
||||||
logger.info("End of decoding loop")
|
|
||||||
|
|
||||||
# if attn_of_alignment_heads is not None:
|
|
||||||
# seg_len = int(segment.shape[0] / 16000 * TOKENS_PER_SECOND)
|
|
||||||
|
|
||||||
# # Lets' now consider only the top hypothesis in the beam search
|
|
||||||
# top_beam_attn_of_alignment_heads = attn_of_alignment_heads[0]
|
|
||||||
|
|
||||||
# # debug print: how is the new token attended?
|
|
||||||
# new_token_attn = top_beam_attn_of_alignment_heads[token_len_before_decoding:, -seg_len:]
|
|
||||||
# logger.debug(f"New token attention shape: {new_token_attn.shape}")
|
|
||||||
# if new_token_attn.shape[0] == 0: # it's not attended in the current audio segment
|
|
||||||
# logger.debug("no token generated")
|
|
||||||
# else: # it is, and the max attention is:
|
|
||||||
# new_token_max_attn, _ = new_token_attn.max(dim=-1)
|
|
||||||
# logger.debug(f"segment max attention: {new_token_max_attn.mean().item()/len(self.segments)}")
|
|
||||||
|
|
||||||
|
|
||||||
# let's now operate only with the top beam hypothesis
|
|
||||||
tokens_to_split = current_tokens[0, token_len_before_decoding:]
|
tokens_to_split = current_tokens[0, token_len_before_decoding:]
|
||||||
if fire_detected or is_last:
|
|
||||||
|
# Prepend pending tokens from previous chunk if any
|
||||||
|
if self.pending_incomplete_tokens:
|
||||||
|
logger.debug(f"[UTF-8 Fix] Prepending {len(self.pending_incomplete_tokens)} pending tokens: {self.pending_incomplete_tokens}")
|
||||||
|
pending_tensor = torch.tensor(self.pending_incomplete_tokens, dtype=torch.long, device=self.device)
|
||||||
|
tokens_to_split = torch.cat([pending_tensor, tokens_to_split])
|
||||||
|
|
||||||
|
if fire_detected or is_last: #or punctuation_stop:
|
||||||
new_hypothesis = tokens_to_split.flatten().tolist()
|
new_hypothesis = tokens_to_split.flatten().tolist()
|
||||||
|
split_words, split_tokens = self.tokenizer.split_to_word_tokens(new_hypothesis)
|
||||||
else:
|
else:
|
||||||
# going to truncate the tokens after the last space
|
# going to truncate the tokens after the last space
|
||||||
split_words, split_tokens = self.tokenizer.split_to_word_tokens(tokens_to_split.tolist())
|
split_words, split_tokens = self.tokenizer.split_to_word_tokens(tokens_to_split.tolist())
|
||||||
generation["result"] = {"split_words": split_words[:-1], "split_tokens": split_tokens[:-1]}
|
|
||||||
generation["result_truncated"] = {"split_words": split_words[-1:], "split_tokens": split_tokens[-1:]}
|
|
||||||
|
|
||||||
# text_to_split = self.tokenizer.decode(tokens_to_split)
|
|
||||||
# logger.debug(f"text_to_split: {text_to_split}")
|
|
||||||
# logger.debug("text at current step: {}".format(text_to_split.replace(" ", "<space>")))
|
|
||||||
# text_before_space = " ".join(text_to_split.split(" ")[:-1])
|
|
||||||
# logger.debug("before the last space: {}".format(text_before_space.replace(" ", "<space>")))
|
|
||||||
if len(split_words) > 1:
|
if len(split_words) > 1:
|
||||||
new_hypothesis = [i for sublist in split_tokens[:-1] for i in sublist]
|
new_hypothesis = [i for sublist in split_tokens[:-1] for i in sublist]
|
||||||
else:
|
else:
|
||||||
new_hypothesis = []
|
new_hypothesis = []
|
||||||
|
|
||||||
|
|
||||||
### new hypothesis
|
|
||||||
logger.debug(f"new_hypothesis: {new_hypothesis}")
|
logger.debug(f"new_hypothesis: {new_hypothesis}")
|
||||||
new_tokens = torch.tensor([new_hypothesis], dtype=torch.long).repeat_interleave(self.cfg.beam_size, dim=0).to(
|
new_tokens = torch.tensor([new_hypothesis], dtype=torch.long).repeat_interleave(self.cfg.beam_size, dim=0).to(
|
||||||
device=self.model.device,
|
device=self.device,
|
||||||
)
|
)
|
||||||
self.tokens.append(new_tokens)
|
self.tokens.append(new_tokens)
|
||||||
# TODO: test if this is redundant or not
|
|
||||||
# ret = ret[ret<DEC_PAD]
|
|
||||||
|
|
||||||
logger.info(f"Output: {self.tokenizer.decode(new_hypothesis)}")
|
logger.info(f"Output: {self.tokenizer.decode(new_hypothesis)}")
|
||||||
|
|
||||||
self._clean_cache()
|
self._clean_cache()
|
||||||
|
|
||||||
return new_hypothesis, generation
|
if len(l_absolute_timestamps) >=2 and self.first_timestamp is None:
|
||||||
|
self.first_timestamp = l_absolute_timestamps[0]
|
||||||
|
|
||||||
|
|
||||||
|
timestamped_words = []
|
||||||
|
timestamp_idx = 0
|
||||||
|
replacement_char = "\ufffd"
|
||||||
|
for word, word_tokens in zip(split_words, split_tokens):
|
||||||
|
# Skip words containing incomplete UTF-8 from client output
|
||||||
|
if replacement_char in word:
|
||||||
|
logger.warning(f"[UTF-8 Filter] Skipping incomplete word from client output: {repr(word)}")
|
||||||
|
timestamp_idx += len(word_tokens)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_timestamp = l_absolute_timestamps[timestamp_idx]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
timestamp_idx += len(word_tokens)
|
||||||
|
|
||||||
|
timestamp_entry = ASRToken(
|
||||||
|
start=round(current_timestamp, 2),
|
||||||
|
end=round(current_timestamp + 0.1, 2),
|
||||||
|
text= word,
|
||||||
|
speaker=self.speaker,
|
||||||
|
detected_language=self.detected_language
|
||||||
|
).with_offset(
|
||||||
|
self.global_time_offset
|
||||||
|
)
|
||||||
|
timestamped_words.append(timestamp_entry)
|
||||||
|
|
||||||
|
# Hold incomplete tokens for next chunk
|
||||||
|
self.pending_incomplete_tokens = []
|
||||||
|
if split_words and replacement_char in split_words[-1]:
|
||||||
|
self.pending_incomplete_tokens = split_tokens[-1]
|
||||||
|
logger.warning(f"[UTF-8 Fix] Holding {len(self.pending_incomplete_tokens)} incomplete tokens for next chunk: {self.pending_incomplete_tokens}")
|
||||||
|
|
||||||
|
return timestamped_words
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class TokenBuffer:
|
|||||||
self.prefix_token_ids = prefix_token_ids
|
self.prefix_token_ids = prefix_token_ids
|
||||||
self.tokenizer = tokenizer
|
self.tokenizer = tokenizer
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.pending_token_ids = []
|
||||||
|
|
||||||
def as_token_ids(self, tokenizer=None):
|
def as_token_ids(self, tokenizer=None):
|
||||||
|
|
||||||
@@ -64,7 +65,26 @@ class TokenBuffer:
|
|||||||
def append_token_ids(self, token_ids):
|
def append_token_ids(self, token_ids):
|
||||||
tokenizer = self.tokenizer
|
tokenizer = self.tokenizer
|
||||||
assert tokenizer is not None, "Tokenizer is not set."
|
assert tokenizer is not None, "Tokenizer is not set."
|
||||||
self.text += self.tokenizer.decode(token_ids)
|
|
||||||
|
all_tokens = self.pending_token_ids + token_ids
|
||||||
|
|
||||||
|
decoded = tokenizer.decode(all_tokens)
|
||||||
|
replacement_char = "\ufffd"
|
||||||
|
|
||||||
|
if replacement_char in decoded:
|
||||||
|
if len(all_tokens) > 1:
|
||||||
|
decoded_partial = tokenizer.decode(all_tokens[:-1])
|
||||||
|
|
||||||
|
if replacement_char not in decoded_partial:
|
||||||
|
self.text += decoded_partial
|
||||||
|
self.pending_token_ids = [all_tokens[-1]]
|
||||||
|
else:
|
||||||
|
self.pending_token_ids = all_tokens
|
||||||
|
else:
|
||||||
|
self.pending_token_ids = all_tokens
|
||||||
|
else:
|
||||||
|
self.text += decoded
|
||||||
|
self.pending_token_ids = []
|
||||||
|
|
||||||
def as_split_word_tokens(self):
|
def as_split_word_tokens(self):
|
||||||
tokenizer = self.tokenizer
|
tokenizer = self.tokenizer
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import urllib
|
|
||||||
import warnings
|
|
||||||
from typing import List, Optional, Union
|
|
||||||
|
|
||||||
import torch
|
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
from .audio import load_audio, log_mel_spectrogram, pad_or_trim
|
|
||||||
from .decoding import DecodingOptions, DecodingResult, decode, detect_language
|
|
||||||
from .model import ModelDimensions, Whisper
|
|
||||||
from .transcribe import transcribe
|
|
||||||
from .version import __version__
|
|
||||||
|
|
||||||
_MODELS = {
|
|
||||||
"tiny.en": "https://openaipublic.azureedge.net/main/whisper/models/d3dd57d32accea0b295c96e26691aa14d8822fac7d9d27d5dc00b4ca2826dd03/tiny.en.pt",
|
|
||||||
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt",
|
|
||||||
"base.en": "https://openaipublic.azureedge.net/main/whisper/models/25a8566e1d0c1e2231d1c762132cd20e0f96a85d16145c3a00adf5d1ac670ead/base.en.pt",
|
|
||||||
"base": "https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt",
|
|
||||||
"small.en": "https://openaipublic.azureedge.net/main/whisper/models/f953ad0fd29cacd07d5a9eda5624af0f6bcf2258be67c92b79389873d91e0872/small.en.pt",
|
|
||||||
"small": "https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt",
|
|
||||||
"medium.en": "https://openaipublic.azureedge.net/main/whisper/models/d7440d1dc186f76616474e0ff0b3b6b879abc9d1a4926b7adfa41db2d497ab4f/medium.en.pt",
|
|
||||||
"medium": "https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt",
|
|
||||||
"large-v1": "https://openaipublic.azureedge.net/main/whisper/models/e4b87e7e0bf463eb8e6956e646f1e277e901512310def2c24bf0e11bd3c28e9a/large-v1.pt",
|
|
||||||
"large-v2": "https://openaipublic.azureedge.net/main/whisper/models/81f7c96c852ee8fc832187b0132e569d6c3065a3252ed18e56effd0b6a73e524/large-v2.pt",
|
|
||||||
"large-v3": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
|
|
||||||
"large": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
|
|
||||||
"large-v3-turbo": "https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt",
|
|
||||||
"turbo": "https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt",
|
|
||||||
}
|
|
||||||
|
|
||||||
# base85-encoded (n_layers, n_heads) boolean arrays indicating the cross-attention heads that are
|
|
||||||
# highly correlated to the word-level timing, i.e. the alignment between audio and text tokens.
|
|
||||||
_ALIGNMENT_HEADS = {
|
|
||||||
"tiny.en": b"ABzY8J1N>@0{>%R00Bk>$p{7v037`oCl~+#00",
|
|
||||||
"tiny": b"ABzY8bu8Lr0{>%RKn9Fp%m@SkK7Kt=7ytkO",
|
|
||||||
"base.en": b"ABzY8;40c<0{>%RzzG;p*o+Vo09|#PsxSZm00",
|
|
||||||
"base": b"ABzY8KQ!870{>%RzyTQH3`Q^yNP!>##QT-<FaQ7m",
|
|
||||||
"small.en": b"ABzY8>?_)10{>%RpeA61k&I|OI3I$65C{;;pbCHh0B{qLQ;+}v00",
|
|
||||||
"small": b"ABzY8DmU6=0{>%Rpa?J`kvJ6qF(V^F86#Xh7JUGMK}P<N0000",
|
|
||||||
"medium.en": b"ABzY8usPae0{>%R7<zz_OvQ{)4kMa0BMw6u5rT}kRKX;$NfYBv00*Hl@qhsU00",
|
|
||||||
"medium": b"ABzY8B0Jh+0{>%R7}kK1fFL7w6%<-Pf*t^=N)Qr&0RR9",
|
|
||||||
"large-v1": b"ABzY8r9j$a0{>%R7#4sLmoOs{s)o3~84-RPdcFk!JR<kSfC2yj",
|
|
||||||
"large-v2": b"ABzY8zd+h!0{>%R7=D0pU<_bnWW*tkYAhobTNnu$jnkEkXqp)j;w1Tzk)UH3X%SZd&fFZ2fC2yj",
|
|
||||||
"large-v3": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
|
|
||||||
"large": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
|
|
||||||
"large-v3-turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
|
|
||||||
"turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _download(url: str, root: str, in_memory: bool) -> Union[bytes, str]:
|
|
||||||
os.makedirs(root, exist_ok=True)
|
|
||||||
|
|
||||||
expected_sha256 = url.split("/")[-2]
|
|
||||||
download_target = os.path.join(root, os.path.basename(url))
|
|
||||||
|
|
||||||
if os.path.exists(download_target) and not os.path.isfile(download_target):
|
|
||||||
raise RuntimeError(f"{download_target} exists and is not a regular file")
|
|
||||||
|
|
||||||
if os.path.isfile(download_target):
|
|
||||||
with open(download_target, "rb") as f:
|
|
||||||
model_bytes = f.read()
|
|
||||||
if hashlib.sha256(model_bytes).hexdigest() == expected_sha256:
|
|
||||||
return model_bytes if in_memory else download_target
|
|
||||||
else:
|
|
||||||
warnings.warn(
|
|
||||||
f"{download_target} exists, but the SHA256 checksum does not match; re-downloading the file"
|
|
||||||
)
|
|
||||||
|
|
||||||
with urllib.request.urlopen(url) as source, open(download_target, "wb") as output:
|
|
||||||
with tqdm(
|
|
||||||
total=int(source.info().get("Content-Length")),
|
|
||||||
ncols=80,
|
|
||||||
unit="iB",
|
|
||||||
unit_scale=True,
|
|
||||||
unit_divisor=1024,
|
|
||||||
) as loop:
|
|
||||||
while True:
|
|
||||||
buffer = source.read(8192)
|
|
||||||
if not buffer:
|
|
||||||
break
|
|
||||||
|
|
||||||
output.write(buffer)
|
|
||||||
loop.update(len(buffer))
|
|
||||||
|
|
||||||
model_bytes = open(download_target, "rb").read()
|
|
||||||
if hashlib.sha256(model_bytes).hexdigest() != expected_sha256:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Model has been downloaded but the SHA256 checksum does not not match. Please retry loading the model."
|
|
||||||
)
|
|
||||||
|
|
||||||
return model_bytes if in_memory else download_target
|
|
||||||
|
|
||||||
|
|
||||||
def available_models() -> List[str]:
|
|
||||||
"""Returns the names of available models"""
|
|
||||||
return list(_MODELS.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def load_model(
|
|
||||||
name: str,
|
|
||||||
device: Optional[Union[str, torch.device]] = None,
|
|
||||||
download_root: str = None,
|
|
||||||
in_memory: bool = False,
|
|
||||||
) -> Whisper:
|
|
||||||
"""
|
|
||||||
Load a Whisper ASR model
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
name : str
|
|
||||||
one of the official model names listed by `whisper.available_models()`, or
|
|
||||||
path to a model checkpoint containing the model dimensions and the model state_dict.
|
|
||||||
device : Union[str, torch.device]
|
|
||||||
the PyTorch device to put the model into
|
|
||||||
download_root: str
|
|
||||||
path to download the model files; by default, it uses "~/.cache/whisper"
|
|
||||||
in_memory: bool
|
|
||||||
whether to preload the model weights into host memory
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
model : Whisper
|
|
||||||
The Whisper ASR model instance
|
|
||||||
"""
|
|
||||||
|
|
||||||
if device is None:
|
|
||||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
if download_root is None:
|
|
||||||
default = os.path.join(os.path.expanduser("~"), ".cache")
|
|
||||||
download_root = os.path.join(os.getenv("XDG_CACHE_HOME", default), "whisper")
|
|
||||||
|
|
||||||
if name in _MODELS:
|
|
||||||
checkpoint_file = _download(_MODELS[name], download_root, in_memory)
|
|
||||||
alignment_heads = _ALIGNMENT_HEADS[name]
|
|
||||||
elif os.path.isfile(name):
|
|
||||||
checkpoint_file = open(name, "rb").read() if in_memory else name
|
|
||||||
alignment_heads = None
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Model {name} not found; available models = {available_models()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
io.BytesIO(checkpoint_file) if in_memory else open(checkpoint_file, "rb")
|
|
||||||
) as fp:
|
|
||||||
checkpoint = torch.load(fp, map_location=device)
|
|
||||||
del checkpoint_file
|
|
||||||
|
|
||||||
dims = ModelDimensions(**checkpoint["dims"])
|
|
||||||
model = Whisper(dims)
|
|
||||||
model.load_state_dict(checkpoint["model_state_dict"])
|
|
||||||
|
|
||||||
if alignment_heads is not None:
|
|
||||||
model.set_alignment_heads(alignment_heads)
|
|
||||||
|
|
||||||
return model.to(device)
|
|
||||||
@@ -1,20 +1,52 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional, List, Union, Dict, Any
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
PUNCTUATION_MARKS = {'.', '!', '?', '。', '!', '?'}
|
||||||
|
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
"""Format seconds as HH:MM:SS."""
|
||||||
|
return str(timedelta(seconds=int(seconds)))
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TimedText:
|
class Timed:
|
||||||
start: Optional[float]
|
start: Optional[float] = 0
|
||||||
end: Optional[float]
|
end: Optional[float] = 0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimedText(Timed):
|
||||||
text: Optional[str] = ''
|
text: Optional[str] = ''
|
||||||
speaker: Optional[int] = -1
|
speaker: Optional[int] = -1
|
||||||
probability: Optional[float] = None
|
detected_language: Optional[str] = None
|
||||||
is_dummy: Optional[bool] = False
|
|
||||||
|
def has_punctuation(self) -> bool:
|
||||||
|
return any(char in PUNCTUATION_MARKS for char in self.text.strip())
|
||||||
|
|
||||||
|
def is_within(self, other: 'TimedText') -> bool:
|
||||||
|
return other.contains_timespan(self)
|
||||||
|
|
||||||
@dataclass
|
def duration(self) -> float:
|
||||||
|
return self.end - self.start
|
||||||
|
|
||||||
|
def contains_timespan(self, other: 'TimedText') -> bool:
|
||||||
|
return self.start <= other.start and self.end >= other.end
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self.text)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.text)
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
class ASRToken(TimedText):
|
class ASRToken(TimedText):
|
||||||
|
|
||||||
def with_offset(self, offset: float) -> "ASRToken":
|
def with_offset(self, offset: float) -> "ASRToken":
|
||||||
"""Return a new token with the time offset added."""
|
"""Return a new token with the time offset added."""
|
||||||
return ASRToken(self.start + offset, self.end + offset, self.text, self.speaker, self.probability)
|
return ASRToken(self.start + offset, self.end + offset, self.text, self.speaker, detected_language=self.detected_language)
|
||||||
|
|
||||||
|
def is_silence(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Sentence(TimedText):
|
class Sentence(TimedText):
|
||||||
@@ -22,11 +54,193 @@ class Sentence(TimedText):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Transcript(TimedText):
|
class Transcript(TimedText):
|
||||||
pass
|
"""
|
||||||
|
represents a concatenation of several ASRToken
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tokens(
|
||||||
|
cls,
|
||||||
|
tokens: List[ASRToken],
|
||||||
|
sep: Optional[str] = None,
|
||||||
|
offset: float = 0
|
||||||
|
) -> "Transcript":
|
||||||
|
"""Collapse multiple ASR tokens into a single transcript span."""
|
||||||
|
sep = sep if sep is not None else ' '
|
||||||
|
text = sep.join(token.text for token in tokens)
|
||||||
|
if tokens:
|
||||||
|
start = offset + tokens[0].start
|
||||||
|
end = offset + tokens[-1].end
|
||||||
|
else:
|
||||||
|
start = None
|
||||||
|
end = None
|
||||||
|
return cls(start, end, text)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SpeakerSegment(TimedText):
|
class SpeakerSegment(Timed):
|
||||||
"""Represents a segment of audio attributed to a specific speaker.
|
"""Represents a segment of audio attributed to a specific speaker.
|
||||||
No text nor probability is associated with this segment.
|
No text nor probability is associated with this segment.
|
||||||
"""
|
"""
|
||||||
pass
|
speaker: Optional[int] = -1
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Translation(TimedText):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Silence():
|
||||||
|
start: Optional[float] = None
|
||||||
|
end: Optional[float] = None
|
||||||
|
duration: Optional[float] = None
|
||||||
|
is_starting: bool = False
|
||||||
|
has_ended: bool = False
|
||||||
|
|
||||||
|
def compute_duration(self) -> Optional[float]:
|
||||||
|
if self.start is None or self.end is None:
|
||||||
|
return None
|
||||||
|
self.duration = self.end - self.start
|
||||||
|
return self.duration
|
||||||
|
|
||||||
|
def is_silence(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Segment(TimedText):
|
||||||
|
"""Generic contiguous span built from tokens or silence markers."""
|
||||||
|
start: Optional[float]
|
||||||
|
end: Optional[float]
|
||||||
|
text: Optional[str]
|
||||||
|
speaker: Optional[str]
|
||||||
|
@classmethod
|
||||||
|
def from_tokens(
|
||||||
|
cls,
|
||||||
|
tokens: List[Union[ASRToken, Silence]],
|
||||||
|
is_silence: bool = False
|
||||||
|
) -> Optional["Segment"]:
|
||||||
|
"""Return a normalized segment representing the provided tokens."""
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_token = tokens[0]
|
||||||
|
end_token = tokens[-1]
|
||||||
|
if is_silence:
|
||||||
|
return cls(
|
||||||
|
start=start_token.start,
|
||||||
|
end=end_token.end,
|
||||||
|
text=None,
|
||||||
|
speaker=-2
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return cls(
|
||||||
|
start=start_token.start,
|
||||||
|
end=end_token.end,
|
||||||
|
text=''.join(token.text for token in tokens),
|
||||||
|
speaker=-1,
|
||||||
|
detected_language=start_token.detected_language
|
||||||
|
)
|
||||||
|
def is_silence(self) -> bool:
|
||||||
|
"""True when this segment represents a silence gap."""
|
||||||
|
return self.speaker == -2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Line(TimedText):
|
||||||
|
translation: str = ''
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize the line for frontend consumption."""
|
||||||
|
_dict: Dict[str, Any] = {
|
||||||
|
'speaker': int(self.speaker) if self.speaker != -1 else 1,
|
||||||
|
'text': self.text,
|
||||||
|
'start': format_time(self.start),
|
||||||
|
'end': format_time(self.end),
|
||||||
|
}
|
||||||
|
if self.translation:
|
||||||
|
_dict['translation'] = self.translation
|
||||||
|
if self.detected_language:
|
||||||
|
_dict['detected_language'] = self.detected_language
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
def build_from_tokens(self, tokens: List[ASRToken]) -> "Line":
|
||||||
|
"""Populate line attributes from a contiguous token list."""
|
||||||
|
self.text = ''.join([token.text for token in tokens])
|
||||||
|
self.start = tokens[0].start
|
||||||
|
self.end = tokens[-1].end
|
||||||
|
self.speaker = 1
|
||||||
|
self.detected_language = tokens[0].detected_language
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build_from_segment(self, segment: Segment) -> "Line":
|
||||||
|
"""Populate the line fields from a pre-built segment."""
|
||||||
|
self.text = segment.text
|
||||||
|
self.start = segment.start
|
||||||
|
self.end = segment.end
|
||||||
|
self.speaker = segment.speaker
|
||||||
|
self.detected_language = segment.detected_language
|
||||||
|
return self
|
||||||
|
|
||||||
|
def is_silent(self) -> bool:
|
||||||
|
return self.speaker == -2
|
||||||
|
|
||||||
|
class SilentLine(Line):
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.speaker = -2
|
||||||
|
self.text = ''
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrontData():
|
||||||
|
status: str = ''
|
||||||
|
error: str = ''
|
||||||
|
lines: list[Line] = field(default_factory=list)
|
||||||
|
buffer_transcription: str = ''
|
||||||
|
buffer_diarization: str = ''
|
||||||
|
buffer_translation: str = ''
|
||||||
|
remaining_time_transcription: float = 0.
|
||||||
|
remaining_time_diarization: float = 0.
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize the front-end data payload."""
|
||||||
|
_dict: Dict[str, Any] = {
|
||||||
|
'status': self.status,
|
||||||
|
'lines': [line.to_dict() for line in self.lines if (line.text or line.speaker == -2)],
|
||||||
|
'buffer_transcription': self.buffer_transcription,
|
||||||
|
'buffer_diarization': self.buffer_diarization,
|
||||||
|
'buffer_translation': self.buffer_translation,
|
||||||
|
'remaining_time_transcription': self.remaining_time_transcription,
|
||||||
|
'remaining_time_diarization': self.remaining_time_diarization,
|
||||||
|
}
|
||||||
|
if self.error:
|
||||||
|
_dict['error'] = self.error
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChangeSpeaker:
|
||||||
|
speaker: int
|
||||||
|
start: int
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class State():
|
||||||
|
"""Unified state class for audio processing.
|
||||||
|
|
||||||
|
Contains both persistent state (tokens, buffers) and temporary update buffers
|
||||||
|
(new_* fields) that are consumed by TokensAlignment.
|
||||||
|
"""
|
||||||
|
# Persistent state
|
||||||
|
tokens: List[ASRToken] = field(default_factory=list)
|
||||||
|
buffer_transcription: Transcript = field(default_factory=Transcript)
|
||||||
|
end_buffer: float = 0.0
|
||||||
|
end_attributed_speaker: float = 0.0
|
||||||
|
remaining_time_transcription: float = 0.0
|
||||||
|
remaining_time_diarization: float = 0.0
|
||||||
|
|
||||||
|
# Temporary update buffers (consumed by TokensAlignment.update())
|
||||||
|
new_tokens: List[Union[ASRToken, Silence]] = field(default_factory=list)
|
||||||
|
new_translation: List[Any] = field(default_factory=list)
|
||||||
|
new_diarization: List[Any] = field(default_factory=list)
|
||||||
|
new_tokens_buffer: List[Any] = field(default_factory=list) # only when local agreement
|
||||||
|
new_translation_buffer= TimedText()
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import torch
|
|
||||||
import sys
|
|
||||||
class TokenBuffer:
|
|
||||||
|
|
||||||
def __init__(self, text="", tokenizer=None, device=None, prefix_token_ids=[]):
|
|
||||||
self.text = text
|
|
||||||
self.prefix_token_ids = prefix_token_ids
|
|
||||||
self.tokenizer = tokenizer
|
|
||||||
self.device = device
|
|
||||||
|
|
||||||
def as_token_ids(self, tokenizer=None):
|
|
||||||
|
|
||||||
if tokenizer is None:
|
|
||||||
tokenizer = self.tokenizer
|
|
||||||
if tokenizer is None:
|
|
||||||
raise ValueError("Tokenizer is not set.")
|
|
||||||
return self.prefix_token_ids + tokenizer.encode(self.text)
|
|
||||||
|
|
||||||
def as_tensor(self, device=None):
|
|
||||||
if device is None:
|
|
||||||
device = self.device
|
|
||||||
if device is None:
|
|
||||||
raise ValueError("Device is not set.")
|
|
||||||
tok_ids = self.as_token_ids()
|
|
||||||
return torch.tensor(tok_ids,
|
|
||||||
dtype=torch.long, device=device).unsqueeze(0)
|
|
||||||
|
|
||||||
def as_tensor_beam(self, beam, device=None):
|
|
||||||
t = self.as_tensor(device=device)
|
|
||||||
return t.repeat_interleave(beam, dim=0)
|
|
||||||
|
|
||||||
|
|
||||||
def as_text(self):
|
|
||||||
return self.text
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def empty(*a, **kw):
|
|
||||||
return TokenBuffer(*a,**kw)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_text(text, *a, **kw):
|
|
||||||
return TokenBuffer(*a, text=text, **kw)
|
|
||||||
|
|
||||||
def is_empty(self):
|
|
||||||
return self.text is None or self.text == ""
|
|
||||||
|
|
||||||
def trim_words(self, num=1, after=0):
|
|
||||||
'''
|
|
||||||
num: how many words to trim from the beginning
|
|
||||||
after: how many characters to skip (length of the static prompt)
|
|
||||||
'''
|
|
||||||
tokenizer = self.tokenizer
|
|
||||||
assert tokenizer is not None, "Tokenizer is not set."
|
|
||||||
|
|
||||||
ids = tokenizer.encode(self.text[after:])
|
|
||||||
words, wids = self.tokenizer.split_to_word_tokens(ids)
|
|
||||||
print(words, file=sys.stderr)
|
|
||||||
print(wids, file=sys.stderr)
|
|
||||||
if not words:
|
|
||||||
return 0
|
|
||||||
self.text = self.text[:after] + "".join(words[num:])
|
|
||||||
return sum(len(wi) for wi in wids[:num])
|
|
||||||
|
|
||||||
def append_token_ids(self, token_ids):
|
|
||||||
tokenizer = self.tokenizer
|
|
||||||
assert tokenizer is not None, "Tokenizer is not set."
|
|
||||||
self.text += self.tokenizer.decode(token_ids)
|
|
||||||
|
|
||||||
def as_split_word_tokens(self):
|
|
||||||
tokenizer = self.tokenizer
|
|
||||||
assert tokenizer is not None, "Tokenizer is not set."
|
|
||||||
ids = tokenizer.encode(self.text)
|
|
||||||
return tokenizer.split_to_word_tokens(ids)
|
|
||||||
177
whisperlivekit/tokens_alignment.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from time import time
|
||||||
|
from typing import Optional, List, Tuple, Union, Any
|
||||||
|
|
||||||
|
from whisperlivekit.timed_objects import Line, SilentLine, ASRToken, SpeakerSegment, Silence, TimedText, Segment
|
||||||
|
|
||||||
|
|
||||||
|
class TokensAlignment:
|
||||||
|
|
||||||
|
def __init__(self, state: Any, args: Any, sep: Optional[str]) -> None:
|
||||||
|
self.state = state
|
||||||
|
self.diarization = args.diarization
|
||||||
|
self._tokens_index: int = 0
|
||||||
|
self._diarization_index: int = 0
|
||||||
|
self._translation_index: int = 0
|
||||||
|
|
||||||
|
self.all_tokens: List[ASRToken] = []
|
||||||
|
self.all_diarization_segments: List[SpeakerSegment] = []
|
||||||
|
self.all_translation_segments: List[Any] = []
|
||||||
|
|
||||||
|
self.new_tokens: List[ASRToken] = []
|
||||||
|
self.new_diarization: List[SpeakerSegment] = []
|
||||||
|
self.new_translation: List[Any] = []
|
||||||
|
self.new_translation_buffer: Union[TimedText, str] = TimedText()
|
||||||
|
self.new_tokens_buffer: List[Any] = []
|
||||||
|
self.sep: str = sep if sep is not None else ' '
|
||||||
|
self.beg_loop: Optional[float] = None
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Drain state buffers into the running alignment context."""
|
||||||
|
self.new_tokens, self.state.new_tokens = self.state.new_tokens, []
|
||||||
|
self.new_diarization, self.state.new_diarization = self.state.new_diarization, []
|
||||||
|
self.new_translation, self.state.new_translation = self.state.new_translation, []
|
||||||
|
self.new_tokens_buffer, self.state.new_tokens_buffer = self.state.new_tokens_buffer, []
|
||||||
|
|
||||||
|
self.all_tokens.extend(self.new_tokens)
|
||||||
|
self.all_diarization_segments.extend(self.new_diarization)
|
||||||
|
self.all_translation_segments.extend(self.new_translation)
|
||||||
|
self.new_translation_buffer = self.state.new_translation_buffer
|
||||||
|
|
||||||
|
def add_translation(self, line: Line) -> None:
|
||||||
|
"""Append translated text segments that overlap with a line."""
|
||||||
|
for ts in self.all_translation_segments:
|
||||||
|
if ts.is_within(line):
|
||||||
|
line.translation += ts.text + (self.sep if ts.text else '')
|
||||||
|
elif line.translation:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def compute_punctuations_segments(self, tokens: Optional[List[ASRToken]] = None) -> List[Segment]:
|
||||||
|
"""Group tokens into segments split by punctuation and explicit silence."""
|
||||||
|
segments = []
|
||||||
|
segment_start_idx = 0
|
||||||
|
for i, token in enumerate(self.all_tokens):
|
||||||
|
if token.is_silence():
|
||||||
|
previous_segment = Segment.from_tokens(
|
||||||
|
tokens=self.all_tokens[segment_start_idx: i],
|
||||||
|
)
|
||||||
|
if previous_segment:
|
||||||
|
segments.append(previous_segment)
|
||||||
|
segment = Segment.from_tokens(
|
||||||
|
tokens=[token],
|
||||||
|
is_silence=True
|
||||||
|
)
|
||||||
|
segments.append(segment)
|
||||||
|
segment_start_idx = i+1
|
||||||
|
else:
|
||||||
|
if token.has_punctuation():
|
||||||
|
segment = Segment.from_tokens(
|
||||||
|
tokens=self.all_tokens[segment_start_idx: i+1],
|
||||||
|
)
|
||||||
|
segments.append(segment)
|
||||||
|
segment_start_idx = i+1
|
||||||
|
|
||||||
|
final_segment = Segment.from_tokens(
|
||||||
|
tokens=self.all_tokens[segment_start_idx:],
|
||||||
|
)
|
||||||
|
if final_segment:
|
||||||
|
segments.append(final_segment)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def concatenate_diar_segments(self) -> List[SpeakerSegment]:
|
||||||
|
"""Merge consecutive diarization slices that share the same speaker."""
|
||||||
|
if not self.all_diarization_segments:
|
||||||
|
return []
|
||||||
|
merged = [self.all_diarization_segments[0]]
|
||||||
|
for segment in self.all_diarization_segments[1:]:
|
||||||
|
if segment.speaker == merged[-1].speaker:
|
||||||
|
merged[-1].end = segment.end
|
||||||
|
else:
|
||||||
|
merged.append(segment)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def intersection_duration(seg1: TimedText, seg2: TimedText) -> float:
|
||||||
|
"""Return the overlap duration between two timed segments."""
|
||||||
|
start = max(seg1.start, seg2.start)
|
||||||
|
end = min(seg1.end, seg2.end)
|
||||||
|
|
||||||
|
return max(0, end - start)
|
||||||
|
|
||||||
|
def get_lines_diarization(self) -> Tuple[List[Line], str]:
|
||||||
|
"""Build lines when diarization is enabled and track overflow buffer."""
|
||||||
|
diarization_buffer = ''
|
||||||
|
punctuation_segments = self.compute_punctuations_segments()
|
||||||
|
diarization_segments = self.concatenate_diar_segments()
|
||||||
|
for punctuation_segment in punctuation_segments:
|
||||||
|
if not punctuation_segment.is_silence():
|
||||||
|
if diarization_segments and punctuation_segment.start >= diarization_segments[-1].end:
|
||||||
|
diarization_buffer += punctuation_segment.text
|
||||||
|
else:
|
||||||
|
max_overlap = 0.0
|
||||||
|
max_overlap_speaker = 1
|
||||||
|
for diarization_segment in diarization_segments:
|
||||||
|
intersec = self.intersection_duration(punctuation_segment, diarization_segment)
|
||||||
|
if intersec > max_overlap:
|
||||||
|
max_overlap = intersec
|
||||||
|
max_overlap_speaker = diarization_segment.speaker + 1
|
||||||
|
punctuation_segment.speaker = max_overlap_speaker
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if punctuation_segments:
|
||||||
|
lines = [Line().build_from_segment(punctuation_segments[0])]
|
||||||
|
for segment in punctuation_segments[1:]:
|
||||||
|
if segment.speaker == lines[-1].speaker:
|
||||||
|
if lines[-1].text:
|
||||||
|
lines[-1].text += segment.text
|
||||||
|
lines[-1].end = segment.end
|
||||||
|
else:
|
||||||
|
lines.append(Line().build_from_segment(segment))
|
||||||
|
|
||||||
|
return lines, diarization_buffer
|
||||||
|
|
||||||
|
|
||||||
|
def get_lines(
|
||||||
|
self,
|
||||||
|
diarization: bool = False,
|
||||||
|
translation: bool = False,
|
||||||
|
current_silence: Optional[Silence] = None
|
||||||
|
) -> Tuple[List[Line], str, Union[str, TimedText]]:
|
||||||
|
"""Return the formatted lines plus buffers, optionally with diarization/translation."""
|
||||||
|
if diarization:
|
||||||
|
lines, diarization_buffer = self.get_lines_diarization()
|
||||||
|
else:
|
||||||
|
diarization_buffer = ''
|
||||||
|
lines = []
|
||||||
|
current_line_tokens = []
|
||||||
|
for token in self.all_tokens:
|
||||||
|
if token.is_silence():
|
||||||
|
if current_line_tokens:
|
||||||
|
lines.append(Line().build_from_tokens(current_line_tokens))
|
||||||
|
current_line_tokens = []
|
||||||
|
end_silence = token.end if token.has_ended else time() - self.beg_loop
|
||||||
|
if lines and lines[-1].is_silent():
|
||||||
|
lines[-1].end = end_silence
|
||||||
|
else:
|
||||||
|
lines.append(SilentLine(
|
||||||
|
start = token.start,
|
||||||
|
end = end_silence
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
current_line_tokens.append(token)
|
||||||
|
if current_line_tokens:
|
||||||
|
lines.append(Line().build_from_tokens(current_line_tokens))
|
||||||
|
if current_silence:
|
||||||
|
end_silence = current_silence.end if current_silence.has_ended else time() - self.beg_loop
|
||||||
|
if lines and lines[-1].is_silent():
|
||||||
|
lines[-1].end = end_silence
|
||||||
|
else:
|
||||||
|
lines.append(SilentLine(
|
||||||
|
start = current_silence.start,
|
||||||
|
end = end_silence
|
||||||
|
))
|
||||||
|
if translation:
|
||||||
|
[self.add_translation(line) for line in lines if not type(line) == Silence]
|
||||||
|
return lines, diarization_buffer, self.new_translation_buffer.text
|
||||||
BIN
whisperlivekit/vad_models/silero_vad.jit
Normal file
BIN
whisperlivekit/vad_models/silero_vad.onnx
Normal file
BIN
whisperlivekit/vad_models/silero_vad_16k_op15.onnx
Normal file
BIN
whisperlivekit/vad_models/silero_vad_half.onnx
Normal file
51
whisperlivekit/warmup.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def load_file(warmup_file=None, timeout=5):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
if warmup_file == "":
|
||||||
|
logger.info(f"Skipping warmup.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Download JFK sample if not already present
|
||||||
|
if warmup_file is None:
|
||||||
|
jfk_url = "https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav"
|
||||||
|
temp_dir = tempfile.gettempdir()
|
||||||
|
warmup_file = os.path.join(temp_dir, "whisper_warmup_jfk.wav")
|
||||||
|
if not os.path.exists(warmup_file) or os.path.getsize(warmup_file) == 0:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Downloading warmup file from {jfk_url}")
|
||||||
|
with urllib.request.urlopen(jfk_url, timeout=timeout) as r, open(warmup_file, "wb") as f:
|
||||||
|
f.write(r.read())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Warmup file download failed: {e}.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate file and load
|
||||||
|
if not os.path.exists(warmup_file) or os.path.getsize(warmup_file) == 0:
|
||||||
|
logger.warning(f"Warmup file {warmup_file} is invalid or missing.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, _ = librosa.load(warmup_file, sr=16000)
|
||||||
|
return audio
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load warmup file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def warmup_asr(asr, warmup_file=None, timeout=5):
|
||||||
|
"""
|
||||||
|
Warmup the ASR model by transcribing a short audio file.
|
||||||
|
"""
|
||||||
|
audio = load_file(warmup_file=warmup_file, timeout=timeout)
|
||||||
|
if audio is None:
|
||||||
|
logger.warning("Warmup file unavailable. Skipping ASR warmup.")
|
||||||
|
return
|
||||||
|
asr.transcribe(audio)
|
||||||
|
logger.info("ASR model is warmed up.")
|
||||||
630
whisperlivekit/web/live_transcription.css
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--text: #111111;
|
||||||
|
--muted: #666666;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--chip-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--chip-text: #000000;
|
||||||
|
--spinner-border: #8d8d8d5c;
|
||||||
|
--spinner-top: #b0b0b0;
|
||||||
|
--silence-bg: #f3f3f3;
|
||||||
|
--loading-bg: rgba(255, 77, 77, 0.06);
|
||||||
|
--button-bg: #ffffff;
|
||||||
|
--button-border: #e9e9e9;
|
||||||
|
--wave-stroke: #000000;
|
||||||
|
--label-dia-text: #868686;
|
||||||
|
--label-trans-text: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #0b0b0b;
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--muted: #9aa0a6;
|
||||||
|
--border: #333333;
|
||||||
|
--chip-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--chip-text: #e6e6e6;
|
||||||
|
--spinner-border: #555555;
|
||||||
|
--spinner-top: #dddddd;
|
||||||
|
--silence-bg: #1a1a1a;
|
||||||
|
--loading-bg: rgba(255, 77, 77, 0.12);
|
||||||
|
--button-bg: #111111;
|
||||||
|
--button-border: #333333;
|
||||||
|
--wave-stroke: #e6e6e6;
|
||||||
|
--label-dia-text: #b3b3b3;
|
||||||
|
--label-trans-text: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0b0b0b;
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--muted: #9aa0a6;
|
||||||
|
--border: #333333;
|
||||||
|
--chip-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
--chip-text: #e6e6e6;
|
||||||
|
--spinner-border: #555555;
|
||||||
|
--spinner-top: #dddddd;
|
||||||
|
--silence-bg: #1a1a1a;
|
||||||
|
--loading-bg: rgba(255, 77, 77, 0.12);
|
||||||
|
--button-bg: #111111;
|
||||||
|
--button-border: #333333;
|
||||||
|
--wave-stroke: #e6e6e6;
|
||||||
|
--label-dia-text: #b3b3b3;
|
||||||
|
--label-trans-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--text: #111111;
|
||||||
|
--muted: #666666;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--chip-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--chip-text: #000000;
|
||||||
|
--spinner-border: #8d8d8d5c;
|
||||||
|
--spinner-top: #b0b0b0;
|
||||||
|
--silence-bg: #f3f3f3;
|
||||||
|
--loading-bg: rgba(255, 77, 77, 0.06);
|
||||||
|
--button-bg: #ffffff;
|
||||||
|
--button-border: #e9e9e9;
|
||||||
|
--wave-stroke: #000000;
|
||||||
|
--label-dia-text: #868686;
|
||||||
|
--label-trans-text: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.is-extension
|
||||||
|
{
|
||||||
|
width: 350px;
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Record button */
|
||||||
|
#recordButton {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--button-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordButton.recording {
|
||||||
|
width: 180px;
|
||||||
|
border-radius: 40px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordButton:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-container {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
background-color: rgb(209, 61, 53);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordButton:disabled .shape {
|
||||||
|
background-color: #6e6d6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordButton.recording .shape {
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recording elements */
|
||||||
|
.recording-info {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 15px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordButton.recording .recording-info {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-container {
|
||||||
|
width: 60px;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waveCanvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--bg);
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings */
|
||||||
|
.settings-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--button-bg);
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle:hover {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle.active {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 10000px) {
|
||||||
|
.settings-toggle {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.settings-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chunkSelector,
|
||||||
|
#websocketInput,
|
||||||
|
#themeSelector,
|
||||||
|
#microphoneSelect {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--button-bg);
|
||||||
|
color: var(--text);
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#microphoneSelect {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 190px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chunkSelector:focus,
|
||||||
|
#websocketInput:focus,
|
||||||
|
#themeSelector:focus,
|
||||||
|
#microphoneSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-default {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented pill control for Theme */
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
background-color: var(--button-bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-selector-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:hover span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:hover {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input[type="radio"]:checked + label {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented input[type="radio"]:focus-visible + label,
|
||||||
|
.segmented input[type="radio"]:focus + label {
|
||||||
|
outline: 2px solid #007bff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transcript area */
|
||||||
|
#linesTranscript {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 700px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#linesTranscript p {
|
||||||
|
margin: 0px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#linesTranscript strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#speaker {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_diarization {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
color: var(--label-dia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_transcription {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
color: var(--label-trans-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_translation {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lag-diarization-value {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_translation img {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_translation img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeInfo {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textcontent {
|
||||||
|
font-size: 16px;
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 1px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-radius: 0px 0px 0px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buffer_diarization {
|
||||||
|
color: var(--label-dia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buffer_transcription {
|
||||||
|
color: #7474748c;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buffer_translation {
|
||||||
|
color: #a0a0a0;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border: 2px solid var(--spinner-border);
|
||||||
|
border-top: 2px solid var(--spinner-top);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.silence {
|
||||||
|
color: var(--muted);
|
||||||
|
background-color: var(--silence-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: var(--muted);
|
||||||
|
background-color: var(--loading-bg);
|
||||||
|
border-radius: 8px 8px 8px 0px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* for smaller screens */
|
||||||
|
@media (max-width: 200px) {
|
||||||
|
.header-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-container {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#websocketInput,
|
||||||
|
#microphoneSelect {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-selector-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#websocketInput,
|
||||||
|
#microphoneSelect {
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label_language {
|
||||||
|
background-color: var(--chip-bg);
|
||||||
|
margin-bottom: 0px;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-left: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.speaker-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: -5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -4,679 +4,76 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Audio Transcription</title>
|
<title>WhisperLiveKit</title>
|
||||||
<style>
|
<link rel="stylesheet" href="live_transcription.css" />
|
||||||
body {
|
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
margin: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
border: 1px solid rgb(233, 233, 233);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton.recording {
|
|
||||||
width: 180px;
|
|
||||||
border-radius: 40px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-container {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
background-color: rgb(209, 61, 53);
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton:disabled .shape {
|
|
||||||
background-color: #6e6d6d;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton.recording .shape {
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recording elements */
|
|
||||||
.recording-info {
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: 15px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recordButton.recording .recording-info {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-container {
|
|
||||||
width: 60px;
|
|
||||||
height: 30px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#waveCanvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chunkSelector,
|
|
||||||
#websocketInput {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
background-color: #ffffff;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#websocketInput {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chunkSelector:focus,
|
|
||||||
#websocketInput:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Speaker-labeled transcript area */
|
|
||||||
#linesTranscript {
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: 700px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#linesTranscript p {
|
|
||||||
margin: 0px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#linesTranscript strong {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
#speaker {
|
|
||||||
border: 1px solid rgb(229, 229, 229);
|
|
||||||
border-radius: 100px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
.label_diarization {
|
|
||||||
background-color: #ffffff66;
|
|
||||||
border-radius: 8px 8px 8px 8px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
color: rgb(134, 134, 134)
|
|
||||||
}
|
|
||||||
|
|
||||||
.label_transcription {
|
|
||||||
background-color: #ffffff66;
|
|
||||||
border-radius: 8px 8px 8px 8px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
color: #000000
|
|
||||||
}
|
|
||||||
|
|
||||||
#timeInfo {
|
|
||||||
color: #666;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textcontent {
|
|
||||||
font-size: 16px;
|
|
||||||
/* margin-left: 10px; */
|
|
||||||
padding-left: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
margin-top: 1px;
|
|
||||||
padding-top: 5px;
|
|
||||||
border-radius: 0px 0px 0px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buffer_diarization {
|
|
||||||
color: rgb(134, 134, 134);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buffer_transcription {
|
|
||||||
color: #7474748c;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border: 2px solid #8d8d8d5c;
|
|
||||||
border-top: 2px solid #6c6c6ce5;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.silence {
|
|
||||||
color: #666;
|
|
||||||
background-color: #f3f3f3;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
color: #666;
|
|
||||||
background-color: #ff4d4d0f;
|
|
||||||
border-radius: 8px 8px 8px 0px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div class="header-container">
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="buttons-container">
|
||||||
|
<button id="recordButton">
|
||||||
|
<div class="shape-container">
|
||||||
|
<div class="shape"></div>
|
||||||
|
</div>
|
||||||
|
<div class="recording-info">
|
||||||
|
<div class="wave-container">
|
||||||
|
<canvas id="waveCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="timer">00:00</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="settings-container">
|
<button id="settingsToggle" class="settings-toggle" title="Show/hide settings">
|
||||||
<button id="recordButton">
|
<img src="web/src/settings.svg" alt="Settings" />
|
||||||
<div class="shape-container">
|
</button>
|
||||||
<div class="shape"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="recording-info">
|
|
||||||
<div class="wave-container">
|
<div class="settings">
|
||||||
<canvas id="waveCanvas"></canvas>
|
<div class="field">
|
||||||
|
<label for="websocketInput">Websocket URL</label>
|
||||||
|
<input id="websocketInput" type="text" placeholder="ws://host:port/asr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label id="microphoneSelectLabel" for="microphoneSelect">Select Microphone</label>
|
||||||
|
<select id="microphoneSelect">
|
||||||
|
<option value="">Default Microphone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-selector-container">
|
||||||
|
<div class="segmented" role="radiogroup" aria-label="Theme selector">
|
||||||
|
<input type="radio" id="theme-system" name="theme" value="system" />
|
||||||
|
<label for="theme-system" title="System">
|
||||||
|
<img src="/web/src/system_mode.svg" alt="" />
|
||||||
|
<span>System</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="radio" id="theme-light" name="theme" value="light" />
|
||||||
|
<label for="theme-light" title="Light">
|
||||||
|
<img src="/web/src/light_mode.svg" alt="" />
|
||||||
|
<span>Light</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="radio" id="theme-dark" name="theme" value="dark" />
|
||||||
|
<label for="theme-dark" title="Dark">
|
||||||
|
<img src="/web/src/dark_mode.svg" alt="" />
|
||||||
|
<span>Dark</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="timer">00:00</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div class="settings">
|
|
||||||
<div>
|
|
||||||
<label for="chunkSelector">Chunk size (ms):</label>
|
|
||||||
<select id="chunkSelector">
|
|
||||||
<option value="500">500 ms</option>
|
|
||||||
<option value="1000" selected>1000 ms</option>
|
|
||||||
<option value="2000">2000 ms</option>
|
|
||||||
<option value="3000">3000 ms</option>
|
|
||||||
<option value="4000">4000 ms</option>
|
|
||||||
<option value="5000">5000 ms</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="websocketInput">WebSocket URL:</label>
|
|
||||||
<input id="websocketInput" type="text" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p id="status"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="status"></p>
|
<div class="transcript-container">
|
||||||
|
<div id="linesTranscript"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Speaker-labeled transcript -->
|
<script src="live_transcription.js"></script>
|
||||||
<div id="linesTranscript"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let isRecording = false;
|
|
||||||
let websocket = null;
|
|
||||||
let recorder = null;
|
|
||||||
let chunkDuration = 1000;
|
|
||||||
let websocketUrl = "ws://localhost:8000/asr";
|
|
||||||
let userClosing = false;
|
|
||||||
let startTime = null;
|
|
||||||
let timerInterval = null;
|
|
||||||
let audioContext = null;
|
|
||||||
let analyser = null;
|
|
||||||
let microphone = null;
|
|
||||||
let waveCanvas = document.getElementById("waveCanvas");
|
|
||||||
let waveCtx = waveCanvas.getContext("2d");
|
|
||||||
let animationFrame = null;
|
|
||||||
let waitingForStop = false;
|
|
||||||
let lastReceivedData = null;
|
|
||||||
waveCanvas.width = 60 * (window.devicePixelRatio || 1);
|
|
||||||
waveCanvas.height = 30 * (window.devicePixelRatio || 1);
|
|
||||||
waveCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
|
|
||||||
|
|
||||||
const statusText = document.getElementById("status");
|
|
||||||
const recordButton = document.getElementById("recordButton");
|
|
||||||
const chunkSelector = document.getElementById("chunkSelector");
|
|
||||||
const websocketInput = document.getElementById("websocketInput");
|
|
||||||
const linesTranscriptDiv = document.getElementById("linesTranscript");
|
|
||||||
const timerElement = document.querySelector(".timer");
|
|
||||||
|
|
||||||
const host = window.location.hostname || "localhost";
|
|
||||||
const port = window.location.port;
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const defaultWebSocketUrl = `${protocol}://${host}:${port}/asr`;
|
|
||||||
websocketInput.value = defaultWebSocketUrl;
|
|
||||||
websocketUrl = defaultWebSocketUrl;
|
|
||||||
|
|
||||||
chunkSelector.addEventListener("change", () => {
|
|
||||||
chunkDuration = parseInt(chunkSelector.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
websocketInput.addEventListener("change", () => {
|
|
||||||
const urlValue = websocketInput.value.trim();
|
|
||||||
if (!urlValue.startsWith("ws://") && !urlValue.startsWith("wss://")) {
|
|
||||||
statusText.textContent = "Invalid WebSocket URL (must start with ws:// or wss://)";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
websocketUrl = urlValue;
|
|
||||||
statusText.textContent = "WebSocket URL updated. Ready to connect.";
|
|
||||||
});
|
|
||||||
|
|
||||||
function setupWebSocket() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
websocket = new WebSocket(websocketUrl);
|
|
||||||
} catch (error) {
|
|
||||||
statusText.textContent = "Invalid WebSocket URL. Please check and try again.";
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
websocket.onopen = () => {
|
|
||||||
statusText.textContent = "Connected to server.";
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onclose = () => {
|
|
||||||
if (userClosing) {
|
|
||||||
if (waitingForStop) {
|
|
||||||
statusText.textContent = "Processing finalized or connection closed.";
|
|
||||||
if (lastReceivedData) {
|
|
||||||
renderLinesWithBuffer(
|
|
||||||
lastReceivedData.lines || [],
|
|
||||||
lastReceivedData.buffer_diarization || "",
|
|
||||||
lastReceivedData.buffer_transcription || "",
|
|
||||||
0, 0, true // isFinalizing = true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If ready_to_stop was received, statusText is already "Finished processing..."
|
|
||||||
// and waitingForStop is false.
|
|
||||||
} else {
|
|
||||||
statusText.textContent = "Disconnected from the WebSocket server. (Check logs if model is loading.)";
|
|
||||||
if (isRecording) {
|
|
||||||
stopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isRecording = false;
|
|
||||||
waitingForStop = false;
|
|
||||||
userClosing = false;
|
|
||||||
lastReceivedData = null;
|
|
||||||
websocket = null;
|
|
||||||
updateUI();
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onerror = () => {
|
|
||||||
statusText.textContent = "Error connecting to WebSocket.";
|
|
||||||
reject(new Error("Error connecting to WebSocket"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle messages from server
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
// Check for status messages
|
|
||||||
if (data.type === "ready_to_stop") {
|
|
||||||
console.log("Ready to stop received, finalizing display and closing WebSocket.");
|
|
||||||
waitingForStop = false;
|
|
||||||
|
|
||||||
if (lastReceivedData) {
|
|
||||||
renderLinesWithBuffer(
|
|
||||||
lastReceivedData.lines || [],
|
|
||||||
lastReceivedData.buffer_diarization || "",
|
|
||||||
lastReceivedData.buffer_transcription || "",
|
|
||||||
0, // No more lag
|
|
||||||
0, // No more lag
|
|
||||||
true // isFinalizing = true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
statusText.textContent = "Finished processing audio! Ready to record again.";
|
|
||||||
recordButton.disabled = false;
|
|
||||||
|
|
||||||
if (websocket) {
|
|
||||||
websocket.close(); // will trigger onclose
|
|
||||||
// websocket = null; // onclose handle setting websocket to null
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastReceivedData = data;
|
|
||||||
|
|
||||||
// Handle normal transcription updates
|
|
||||||
const {
|
|
||||||
lines = [],
|
|
||||||
buffer_transcription = "",
|
|
||||||
buffer_diarization = "",
|
|
||||||
remaining_time_transcription = 0,
|
|
||||||
remaining_time_diarization = 0,
|
|
||||||
status = "active_transcription"
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
renderLinesWithBuffer(
|
|
||||||
lines,
|
|
||||||
buffer_diarization,
|
|
||||||
buffer_transcription,
|
|
||||||
remaining_time_diarization,
|
|
||||||
remaining_time_transcription,
|
|
||||||
false,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLinesWithBuffer(lines, buffer_diarization, buffer_transcription, remaining_time_diarization, remaining_time_transcription, isFinalizing = false, current_status = "active_transcription") {
|
|
||||||
if (current_status === "no_audio_detected") {
|
|
||||||
linesTranscriptDiv.innerHTML = "<p style='text-align: center; color: #666; margin-top: 20px;'><em>No audio detected...</em></p>";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linesHtml = lines.map((item, idx) => {
|
|
||||||
let timeInfo = "";
|
|
||||||
if (item.beg !== undefined && item.end !== undefined) {
|
|
||||||
timeInfo = ` ${item.beg} - ${item.end}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let speakerLabel = "";
|
|
||||||
if (item.speaker === -2) {
|
|
||||||
speakerLabel = `<span class="silence">Silence<span id='timeInfo'>${timeInfo}</span></span>`;
|
|
||||||
} else if (item.speaker == 0 && !isFinalizing) {
|
|
||||||
speakerLabel = `<span class='loading'><span class="spinner"></span><span id='timeInfo'>${remaining_time_diarization} second(s) of audio are undergoing diarization</span></span>`;
|
|
||||||
} else if (item.speaker == -1) {
|
|
||||||
speakerLabel = `<span id="speaker">Speaker 1<span id='timeInfo'>${timeInfo}</span></span>`;
|
|
||||||
} else if (item.speaker !== -1 && item.speaker !== 0) {
|
|
||||||
speakerLabel = `<span id="speaker">Speaker ${item.speaker}<span id='timeInfo'>${timeInfo}</span></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let currentLineText = item.text || "";
|
|
||||||
|
|
||||||
if (idx === lines.length - 1) {
|
|
||||||
if (!isFinalizing) {
|
|
||||||
if (remaining_time_transcription > 0) {
|
|
||||||
speakerLabel += `<span class="label_transcription"><span class="spinner"></span>Transcription lag <span id='timeInfo'>${remaining_time_transcription}s</span></span>`;
|
|
||||||
}
|
|
||||||
if (buffer_diarization && remaining_time_diarization > 0) {
|
|
||||||
speakerLabel += `<span class="label_diarization"><span class="spinner"></span>Diarization lag<span id='timeInfo'>${remaining_time_diarization}s</span></span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer_diarization) {
|
|
||||||
if (isFinalizing) {
|
|
||||||
currentLineText += (currentLineText.length > 0 && buffer_diarization.trim().length > 0 ? " " : "") + buffer_diarization.trim();
|
|
||||||
} else {
|
|
||||||
currentLineText += `<span class="buffer_diarization">${buffer_diarization}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (buffer_transcription) {
|
|
||||||
if (isFinalizing) {
|
|
||||||
currentLineText += (currentLineText.length > 0 && buffer_transcription.trim().length > 0 ? " " : "") + buffer_transcription.trim();
|
|
||||||
} else {
|
|
||||||
currentLineText += `<span class="buffer_transcription">${buffer_transcription}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentLineText.trim().length > 0 || speakerLabel.length > 0
|
|
||||||
? `<p>${speakerLabel}<br/><div class='textcontent'>${currentLineText}</div></p>`
|
|
||||||
: `<p>${speakerLabel}<br/></p>`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
linesTranscriptDiv.innerHTML = linesHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTimer() {
|
|
||||||
if (!startTime) return;
|
|
||||||
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
const minutes = Math.floor(elapsed / 60).toString().padStart(2, "0");
|
|
||||||
const seconds = (elapsed % 60).toString().padStart(2, "0");
|
|
||||||
timerElement.textContent = `${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawWaveform() {
|
|
||||||
if (!analyser) return;
|
|
||||||
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
|
||||||
|
|
||||||
waveCtx.clearRect(0, 0, waveCanvas.width / (window.devicePixelRatio || 1), waveCanvas.height / (window.devicePixelRatio || 1));
|
|
||||||
waveCtx.lineWidth = 1;
|
|
||||||
waveCtx.strokeStyle = 'rgb(0, 0, 0)';
|
|
||||||
waveCtx.beginPath();
|
|
||||||
|
|
||||||
const sliceWidth = (waveCanvas.width / (window.devicePixelRatio || 1)) / bufferLength;
|
|
||||||
let x = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
const v = dataArray[i] / 128.0;
|
|
||||||
const y = v * (waveCanvas.height / (window.devicePixelRatio || 1)) / 2;
|
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
waveCtx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
waveCtx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
x += sliceWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
waveCtx.lineTo(waveCanvas.width / (window.devicePixelRatio || 1), waveCanvas.height / (window.devicePixelRatio || 1) / 2);
|
|
||||||
waveCtx.stroke();
|
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(drawWaveform);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRecording() {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
|
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
analyser = audioContext.createAnalyser();
|
|
||||||
analyser.fftSize = 256;
|
|
||||||
microphone = audioContext.createMediaStreamSource(stream);
|
|
||||||
microphone.connect(analyser);
|
|
||||||
|
|
||||||
recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
|
||||||
recorder.ondataavailable = (e) => {
|
|
||||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
||||||
websocket.send(e.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
recorder.start(chunkDuration);
|
|
||||||
|
|
||||||
startTime = Date.now();
|
|
||||||
timerInterval = setInterval(updateTimer, 1000);
|
|
||||||
drawWaveform();
|
|
||||||
|
|
||||||
isRecording = true;
|
|
||||||
updateUI();
|
|
||||||
} catch (err) {
|
|
||||||
statusText.textContent = "Error accessing microphone. Please allow microphone access.";
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopRecording() {
|
|
||||||
userClosing = true;
|
|
||||||
waitingForStop = true;
|
|
||||||
|
|
||||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
||||||
// Send empty audio buffer as stop signal
|
|
||||||
const emptyBlob = new Blob([], { type: 'audio/webm' });
|
|
||||||
websocket.send(emptyBlob);
|
|
||||||
statusText.textContent = "Recording stopped. Processing final audio...";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recorder) {
|
|
||||||
recorder.stop();
|
|
||||||
recorder = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (microphone) {
|
|
||||||
microphone.disconnect();
|
|
||||||
microphone = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analyser) {
|
|
||||||
analyser = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioContext && audioContext.state !== 'closed') {
|
|
||||||
try {
|
|
||||||
audioContext.close();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Could not close audio context:", e);
|
|
||||||
}
|
|
||||||
audioContext = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animationFrame) {
|
|
||||||
cancelAnimationFrame(animationFrame);
|
|
||||||
animationFrame = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timerInterval) {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
timerInterval = null;
|
|
||||||
}
|
|
||||||
timerElement.textContent = "00:00";
|
|
||||||
startTime = null;
|
|
||||||
|
|
||||||
|
|
||||||
isRecording = false;
|
|
||||||
updateUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleRecording() {
|
|
||||||
if (!isRecording) {
|
|
||||||
if (waitingForStop) {
|
|
||||||
console.log("Waiting for stop, early return");
|
|
||||||
return; // Early return, UI is already updated
|
|
||||||
}
|
|
||||||
console.log("Connecting to WebSocket");
|
|
||||||
try {
|
|
||||||
// If we have an active WebSocket that's still processing, just restart audio capture
|
|
||||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
|
||||||
await startRecording();
|
|
||||||
} else {
|
|
||||||
// If no active WebSocket or it's closed, create new one
|
|
||||||
await setupWebSocket();
|
|
||||||
await startRecording();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
statusText.textContent = "Could not connect to WebSocket or access mic. Aborted.";
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("Stopping recording");
|
|
||||||
stopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUI() {
|
|
||||||
recordButton.classList.toggle("recording", isRecording);
|
|
||||||
recordButton.disabled = waitingForStop;
|
|
||||||
|
|
||||||
if (waitingForStop) {
|
|
||||||
if (statusText.textContent !== "Recording stopped. Processing final audio...") {
|
|
||||||
statusText.textContent = "Please wait for processing to complete...";
|
|
||||||
}
|
|
||||||
} else if (isRecording) {
|
|
||||||
statusText.textContent = "Recording...";
|
|
||||||
} else {
|
|
||||||
if (statusText.textContent !== "Finished processing audio! Ready to record again." &&
|
|
||||||
statusText.textContent !== "Processing finalized or connection closed.") {
|
|
||||||
statusText.textContent = "Click to start transcription";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!waitingForStop) {
|
|
||||||
recordButton.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recordButton.addEventListener("click", toggleRecording);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
817
whisperlivekit/web/live_transcription.js
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
const isExtension = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL;
|
||||||
|
if (isExtension) {
|
||||||
|
document.documentElement.classList.add('is-extension');
|
||||||
|
}
|
||||||
|
const isWebContext = !isExtension;
|
||||||
|
|
||||||
|
let isRecording = false;
|
||||||
|
let websocket = null;
|
||||||
|
let recorder = null;
|
||||||
|
let chunkDuration = 100;
|
||||||
|
let websocketUrl = "ws://localhost:8000/asr";
|
||||||
|
let userClosing = false;
|
||||||
|
let wakeLock = null;
|
||||||
|
let startTime = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let analyser = null;
|
||||||
|
let microphone = null;
|
||||||
|
let workletNode = null;
|
||||||
|
let recorderWorker = null;
|
||||||
|
let waveCanvas = document.getElementById("waveCanvas");
|
||||||
|
let waveCtx = waveCanvas.getContext("2d");
|
||||||
|
let animationFrame = null;
|
||||||
|
let waitingForStop = false;
|
||||||
|
let lastReceivedData = null;
|
||||||
|
let lastSignature = null;
|
||||||
|
let availableMicrophones = [];
|
||||||
|
let selectedMicrophoneId = null;
|
||||||
|
let serverUseAudioWorklet = null;
|
||||||
|
let configReadyResolve;
|
||||||
|
const configReady = new Promise((r) => (configReadyResolve = r));
|
||||||
|
let outputAudioContext = null;
|
||||||
|
let audioSource = null;
|
||||||
|
|
||||||
|
waveCanvas.width = 60 * (window.devicePixelRatio || 1);
|
||||||
|
waveCanvas.height = 30 * (window.devicePixelRatio || 1);
|
||||||
|
waveCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
const statusText = document.getElementById("status");
|
||||||
|
const recordButton = document.getElementById("recordButton");
|
||||||
|
const chunkSelector = document.getElementById("chunkSelector");
|
||||||
|
const websocketInput = document.getElementById("websocketInput");
|
||||||
|
const websocketDefaultSpan = document.getElementById("wsDefaultUrl");
|
||||||
|
const linesTranscriptDiv = document.getElementById("linesTranscript");
|
||||||
|
const timerElement = document.querySelector(".timer");
|
||||||
|
const themeRadios = document.querySelectorAll('input[name="theme"]');
|
||||||
|
const microphoneSelect = document.getElementById("microphoneSelect");
|
||||||
|
|
||||||
|
const settingsToggle = document.getElementById("settingsToggle");
|
||||||
|
const settingsDiv = document.querySelector(".settings");
|
||||||
|
|
||||||
|
// if (isExtension) {
|
||||||
|
// chrome.runtime.onInstalled.addListener((details) => {
|
||||||
|
// if (details.reason.search(/install/g) === -1) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// chrome.tabs.create({
|
||||||
|
// url: chrome.runtime.getURL("welcome.html"),
|
||||||
|
// active: true
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const translationIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12px" viewBox="0 -960 960 960" width="12px" fill="#5f6368"><path d="m603-202-34 97q-4 11-14 18t-22 7q-20 0-32.5-16.5T496-133l152-402q5-11 15-18t22-7h30q12 0 22 7t15 18l152 403q8 19-4 35.5T868-80q-13 0-22.5-7T831-106l-34-96H603ZM362-401 188-228q-11 11-27.5 11.5T132-228q-11-11-11-28t11-28l174-174q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H80q-17 0-28.5-11.5T40-760q0-17 11.5-28.5T80-800h240v-40q0-17 11.5-28.5T360-880q17 0 28.5 11.5T400-840v40h240q17 0 28.5 11.5T680-760q0 17-11.5 28.5T640-720h-76q-21 72-63 148t-83 116l96 98-30 82-122-125Zm266 129h144l-72-204-72 204Z"/></svg>`
|
||||||
|
const silenceIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="vertical-align: text-bottom;" height="14px" viewBox="0 -960 960 960" width="14px" fill="#5f6368"><path d="M514-556 320-752q9-3 19-5.5t21-2.5q66 0 113 47t47 113q0 11-1.5 22t-4.5 22ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q26 0 49.5 2.5T456-392l-56-54q-9 3-19 4.5t-21 1.5q-66 0-113-47t-47-113q0-11 1.5-21t4.5-19L84-764q-11-11-11-28t11-28q12-12 28.5-12t27.5 12l675 685q11 11 11.5 27.5T816-80q-11 13-28 12.5T759-80L641-200h39q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Zm80 0h480v-32q0-14-4.5-19.5T580-266q-36-18-92.5-36T360-320q-71 0-127.5 18T140-266q-9 5-14.5 14t-5.5 20v32Zm240 0Zm560-400q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5Z"/></svg>`;
|
||||||
|
const languageIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12" viewBox="0 -960 960 960" width="12" fill="#5f6368"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>`
|
||||||
|
const speakerIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="16px" style="vertical-align: text-bottom;" viewBox="0 -960 960 960" width="16px" fill="#5f6368"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-240v-32q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v32q0 33-23.5 56.5T720-160H240q-33 0-56.5-23.5T160-240Zm80 0h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>`;
|
||||||
|
|
||||||
|
function getWaveStroke() {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const v = styles.getPropertyValue("--wave-stroke").trim();
|
||||||
|
return v || "#000";
|
||||||
|
}
|
||||||
|
|
||||||
|
let waveStroke = getWaveStroke();
|
||||||
|
function updateWaveStroke() {
|
||||||
|
waveStroke = getWaveStroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(pref) {
|
||||||
|
if (pref === "light") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
} else if (pref === "dark") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
updateWaveStroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persisted theme preference
|
||||||
|
const savedThemePref = localStorage.getItem("themePreference") || "system";
|
||||||
|
applyTheme(savedThemePref);
|
||||||
|
if (themeRadios.length) {
|
||||||
|
themeRadios.forEach((r) => {
|
||||||
|
r.checked = r.value === savedThemePref;
|
||||||
|
r.addEventListener("change", () => {
|
||||||
|
if (r.checked) {
|
||||||
|
localStorage.setItem("themePreference", r.value);
|
||||||
|
applyTheme(r.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to OS theme changes when in "system" mode
|
||||||
|
const darkMq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleOsThemeChange = () => {
|
||||||
|
const pref = localStorage.getItem("themePreference") || "system";
|
||||||
|
if (pref === "system") updateWaveStroke();
|
||||||
|
};
|
||||||
|
if (darkMq && darkMq.addEventListener) {
|
||||||
|
darkMq.addEventListener("change", handleOsThemeChange);
|
||||||
|
} else if (darkMq && darkMq.addListener) {
|
||||||
|
// deprecated, but included for Safari compatibility
|
||||||
|
darkMq.addListener(handleOsThemeChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerateMicrophones() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
availableMicrophones = devices.filter(device => device.kind === 'audioinput');
|
||||||
|
|
||||||
|
populateMicrophoneSelect();
|
||||||
|
console.log(`Found ${availableMicrophones.length} microphone(s)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error enumerating microphones:', error);
|
||||||
|
statusText.textContent = "Error accessing microphones. Please grant permission.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateMicrophoneSelect() {
|
||||||
|
if (!microphoneSelect) return;
|
||||||
|
|
||||||
|
microphoneSelect.innerHTML = '<option value="">Default Microphone</option>';
|
||||||
|
|
||||||
|
availableMicrophones.forEach((device, index) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = device.deviceId;
|
||||||
|
option.textContent = device.label || `Microphone ${index + 1}`;
|
||||||
|
microphoneSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedMicId = localStorage.getItem('selectedMicrophone');
|
||||||
|
if (savedMicId && availableMicrophones.some(mic => mic.deviceId === savedMicId)) {
|
||||||
|
microphoneSelect.value = savedMicId;
|
||||||
|
selectedMicrophoneId = savedMicId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMicrophoneChange() {
|
||||||
|
selectedMicrophoneId = microphoneSelect.value || null;
|
||||||
|
localStorage.setItem('selectedMicrophone', selectedMicrophoneId || '');
|
||||||
|
|
||||||
|
const selectedDevice = availableMicrophones.find(mic => mic.deviceId === selectedMicrophoneId);
|
||||||
|
const deviceName = selectedDevice ? selectedDevice.label : 'Default Microphone';
|
||||||
|
|
||||||
|
console.log(`Selected microphone: ${deviceName}`);
|
||||||
|
statusText.textContent = `Microphone changed to: ${deviceName}`;
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
statusText.textContent = "Switching microphone... Please wait.";
|
||||||
|
stopRecording().then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
toggleRecording();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function fmt1(x) {
|
||||||
|
const n = Number(x);
|
||||||
|
return Number.isFinite(n) ? n.toFixed(1) : x;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host, port, protocol;
|
||||||
|
port = 8000;
|
||||||
|
if (isExtension) {
|
||||||
|
host = "localhost";
|
||||||
|
protocol = "ws";
|
||||||
|
} else {
|
||||||
|
host = window.location.hostname || "localhost";
|
||||||
|
port = window.location.port;
|
||||||
|
protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
}
|
||||||
|
const defaultWebSocketUrl = `${protocol}://${host}${port ? ":" + port : ""}/asr`;
|
||||||
|
|
||||||
|
// Populate default caption and input
|
||||||
|
if (websocketDefaultSpan) websocketDefaultSpan.textContent = defaultWebSocketUrl;
|
||||||
|
websocketInput.value = defaultWebSocketUrl;
|
||||||
|
websocketUrl = defaultWebSocketUrl;
|
||||||
|
|
||||||
|
// Optional chunk selector (guard for presence)
|
||||||
|
if (chunkSelector) {
|
||||||
|
chunkSelector.addEventListener("change", () => {
|
||||||
|
chunkDuration = parseInt(chunkSelector.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket input change handling
|
||||||
|
websocketInput.addEventListener("change", () => {
|
||||||
|
const urlValue = websocketInput.value.trim();
|
||||||
|
if (!urlValue.startsWith("ws://") && !urlValue.startsWith("wss://")) {
|
||||||
|
statusText.textContent = "Invalid WebSocket URL (must start with ws:// or wss://)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
websocketUrl = urlValue;
|
||||||
|
statusText.textContent = "WebSocket URL updated. Ready to connect.";
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupWebSocket() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
websocket = new WebSocket(websocketUrl);
|
||||||
|
} catch (error) {
|
||||||
|
statusText.textContent = "Invalid WebSocket URL. Please check and try again.";
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websocket.onopen = () => {
|
||||||
|
statusText.textContent = "Connected to server.";
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onclose = () => {
|
||||||
|
if (userClosing) {
|
||||||
|
if (waitingForStop) {
|
||||||
|
statusText.textContent = "Processing finalized or connection closed.";
|
||||||
|
if (lastReceivedData) {
|
||||||
|
renderLinesWithBuffer(
|
||||||
|
lastReceivedData.lines || [],
|
||||||
|
lastReceivedData.buffer_diarization || "",
|
||||||
|
lastReceivedData.buffer_transcription || "",
|
||||||
|
lastReceivedData.buffer_translation || "",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusText.textContent = "Disconnected from the WebSocket server. (Check logs if model is loading.)";
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRecording = false;
|
||||||
|
waitingForStop = false;
|
||||||
|
userClosing = false;
|
||||||
|
lastReceivedData = null;
|
||||||
|
websocket = null;
|
||||||
|
updateUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onerror = () => {
|
||||||
|
statusText.textContent = "Error connecting to WebSocket.";
|
||||||
|
reject(new Error("Error connecting to WebSocket"));
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === "config") {
|
||||||
|
serverUseAudioWorklet = !!data.useAudioWorklet;
|
||||||
|
statusText.textContent = serverUseAudioWorklet
|
||||||
|
? "Connected. Using AudioWorklet (PCM)."
|
||||||
|
: "Connected. Using MediaRecorder (WebM).";
|
||||||
|
if (configReadyResolve) configReadyResolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "ready_to_stop") {
|
||||||
|
console.log("Ready to stop received, finalizing display and closing WebSocket.");
|
||||||
|
waitingForStop = false;
|
||||||
|
|
||||||
|
if (lastReceivedData) {
|
||||||
|
renderLinesWithBuffer(
|
||||||
|
lastReceivedData.lines || [],
|
||||||
|
lastReceivedData.buffer_diarization || "",
|
||||||
|
lastReceivedData.buffer_transcription || "",
|
||||||
|
lastReceivedData.buffer_translation || "",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
statusText.textContent = "Finished processing audio! Ready to record again.";
|
||||||
|
recordButton.disabled = false;
|
||||||
|
|
||||||
|
if (websocket) {
|
||||||
|
websocket.close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReceivedData = data;
|
||||||
|
|
||||||
|
const {
|
||||||
|
lines = [],
|
||||||
|
buffer_transcription = "",
|
||||||
|
buffer_diarization = "",
|
||||||
|
buffer_translation = "",
|
||||||
|
remaining_time_transcription = 0,
|
||||||
|
remaining_time_diarization = 0,
|
||||||
|
status = "active_transcription",
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
renderLinesWithBuffer(
|
||||||
|
lines,
|
||||||
|
buffer_diarization,
|
||||||
|
buffer_transcription,
|
||||||
|
buffer_translation,
|
||||||
|
remaining_time_diarization,
|
||||||
|
remaining_time_transcription,
|
||||||
|
false,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinesWithBuffer(
|
||||||
|
lines,
|
||||||
|
buffer_diarization,
|
||||||
|
buffer_transcription,
|
||||||
|
buffer_translation,
|
||||||
|
remaining_time_diarization,
|
||||||
|
remaining_time_transcription,
|
||||||
|
isFinalizing = false,
|
||||||
|
current_status = "active_transcription"
|
||||||
|
) {
|
||||||
|
if (current_status === "no_audio_detected") {
|
||||||
|
linesTranscriptDiv.innerHTML =
|
||||||
|
"<p style='text-align: center; color: var(--muted); margin-top: 20px;'><em>No audio detected...</em></p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLoading = !isFinalizing && (lines || []).some((it) => it.speaker == 0);
|
||||||
|
const showTransLag = !isFinalizing && remaining_time_transcription > 0;
|
||||||
|
const showDiaLag = !isFinalizing && !!buffer_diarization && remaining_time_diarization > 0;
|
||||||
|
const signature = JSON.stringify({
|
||||||
|
lines: (lines || []).map((it) => ({ speaker: it.speaker, text: it.text, start: it.start, end: it.end, detected_language: it.detected_language })),
|
||||||
|
buffer_transcription: buffer_transcription || "",
|
||||||
|
buffer_diarization: buffer_diarization || "",
|
||||||
|
buffer_translation: buffer_translation,
|
||||||
|
status: current_status,
|
||||||
|
showLoading,
|
||||||
|
showTransLag,
|
||||||
|
showDiaLag,
|
||||||
|
isFinalizing: !!isFinalizing,
|
||||||
|
});
|
||||||
|
if (lastSignature === signature) {
|
||||||
|
const t = document.querySelector(".lag-transcription-value");
|
||||||
|
if (t) t.textContent = fmt1(remaining_time_transcription);
|
||||||
|
const d = document.querySelector(".lag-diarization-value");
|
||||||
|
if (d) d.textContent = fmt1(remaining_time_diarization);
|
||||||
|
const ld = document.querySelector(".loading-diarization-value");
|
||||||
|
if (ld) ld.textContent = fmt1(remaining_time_diarization);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSignature = signature;
|
||||||
|
|
||||||
|
const linesHtml = (lines || [])
|
||||||
|
.map((item, idx) => {
|
||||||
|
let timeInfo = "";
|
||||||
|
if (item.start !== undefined && item.end !== undefined) {
|
||||||
|
timeInfo = ` ${item.start} - ${item.end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let speakerLabel = "";
|
||||||
|
if (item.speaker === -2) {
|
||||||
|
speakerLabel = `<span class="silence">${silenceIcon}<span id='timeInfo'>${timeInfo}</span></span>`;
|
||||||
|
} else if (item.speaker == 0 && !isFinalizing) {
|
||||||
|
speakerLabel = `<span class='loading'><span class="spinner"></span><span id='timeInfo'><span class="loading-diarization-value">${fmt1(
|
||||||
|
remaining_time_diarization
|
||||||
|
)}</span> second(s) of audio are undergoing diarization</span></span>`;
|
||||||
|
} else if (item.speaker !== 0) {
|
||||||
|
const speakerNum = `<span class="speaker-badge">${item.speaker}</span>`;
|
||||||
|
speakerLabel = `<span id="speaker">${speakerIcon}${speakerNum}<span id='timeInfo'>${timeInfo}</span></span>`;
|
||||||
|
|
||||||
|
if (item.detected_language) {
|
||||||
|
speakerLabel += `<span class="label_language">${languageIcon}<span>${item.detected_language}</span></span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLineText = item.text || "";
|
||||||
|
|
||||||
|
if (idx === lines.length - 1) {
|
||||||
|
if (!isFinalizing && item.speaker !== -2) {
|
||||||
|
speakerLabel += `<span class="label_transcription"><span class="spinner"></span>Transcription lag <span id='timeInfo'><span class="lag-transcription-value">${fmt1(
|
||||||
|
remaining_time_transcription
|
||||||
|
)}</span>s</span></span>`;
|
||||||
|
|
||||||
|
if (buffer_diarization && remaining_time_diarization) {
|
||||||
|
speakerLabel += `<span class="label_diarization"><span class="spinner"></span>Diarization lag<span id='timeInfo'><span class="lag-diarization-value">${fmt1(
|
||||||
|
remaining_time_diarization
|
||||||
|
)}</span>s</span></span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer_diarization) {
|
||||||
|
if (isFinalizing) {
|
||||||
|
currentLineText +=
|
||||||
|
(currentLineText.length > 0 && buffer_diarization.trim().length > 0 ? " " : "") + buffer_diarization.trim();
|
||||||
|
} else {
|
||||||
|
currentLineText += `<span class="buffer_diarization">${buffer_diarization}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer_transcription) {
|
||||||
|
if (isFinalizing) {
|
||||||
|
currentLineText +=
|
||||||
|
(currentLineText.length > 0 && buffer_transcription.trim().length > 0 ? " " : "") +
|
||||||
|
buffer_transcription.trim();
|
||||||
|
} else {
|
||||||
|
currentLineText += `<span class="buffer_transcription">${buffer_transcription}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let translationContent = "";
|
||||||
|
if (item.translation) {
|
||||||
|
translationContent += item.translation.trim();
|
||||||
|
}
|
||||||
|
if (idx === lines.length - 1 && buffer_translation) {
|
||||||
|
const bufferPiece = isFinalizing
|
||||||
|
? buffer_translation
|
||||||
|
: `<span class="buffer_translation">${buffer_translation}</span>`;
|
||||||
|
translationContent += translationContent ? `${bufferPiece}` : bufferPiece;
|
||||||
|
}
|
||||||
|
if (translationContent.trim().length > 0) {
|
||||||
|
currentLineText += `
|
||||||
|
<div>
|
||||||
|
<div class="label_translation">
|
||||||
|
${translationIcon}
|
||||||
|
<span class="translation_text">${translationContent}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentLineText.trim().length > 0 || speakerLabel.length > 0
|
||||||
|
? `<p>${speakerLabel}<br/><div class='textcontent'>${currentLineText}</div></p>`
|
||||||
|
: `<p>${speakerLabel}<br/></p>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
linesTranscriptDiv.innerHTML = linesHtml;
|
||||||
|
const transcriptContainer = document.querySelector('.transcript-container');
|
||||||
|
if (transcriptContainer) {
|
||||||
|
transcriptContainer.scrollTo({ top: transcriptContainer.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
if (!startTime) return;
|
||||||
|
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const minutes = Math.floor(elapsed / 60).toString().padStart(2, "0");
|
||||||
|
const seconds = (elapsed % 60).toString().padStart(2, "0");
|
||||||
|
timerElement.textContent = `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveform() {
|
||||||
|
if (!analyser) return;
|
||||||
|
|
||||||
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
|
waveCtx.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
waveCanvas.width / (window.devicePixelRatio || 1),
|
||||||
|
waveCanvas.height / (window.devicePixelRatio || 1)
|
||||||
|
);
|
||||||
|
waveCtx.lineWidth = 1;
|
||||||
|
waveCtx.strokeStyle = waveStroke;
|
||||||
|
waveCtx.beginPath();
|
||||||
|
|
||||||
|
const sliceWidth = (waveCanvas.width / (window.devicePixelRatio || 1)) / bufferLength;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const v = dataArray[i] / 128.0;
|
||||||
|
const y = (v * (waveCanvas.height / (window.devicePixelRatio || 1))) / 2;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
waveCtx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
waveCtx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
x += sliceWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
waveCtx.lineTo(
|
||||||
|
waveCanvas.width / (window.devicePixelRatio || 1),
|
||||||
|
(waveCanvas.height / (window.devicePixelRatio || 1)) / 2
|
||||||
|
);
|
||||||
|
waveCtx.stroke();
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(drawWaveform);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
wakeLock = await navigator.wakeLock.request("screen");
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error acquiring wake lock.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream;
|
||||||
|
|
||||||
|
// chromium extension. in the future, both chrome page audio and mic will be used
|
||||||
|
if (isExtension) {
|
||||||
|
try {
|
||||||
|
stream = await new Promise((resolve, reject) => {
|
||||||
|
chrome.tabCapture.capture({audio: true}, (s) => {
|
||||||
|
if (s) {
|
||||||
|
resolve(s);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Tab capture failed or not available'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputAudioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
audioSource = outputAudioContext.createMediaStreamSource(stream);
|
||||||
|
audioSource.connect(outputAudioContext.destination);
|
||||||
|
} catch (audioError) {
|
||||||
|
console.warn('could not preserve system audio:', audioError);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText.textContent = "Using tab audio capture.";
|
||||||
|
} catch (tabError) {
|
||||||
|
console.log('Tab capture not available, falling back to microphone', tabError);
|
||||||
|
const audioConstraints = selectedMicrophoneId
|
||||||
|
? { audio: { deviceId: { exact: selectedMicrophoneId } } }
|
||||||
|
: { audio: true };
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
|
||||||
|
statusText.textContent = "Using microphone audio.";
|
||||||
|
}
|
||||||
|
} else if (isWebContext) {
|
||||||
|
const audioConstraints = selectedMicrophoneId
|
||||||
|
? { audio: { deviceId: { exact: selectedMicrophoneId } } }
|
||||||
|
: { audio: true };
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
microphone = audioContext.createMediaStreamSource(stream);
|
||||||
|
microphone.connect(analyser);
|
||||||
|
|
||||||
|
if (serverUseAudioWorklet) {
|
||||||
|
if (!audioContext.audioWorklet) {
|
||||||
|
throw new Error("AudioWorklet is not supported in this browser");
|
||||||
|
}
|
||||||
|
await audioContext.audioWorklet.addModule("/web/pcm_worklet.js");
|
||||||
|
workletNode = new AudioWorkletNode(audioContext, "pcm-forwarder", { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||||
|
microphone.connect(workletNode);
|
||||||
|
|
||||||
|
recorderWorker = new Worker("/web/recorder_worker.js");
|
||||||
|
recorderWorker.postMessage({
|
||||||
|
command: "init",
|
||||||
|
config: {
|
||||||
|
sampleRate: audioContext.sampleRate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderWorker.onmessage = (e) => {
|
||||||
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||||
|
websocket.send(e.data.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
workletNode.port.onmessage = (e) => {
|
||||||
|
const data = e.data;
|
||||||
|
const ab = data instanceof ArrayBuffer ? data : data.buffer;
|
||||||
|
recorderWorker.postMessage(
|
||||||
|
{
|
||||||
|
command: "record",
|
||||||
|
buffer: ab,
|
||||||
|
},
|
||||||
|
[ab]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||||
|
} catch (e) {
|
||||||
|
recorder = new MediaRecorder(stream);
|
||||||
|
}
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||||
|
if (e.data && e.data.size > 0) {
|
||||||
|
websocket.send(e.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.start(chunkDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime = Date.now();
|
||||||
|
timerInterval = setInterval(updateTimer, 1000);
|
||||||
|
drawWaveform();
|
||||||
|
|
||||||
|
isRecording = true;
|
||||||
|
updateUI();
|
||||||
|
} catch (err) {
|
||||||
|
if (window.location.hostname === "0.0.0.0") {
|
||||||
|
statusText.textContent =
|
||||||
|
"Error accessing microphone. Browsers may block microphone access on 0.0.0.0. Try using localhost:8000 instead.";
|
||||||
|
} else {
|
||||||
|
statusText.textContent = "Error accessing microphone. Please allow microphone access.";
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
if (wakeLock) {
|
||||||
|
try {
|
||||||
|
await wakeLock.release();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
userClosing = true;
|
||||||
|
waitingForStop = true;
|
||||||
|
|
||||||
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||||
|
const emptyBlob = new Blob([], { type: "audio/webm" });
|
||||||
|
websocket.send(emptyBlob);
|
||||||
|
statusText.textContent = "Recording stopped. Processing final audio...";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recorder) {
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
recorder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recorderWorker) {
|
||||||
|
recorderWorker.terminate();
|
||||||
|
recorderWorker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workletNode) {
|
||||||
|
try {
|
||||||
|
workletNode.port.onmessage = null;
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
workletNode.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
|
workletNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microphone) {
|
||||||
|
microphone.disconnect();
|
||||||
|
microphone = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analyser) {
|
||||||
|
analyser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioContext && audioContext.state !== "closed") {
|
||||||
|
try {
|
||||||
|
await audioContext.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not close audio context:", e);
|
||||||
|
}
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioSource) {
|
||||||
|
audioSource.disconnect();
|
||||||
|
audioSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputAudioContext && outputAudioContext.state !== "closed") {
|
||||||
|
outputAudioContext.close()
|
||||||
|
outputAudioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
}
|
||||||
|
timerElement.textContent = "00:00";
|
||||||
|
startTime = null;
|
||||||
|
|
||||||
|
isRecording = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (!isRecording) {
|
||||||
|
if (waitingForStop) {
|
||||||
|
console.log("Waiting for stop, early return");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Connecting to WebSocket");
|
||||||
|
try {
|
||||||
|
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||||
|
await configReady;
|
||||||
|
await startRecording();
|
||||||
|
} else {
|
||||||
|
await setupWebSocket();
|
||||||
|
await configReady;
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusText.textContent = "Could not connect to WebSocket or access mic. Aborted.";
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Stopping recording");
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
recordButton.classList.toggle("recording", isRecording);
|
||||||
|
recordButton.disabled = waitingForStop;
|
||||||
|
|
||||||
|
if (waitingForStop) {
|
||||||
|
if (statusText.textContent !== "Recording stopped. Processing final audio...") {
|
||||||
|
statusText.textContent = "Please wait for processing to complete...";
|
||||||
|
}
|
||||||
|
} else if (isRecording) {
|
||||||
|
statusText.textContent = "";
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
statusText.textContent !== "Finished processing audio! Ready to record again." &&
|
||||||
|
statusText.textContent !== "Processing finalized or connection closed."
|
||||||
|
) {
|
||||||
|
statusText.textContent = "Click to start transcription";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!waitingForStop) {
|
||||||
|
recordButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordButton.addEventListener("click", toggleRecording);
|
||||||
|
|
||||||
|
if (microphoneSelect) {
|
||||||
|
microphoneSelect.addEventListener("change", handleMicrophoneChange);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await enumerateMicrophones();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Could not enumerate microphones on load:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', async () => {
|
||||||
|
console.log('Device change detected, re-enumerating microphones');
|
||||||
|
try {
|
||||||
|
await enumerateMicrophones();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error re-enumerating microphones:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
settingsToggle.addEventListener("click", () => {
|
||||||
|
settingsDiv.classList.toggle("visible");
|
||||||
|
settingsToggle.classList.toggle("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isExtension) {
|
||||||
|
async function checkAndRequestPermissions() {
|
||||||
|
const micPermission = await navigator.permissions.query({
|
||||||
|
name: "microphone",
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionDisplay = document.getElementById("audioPermission");
|
||||||
|
if (permissionDisplay) {
|
||||||
|
permissionDisplay.innerText = `MICROPHONE: ${micPermission.state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (micPermission.state !== "granted") {
|
||||||
|
// chrome.tabs.create({ url: "welcome.html" });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
const micPermission = await navigator.permissions.query({
|
||||||
|
name: "microphone",
|
||||||
|
});
|
||||||
|
if (micPermission.state === "granted") {
|
||||||
|
if (permissionDisplay) {
|
||||||
|
permissionDisplay.innerText = `MICROPHONE: ${micPermission.state}`;
|
||||||
|
}
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkAndRequestPermissions();
|
||||||
|
}
|
||||||
16
whisperlivekit/web/pcm_worklet.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class PCMForwarder extends AudioWorkletProcessor {
|
||||||
|
process(inputs) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (input && input[0] && input[0].length) {
|
||||||
|
// Forward mono channel (0). If multi-channel, downmixing can be added here.
|
||||||
|
const channelData = input[0];
|
||||||
|
const copy = new Float32Array(channelData.length);
|
||||||
|
copy.set(channelData);
|
||||||
|
this.port.postMessage(copy, [copy.buffer]);
|
||||||
|
}
|
||||||
|
// Keep processor alive
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pcm-forwarder', PCMForwarder);
|
||||||
58
whisperlivekit/web/recorder_worker.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
let sampleRate = 48000;
|
||||||
|
let targetSampleRate = 16000;
|
||||||
|
|
||||||
|
self.onmessage = function (e) {
|
||||||
|
switch (e.data.command) {
|
||||||
|
case 'init':
|
||||||
|
init(e.data.config);
|
||||||
|
break;
|
||||||
|
case 'record':
|
||||||
|
record(e.data.buffer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function init(config) {
|
||||||
|
sampleRate = config.sampleRate;
|
||||||
|
targetSampleRate = config.targetSampleRate || 16000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(inputBuffer) {
|
||||||
|
const buffer = new Float32Array(inputBuffer);
|
||||||
|
const resampledBuffer = resample(buffer, sampleRate, targetSampleRate);
|
||||||
|
const pcmBuffer = toPCM(resampledBuffer);
|
||||||
|
self.postMessage({ buffer: pcmBuffer }, [pcmBuffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resample(buffer, from, to) {
|
||||||
|
if (from === to) {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
const ratio = from / to;
|
||||||
|
const newLength = Math.round(buffer.length / ratio);
|
||||||
|
const result = new Float32Array(newLength);
|
||||||
|
let offsetResult = 0;
|
||||||
|
let offsetBuffer = 0;
|
||||||
|
while (offsetResult < result.length) {
|
||||||
|
const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
|
||||||
|
let accum = 0, count = 0;
|
||||||
|
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
|
||||||
|
accum += buffer[i];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
result[offsetResult] = accum / count;
|
||||||
|
offsetResult++;
|
||||||
|
offsetBuffer = nextOffsetBuffer;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPCM(input) {
|
||||||
|
const buffer = new ArrayBuffer(input.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, input[i]));
|
||||||
|
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
1
whisperlivekit/web/src/dark_mode.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-120q-151 0-255.5-104.5T120-480q0-138 90-239.5T440-838q13-2 23 3.5t16 14.5q6 9 6.5 21t-7.5 23q-17 26-25.5 55t-8.5 61q0 90 63 153t153 63q31 0 61.5-9t54.5-25q11-7 22.5-6.5T819-479q10 5 15.5 15t3.5 24q-14 138-117.5 229T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 493 B |
1
whisperlivekit/web/src/language.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
1
whisperlivekit/web/src/light_mode.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM80-440q-17 0-28.5-11.5T40-480q0-17 11.5-28.5T80-520h80q17 0 28.5 11.5T200-480q0 17-11.5 28.5T160-440H80Zm720 0q-17 0-28.5-11.5T760-480q0-17 11.5-28.5T800-520h80q17 0 28.5 11.5T920-480q0 17-11.5 28.5T880-440h-80ZM480-760q-17 0-28.5-11.5T440-800v-80q0-17 11.5-28.5T480-920q17 0 28.5 11.5T520-880v80q0 17-11.5 28.5T480-760Zm0 720q-17 0-28.5-11.5T440-80v-80q0-17 11.5-28.5T480-200q17 0 28.5 11.5T520-160v80q0 17-11.5 28.5T480-40ZM226-678l-43-42q-12-11-11.5-28t11.5-29q12-12 29-12t28 12l42 43q11 12 11 28t-11 28q-11 12-27.5 11.5T226-678Zm494 495-42-43q-11-12-11-28.5t11-27.5q11-12 27.5-11.5T734-282l43 42q12 11 11.5 28T777-183q-12 12-29 12t-28-12Zm-42-495q-12-11-11.5-27.5T678-734l42-43q11-12 28-11.5t29 11.5q12 12 12 29t-12 28l-43 42q-12 11-28 11t-28-11ZM183-183q-12-12-12-29t12-28l43-42q12-11 28.5-11t27.5 11q12 11 11.5 27.5T282-226l-42 43q-11 12-28 11.5T183-183Zm297-297Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
whisperlivekit/web/src/settings.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M433-80q-27 0-46.5-18T363-142l-9-66q-13-5-24.5-12T307-235l-62 26q-25 11-50 2t-39-32l-47-82q-14-23-8-49t27-43l53-40q-1-7-1-13.5v-27q0-6.5 1-13.5l-53-40q-21-17-27-43t8-49l47-82q14-23 39-32t50 2l62 26q11-8 23-15t24-12l9-66q4-26 23.5-44t46.5-18h94q27 0 46.5 18t23.5 44l9 66q13 5 24.5 12t22.5 15l62-26q25-11 50-2t39 32l47 82q14 23 8 49t-27 43l-53 40q1 7 1 13.5v27q0 6.5-2 13.5l53 40q21 17 27 43t-8 49l-48 82q-14 23-39 32t-50-2l-60-26q-11 8-23 15t-24 12l-9 66q-4 26-23.5 44T527-80h-94Zm7-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 982 B |
1
whisperlivekit/web/src/silence.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M514-556 320-752q9-3 19-5.5t21-2.5q66 0 113 47t47 113q0 11-1.5 22t-4.5 22ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q26 0 49.5 2.5T456-392l-56-54q-9 3-19 4.5t-21 1.5q-66 0-113-47t-47-113q0-11 1.5-21t4.5-19L84-764q-11-11-11-28t11-28q12-12 28.5-12t27.5 12l675 685q11 11 11.5 27.5T816-80q-11 13-28 12.5T759-80L641-200h39q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Zm80 0h480v-32q0-14-4.5-19.5T580-266q-36-18-92.5-36T360-320q-71 0-127.5 18T140-266q-9 5-14.5 14t-5.5 20v32Zm240 0Zm560-400q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 984 B |
1
whisperlivekit/web/src/speaker.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-240v-32q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v32q0 33-23.5 56.5T720-160H240q-33 0-56.5-23.5T160-240Zm80 0h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 592 B |
1
whisperlivekit/web/src/system_mode.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M396-396q-32-32-58.5-67T289-537q-5 14-6.5 28.5T281-480q0 83 58 141t141 58q14 0 28.5-2t28.5-6q-39-22-74-48.5T396-396Zm85 196q-56 0-107-21t-91-61q-40-40-61-91t-21-107q0-51 17-97.5t50-84.5q13-14 32-9.5t27 24.5q21 55 52.5 104t73.5 91q42 42 91 73.5T648-326q20 8 24.5 27t-9.5 32q-38 33-84.5 50T481-200Zm223-192q-16-5-23-20.5t-4-32.5q9-48-6-94.5T621-621q-35-35-80.5-49.5T448-677q-17 3-32-4t-21-23q-6-16 1.5-31t23.5-19q69-15 138 4.5T679-678q51 51 71 120t5 138q-4 17-19 25t-32 3ZM480-840q-17 0-28.5-11.5T440-880v-40q0-17 11.5-28.5T480-960q17 0 28.5 11.5T520-920v40q0 17-11.5 28.5T480-840Zm0 840q-17 0-28.5-11.5T440-40v-40q0-17 11.5-28.5T480-120q17 0 28.5 11.5T520-80v40q0 17-11.5 28.5T480 0Zm255-734q-12-12-12-28.5t12-28.5l28-28q11-11 27.5-11t28.5 11q12 12 12 28.5T819-762l-28 28q-12 12-28 12t-28-12ZM141-141q-12-12-12-28.5t12-28.5l28-28q12-12 28-12t28 12q12 12 12 28.5T225-169l-28 28q-11 11-27.5 11T141-141Zm739-299q-17 0-28.5-11.5T840-480q0-17 11.5-28.5T880-520h40q17 0 28.5 11.5T960-480q0 17-11.5 28.5T920-440h-40Zm-840 0q-17 0-28.5-11.5T0-480q0-17 11.5-28.5T40-520h40q17 0 28.5 11.5T120-480q0 17-11.5 28.5T80-440H40Zm779 299q-12 12-28.5 12T762-141l-28-28q-12-12-12-28t12-28q12-12 28.5-12t28.5 12l28 28q11 11 11 27.5T819-141ZM226-735q-12 12-28.5 12T169-735l-28-28q-11-11-11-27.5t11-28.5q12-12 28.5-12t28.5 12l28 28q12 12 12 28t-12 28Zm170 339Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
whisperlivekit/web/src/translate.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m603-202-34 97q-4 11-14 18t-22 7q-20 0-32.5-16.5T496-133l152-402q5-11 15-18t22-7h30q12 0 22 7t15 18l152 403q8 19-4 35.5T868-80q-13 0-22.5-7T831-106l-34-96H603ZM362-401 188-228q-11 11-27.5 11.5T132-228q-11-11-11-28t11-28l174-174q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H80q-17 0-28.5-11.5T40-760q0-17 11.5-28.5T80-800h240v-40q0-17 11.5-28.5T360-880q17 0 28.5 11.5T400-840v40h240q17 0 28.5 11.5T680-760q0 17-11.5 28.5T640-720h-76q-21 72-63 148t-83 116l96 98-30 82-122-125Zm266 129h144l-72-204-72 204Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 650 B |
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import importlib.resources as resources
|
import importlib.resources as resources
|
||||||
|
import base64
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -10,4 +11,104 @@ def get_web_interface_html():
|
|||||||
return f.read()
|
return f.read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading web interface HTML: {e}")
|
logger.error(f"Error loading web interface HTML: {e}")
|
||||||
return "<html><body><h1>Error loading interface</h1></body></html>"
|
return "<html><body><h1>Error loading interface</h1></body></html>"
|
||||||
|
|
||||||
|
def get_inline_ui_html():
|
||||||
|
"""Returns the complete web interface HTML with all assets embedded in a single call."""
|
||||||
|
try:
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('live_transcription.html').open('r', encoding='utf-8') as f:
|
||||||
|
html_content = f.read()
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('live_transcription.css').open('r', encoding='utf-8') as f:
|
||||||
|
css_content = f.read()
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('live_transcription.js').open('r', encoding='utf-8') as f:
|
||||||
|
js_content = f.read()
|
||||||
|
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('pcm_worklet.js').open('r', encoding='utf-8') as f:
|
||||||
|
worklet_code = f.read()
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('recorder_worker.js').open('r', encoding='utf-8') as f:
|
||||||
|
worker_code = f.read()
|
||||||
|
|
||||||
|
js_content = js_content.replace(
|
||||||
|
'await audioContext.audioWorklet.addModule("/web/pcm_worklet.js");',
|
||||||
|
'const workletBlob = new Blob([`' + worklet_code + '`], { type: "application/javascript" });\n' +
|
||||||
|
'const workletUrl = URL.createObjectURL(workletBlob);\n' +
|
||||||
|
'await audioContext.audioWorklet.addModule(workletUrl);'
|
||||||
|
)
|
||||||
|
js_content = js_content.replace(
|
||||||
|
'recorderWorker = new Worker("/web/recorder_worker.js");',
|
||||||
|
'const workerBlob = new Blob([`' + worker_code + '`], { type: "application/javascript" });\n' +
|
||||||
|
'const workerUrl = URL.createObjectURL(workerBlob);\n' +
|
||||||
|
'recorderWorker = new Worker(workerUrl);'
|
||||||
|
)
|
||||||
|
|
||||||
|
# SVG files
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('src', 'system_mode.svg').open('r', encoding='utf-8') as f:
|
||||||
|
system_svg = f.read()
|
||||||
|
system_data_uri = f"data:image/svg+xml;base64,{base64.b64encode(system_svg.encode('utf-8')).decode('utf-8')}"
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('src', 'light_mode.svg').open('r', encoding='utf-8') as f:
|
||||||
|
light_svg = f.read()
|
||||||
|
light_data_uri = f"data:image/svg+xml;base64,{base64.b64encode(light_svg.encode('utf-8')).decode('utf-8')}"
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('src', 'dark_mode.svg').open('r', encoding='utf-8') as f:
|
||||||
|
dark_svg = f.read()
|
||||||
|
dark_data_uri = f"data:image/svg+xml;base64,{base64.b64encode(dark_svg.encode('utf-8')).decode('utf-8')}"
|
||||||
|
with resources.files('whisperlivekit.web').joinpath('src', 'settings.svg').open('r', encoding='utf-8') as f:
|
||||||
|
settings = f.read()
|
||||||
|
settings_uri = f"data:image/svg+xml;base64,{base64.b64encode(settings.encode('utf-8')).decode('utf-8')}"
|
||||||
|
|
||||||
|
# Replace external references
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<link rel="stylesheet" href="live_transcription.css" />',
|
||||||
|
f'<style>\n{css_content}\n</style>'
|
||||||
|
)
|
||||||
|
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<script src="live_transcription.js"></script>',
|
||||||
|
f'<script>\n{js_content}\n</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace SVG references
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<img src="/web/src/system_mode.svg" alt="" />',
|
||||||
|
f'<img src="{system_data_uri}" alt="" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<img src="/web/src/light_mode.svg" alt="" />',
|
||||||
|
f'<img src="{light_data_uri}" alt="" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<img src="/web/src/dark_mode.svg" alt="" />',
|
||||||
|
f'<img src="{dark_data_uri}" alt="" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<img src="web/src/settings.svg" alt="Settings" />',
|
||||||
|
f'<img src="{settings_uri}" alt="" />'
|
||||||
|
)
|
||||||
|
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating embedded web interface: {e}")
|
||||||
|
return "<html><body><h1>Error loading embedded interface</h1></body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import uvicorn
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
import pathlib
|
||||||
|
import whisperlivekit.web as webpkg
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
web_dir = pathlib.Path(webpkg.__file__).parent
|
||||||
|
app.mount("/web", StaticFiles(directory=str(web_dir)), name="web")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def get():
|
||||||
|
return HTMLResponse(get_inline_ui_html())
|
||||||
|
|
||||||
|
uvicorn.run(app=app)
|
||||||
|
|||||||
463
whisperlivekit/whisper/__init__.py
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib
|
||||||
|
import warnings
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from tqdm import tqdm
|
||||||
|
from pathlib import Path
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
from whisperlivekit.whisper.audio import load_audio, log_mel_spectrogram, pad_or_trim
|
||||||
|
from whisperlivekit.whisper.decoding import DecodingOptions, DecodingResult, decode, detect_language
|
||||||
|
from whisperlivekit.whisper.model import ModelDimensions, Whisper
|
||||||
|
from whisperlivekit.whisper.transcribe import transcribe
|
||||||
|
from whisperlivekit.whisper.version import __version__
|
||||||
|
|
||||||
|
_MODELS = {
|
||||||
|
"tiny.en": "https://openaipublic.azureedge.net/main/whisper/models/d3dd57d32accea0b295c96e26691aa14d8822fac7d9d27d5dc00b4ca2826dd03/tiny.en.pt",
|
||||||
|
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt",
|
||||||
|
"base.en": "https://openaipublic.azureedge.net/main/whisper/models/25a8566e1d0c1e2231d1c762132cd20e0f96a85d16145c3a00adf5d1ac670ead/base.en.pt",
|
||||||
|
"base": "https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt",
|
||||||
|
"small.en": "https://openaipublic.azureedge.net/main/whisper/models/f953ad0fd29cacd07d5a9eda5624af0f6bcf2258be67c92b79389873d91e0872/small.en.pt",
|
||||||
|
"small": "https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt",
|
||||||
|
"medium.en": "https://openaipublic.azureedge.net/main/whisper/models/d7440d1dc186f76616474e0ff0b3b6b879abc9d1a4926b7adfa41db2d497ab4f/medium.en.pt",
|
||||||
|
"medium": "https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt",
|
||||||
|
"large-v1": "https://openaipublic.azureedge.net/main/whisper/models/e4b87e7e0bf463eb8e6956e646f1e277e901512310def2c24bf0e11bd3c28e9a/large-v1.pt",
|
||||||
|
"large-v2": "https://openaipublic.azureedge.net/main/whisper/models/81f7c96c852ee8fc832187b0132e569d6c3065a3252ed18e56effd0b6a73e524/large-v2.pt",
|
||||||
|
"large-v3": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
|
||||||
|
"large": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
|
||||||
|
"large-v3-turbo": "https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt",
|
||||||
|
"turbo": "https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# base85-encoded (n_layers, n_heads) boolean arrays indicating the cross-attention heads that are
|
||||||
|
# highly correlated to the word-level timing, i.e. the alignment between audio and text tokens.
|
||||||
|
_ALIGNMENT_HEADS = {
|
||||||
|
"tiny.en": b"ABzY8J1N>@0{>%R00Bk>$p{7v037`oCl~+#00",
|
||||||
|
"tiny": b"ABzY8bu8Lr0{>%RKn9Fp%m@SkK7Kt=7ytkO",
|
||||||
|
"base.en": b"ABzY8;40c<0{>%RzzG;p*o+Vo09|#PsxSZm00",
|
||||||
|
"base": b"ABzY8KQ!870{>%RzyTQH3`Q^yNP!>##QT-<FaQ7m",
|
||||||
|
"small.en": b"ABzY8>?_)10{>%RpeA61k&I|OI3I$65C{;;pbCHh0B{qLQ;+}v00",
|
||||||
|
"small": b"ABzY8DmU6=0{>%Rpa?J`kvJ6qF(V^F86#Xh7JUGMK}P<N0000",
|
||||||
|
"medium.en": b"ABzY8usPae0{>%R7<zz_OvQ{)4kMa0BMw6u5rT}kRKX;$NfYBv00*Hl@qhsU00",
|
||||||
|
"medium": b"ABzY8B0Jh+0{>%R7}kK1fFL7w6%<-Pf*t^=N)Qr&0RR9",
|
||||||
|
"large-v1": b"ABzY8r9j$a0{>%R7#4sLmoOs{s)o3~84-RPdcFk!JR<kSfC2yj",
|
||||||
|
"large-v2": b"ABzY8zd+h!0{>%R7=D0pU<_bnWW*tkYAhobTNnu$jnkEkXqp)j;w1Tzk)UH3X%SZd&fFZ2fC2yj",
|
||||||
|
"large-v3": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
|
||||||
|
"large": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
|
||||||
|
"large-v3-turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
|
||||||
|
"turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, root: str, in_memory: bool) -> Union[bytes, str]:
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
|
||||||
|
expected_sha256 = url.split("/")[-2]
|
||||||
|
download_target = os.path.join(root, os.path.basename(url))
|
||||||
|
|
||||||
|
if os.path.exists(download_target) and not os.path.isfile(download_target):
|
||||||
|
raise RuntimeError(f"{download_target} exists and is not a regular file")
|
||||||
|
|
||||||
|
if os.path.isfile(download_target):
|
||||||
|
with open(download_target, "rb") as f:
|
||||||
|
model_bytes = f.read()
|
||||||
|
if hashlib.sha256(model_bytes).hexdigest() == expected_sha256:
|
||||||
|
return model_bytes if in_memory else download_target
|
||||||
|
else:
|
||||||
|
warnings.warn(
|
||||||
|
f"{download_target} exists, but the SHA256 checksum does not match; re-downloading the file"
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(url) as source, open(download_target, "wb") as output:
|
||||||
|
with tqdm(
|
||||||
|
total=int(source.info().get("Content-Length")),
|
||||||
|
ncols=80,
|
||||||
|
unit="iB",
|
||||||
|
unit_scale=True,
|
||||||
|
unit_divisor=1024,
|
||||||
|
) as loop:
|
||||||
|
while True:
|
||||||
|
buffer = source.read(8192)
|
||||||
|
if not buffer:
|
||||||
|
break
|
||||||
|
|
||||||
|
output.write(buffer)
|
||||||
|
loop.update(len(buffer))
|
||||||
|
|
||||||
|
model_bytes = open(download_target, "rb").read()
|
||||||
|
if hashlib.sha256(model_bytes).hexdigest() != expected_sha256:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Model has been downloaded but the SHA256 checksum does not not match. Please retry loading the model."
|
||||||
|
)
|
||||||
|
|
||||||
|
return model_bytes if in_memory else download_target
|
||||||
|
|
||||||
|
|
||||||
|
def available_models() -> List[str]:
|
||||||
|
"""Returns the names of available models"""
|
||||||
|
return list(_MODELS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_dims_from_config(path: str) -> Optional[ModelDimensions]:
|
||||||
|
"""
|
||||||
|
attempt to infer ModelDimensions from a HF style config.json located
|
||||||
|
next to the given checkpoint, usefull for distilled models
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
if os.path.isdir(path):
|
||||||
|
candidates.append(os.path.join(path, "config.json"))
|
||||||
|
else:
|
||||||
|
candidates.append(os.path.join(os.path.dirname(path), "config.json"))
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if not os.path.isfile(candidate):
|
||||||
|
continue
|
||||||
|
with open(candidate, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return ModelDimensions(
|
||||||
|
n_mels=config["num_mel_bins"],
|
||||||
|
n_audio_ctx=config["max_source_positions"],
|
||||||
|
n_audio_state=config["d_model"],
|
||||||
|
n_audio_head=config["encoder_attention_heads"],
|
||||||
|
n_audio_layer=config.get("encoder_layers")
|
||||||
|
or config["num_hidden_layers"],
|
||||||
|
n_vocab=config["vocab_size"],
|
||||||
|
n_text_ctx=config["max_target_positions"],
|
||||||
|
n_text_state=config["d_model"],
|
||||||
|
n_text_head=config["decoder_attention_heads"],
|
||||||
|
n_text_layer=config["decoder_layers"],
|
||||||
|
)
|
||||||
|
except KeyError as err:
|
||||||
|
warnings.warn(f"Missing key {err} in HuggingFace config {candidate}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_hf_state_dict(state_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
|
||||||
|
"""
|
||||||
|
converts a HF checkpoint state_dict into the naming convention used by
|
||||||
|
default whisper
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not any(k.startswith("model.") for k in state_dict):
|
||||||
|
return state_dict
|
||||||
|
|
||||||
|
def map_block(prefix: str, target_prefix: str, remainder: str) -> Optional[str]:
|
||||||
|
if remainder.startswith("self_attn."):
|
||||||
|
suffix = remainder.split(".", 1)[1]
|
||||||
|
mapping = {
|
||||||
|
"q_proj": "attn.query",
|
||||||
|
"k_proj": "attn.key",
|
||||||
|
"v_proj": "attn.value",
|
||||||
|
"out_proj": "attn.out",
|
||||||
|
}
|
||||||
|
stem = mapping.get(suffix.split(".")[0])
|
||||||
|
if stem:
|
||||||
|
rest = suffix.split(".", 1)[1] if "." in suffix else ""
|
||||||
|
return f"{target_prefix}.{stem}" + (f".{rest}" if rest else "")
|
||||||
|
elif remainder == "self_attn_layer_norm.weight":
|
||||||
|
return f"{target_prefix}.attn_ln.weight"
|
||||||
|
elif remainder == "self_attn_layer_norm.bias":
|
||||||
|
return f"{target_prefix}.attn_ln.bias"
|
||||||
|
elif remainder.startswith("encoder_attn."):
|
||||||
|
suffix = remainder.split(".", 1)[1]
|
||||||
|
mapping = {
|
||||||
|
"q_proj": "cross_attn.query",
|
||||||
|
"k_proj": "cross_attn.key",
|
||||||
|
"v_proj": "cross_attn.value",
|
||||||
|
"out_proj": "cross_attn.out",
|
||||||
|
}
|
||||||
|
stem = mapping.get(suffix.split(".", 1)[0])
|
||||||
|
if stem:
|
||||||
|
rest = suffix.split(".", 1)[1] if "." in suffix else ""
|
||||||
|
return f"{target_prefix}.{stem}" + (f".{rest}" if rest else "")
|
||||||
|
elif remainder == "encoder_attn_layer_norm.weight":
|
||||||
|
return f"{target_prefix}.cross_attn_ln.weight"
|
||||||
|
elif remainder == "encoder_attn_layer_norm.bias":
|
||||||
|
return f"{target_prefix}.cross_attn_ln.bias"
|
||||||
|
elif remainder.startswith("fc1."):
|
||||||
|
return f"{target_prefix}.mlp.0.{remainder.split('.',1)[1]}"
|
||||||
|
elif remainder.startswith("fc2."):
|
||||||
|
return f"{target_prefix}.mlp.2.{remainder.split('.',1)[1]}"
|
||||||
|
elif remainder == "final_layer_norm.weight":
|
||||||
|
return f"{target_prefix}.mlp_ln.weight"
|
||||||
|
elif remainder == "final_layer_norm.bias":
|
||||||
|
return f"{target_prefix}.mlp_ln.bias"
|
||||||
|
return None
|
||||||
|
|
||||||
|
converted = {}
|
||||||
|
for key, value in state_dict.items():
|
||||||
|
if not key.startswith("model."):
|
||||||
|
continue
|
||||||
|
subkey = key[len("model.") :]
|
||||||
|
|
||||||
|
if subkey.startswith("encoder.layers."):
|
||||||
|
parts = subkey.split(".")
|
||||||
|
layer_idx = parts[2]
|
||||||
|
remainder = ".".join(parts[3:])
|
||||||
|
mapped = map_block(subkey, f"encoder.blocks.{layer_idx}", remainder)
|
||||||
|
elif subkey.startswith("decoder.layers."):
|
||||||
|
parts = subkey.split(".")
|
||||||
|
layer_idx = parts[2]
|
||||||
|
remainder = ".".join(parts[3:])
|
||||||
|
mapped = map_block(subkey, f"decoder.blocks.{layer_idx}", remainder)
|
||||||
|
elif subkey.startswith("encoder.conv") or subkey.startswith("decoder.conv"):
|
||||||
|
mapped = subkey
|
||||||
|
elif subkey == "encoder.embed_positions.weight":
|
||||||
|
mapped = "encoder.positional_embedding"
|
||||||
|
elif subkey == "decoder.embed_positions.weight":
|
||||||
|
mapped = "decoder.positional_embedding"
|
||||||
|
elif subkey == "encoder.layer_norm.weight":
|
||||||
|
mapped = "encoder.ln_post.weight"
|
||||||
|
elif subkey == "encoder.layer_norm.bias":
|
||||||
|
mapped = "encoder.ln_post.bias"
|
||||||
|
elif subkey.startswith("decoder.embed_tokens."):
|
||||||
|
mapped = subkey.replace("embed_tokens", "token_embedding", 1)
|
||||||
|
elif subkey == "decoder.layer_norm.weight":
|
||||||
|
mapped = "decoder.ln.weight"
|
||||||
|
elif subkey == "decoder.layer_norm.bias":
|
||||||
|
mapped = "decoder.ln.bias"
|
||||||
|
else:
|
||||||
|
mapped = None
|
||||||
|
|
||||||
|
if mapped:
|
||||||
|
converted[mapped] = value
|
||||||
|
|
||||||
|
return converted if converted else state_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lora_state(lora_path: str):
|
||||||
|
safe_path = os.path.join(lora_path, "adapter_model.safetensors")
|
||||||
|
bin_path = os.path.join(lora_path, "adapter_model.bin")
|
||||||
|
if os.path.isfile(safe_path):
|
||||||
|
try:
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Loading LoRA adapters stored as .safetensors requires the `safetensors` package."
|
||||||
|
) from exc
|
||||||
|
return load_file(safe_path)
|
||||||
|
if os.path.isfile(bin_path):
|
||||||
|
return torch.load(bin_path, map_location="cpu")
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No adapter weights found under {lora_path}. Expected adapter_model.safetensors or adapter_model.bin."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collapse_hf_module_name(module: str):
|
||||||
|
if module.startswith("base_model."):
|
||||||
|
module = module[len("base_model.") :]
|
||||||
|
if module.startswith("model.model."):
|
||||||
|
module = module[len("model.") :]
|
||||||
|
if not module.startswith("model."):
|
||||||
|
module = f"model.{module}"
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_lora_adapter(state_dict: Dict[str, Tensor], lora_path: Optional[str]):
|
||||||
|
if not lora_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
config_path = os.path.join(lora_path, "adapter_config.json")
|
||||||
|
if not os.path.isfile(config_path):
|
||||||
|
raise FileNotFoundError(f"Missing adapter_config.json inside {lora_path}")
|
||||||
|
with open(config_path, "r", encoding="utf-8") as handle:
|
||||||
|
config = json.load(handle)
|
||||||
|
if config.get("peft_type") != "LORA":
|
||||||
|
raise ValueError("Only LoRA adapters are supported.")
|
||||||
|
|
||||||
|
r = config.get("r")
|
||||||
|
alpha = config.get("lora_alpha") or config.get("alpha")
|
||||||
|
if not r or not alpha:
|
||||||
|
raise ValueError("LoRA config must include `r` and `lora_alpha`.")
|
||||||
|
scaling = alpha / r
|
||||||
|
|
||||||
|
adapter_state = _load_lora_state(lora_path)
|
||||||
|
lora_layers: Dict[str, Dict[str, Tensor]] = {}
|
||||||
|
for key, tensor in adapter_state.items():
|
||||||
|
if key.endswith("lora_A.weight"):
|
||||||
|
module = key[: -len(".lora_A.weight")]
|
||||||
|
lora_layers.setdefault(module, {})["A"] = tensor
|
||||||
|
elif key.endswith("lora_B.weight"):
|
||||||
|
module = key[: -len(".lora_B.weight")]
|
||||||
|
lora_layers.setdefault(module, {})["B"] = tensor
|
||||||
|
|
||||||
|
if not lora_layers:
|
||||||
|
raise ValueError(f"No LoRA tensors found in {lora_path}")
|
||||||
|
|
||||||
|
for module, parts in lora_layers.items():
|
||||||
|
if "A" not in parts or "B" not in parts:
|
||||||
|
raise ValueError(f"Incomplete LoRA tensors for module '{module}'")
|
||||||
|
|
||||||
|
hf_module = _collapse_hf_module_name(module)
|
||||||
|
hf_weight_key = f"{hf_module}.weight"
|
||||||
|
|
||||||
|
delta = parts["B"] @ parts["A"]
|
||||||
|
delta = delta * scaling
|
||||||
|
|
||||||
|
converted = _convert_hf_state_dict({hf_weight_key: delta})
|
||||||
|
if not converted:
|
||||||
|
raise KeyError(f"Failed to map LoRA module '{module}' into Whisper state dict.")
|
||||||
|
target_name, delta_tensor = next(iter(converted.items()))
|
||||||
|
if target_name not in state_dict:
|
||||||
|
raise KeyError(
|
||||||
|
f"LoRA module '{module}' mapped to '{target_name}', but the base model has no such parameter."
|
||||||
|
)
|
||||||
|
|
||||||
|
state_dict[target_name] = state_dict[target_name] + delta_tensor.to(
|
||||||
|
dtype=state_dict[target_name].dtype, device=state_dict[target_name].device
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_model(
|
||||||
|
name: str,
|
||||||
|
device: Optional[Union[str, torch.device]] = None,
|
||||||
|
download_root: str = None,
|
||||||
|
in_memory: bool = False,
|
||||||
|
decoder_only: bool = False,
|
||||||
|
custom_alignment_heads: Optional[str] = None,
|
||||||
|
lora_path: Optional[str] = None,
|
||||||
|
) -> Whisper:
|
||||||
|
"""
|
||||||
|
Load a Whisper ASR model
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : str
|
||||||
|
one of the official model names listed by `whisper.available_models()`, or
|
||||||
|
path to a model checkpoint containing the model dimensions and the model state_dict.
|
||||||
|
device : Union[str, torch.device]
|
||||||
|
the PyTorch device to put the model into
|
||||||
|
download_root: str
|
||||||
|
path to download the model files; by default, it uses "~/.cache/whisper"
|
||||||
|
in_memory: bool
|
||||||
|
whether to preload the model weights into host memory
|
||||||
|
lora_path: str
|
||||||
|
optional directory containing PEFT LoRA adapter weights (adapter_config + adapter_model)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
model : Whisper
|
||||||
|
The Whisper ASR model instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
if download_root is None:
|
||||||
|
default = os.path.join(os.path.expanduser("~"), ".cache")
|
||||||
|
download_root = os.path.join(os.getenv("XDG_CACHE_HOME", default), "whisper")
|
||||||
|
if name in _MODELS:
|
||||||
|
checkpoint_file = _download(_MODELS[name], download_root, in_memory)
|
||||||
|
elif os.path.isfile(name):
|
||||||
|
checkpoint_file = open(name, "rb").read() if in_memory else name
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Model {name} not found; available models = {available_models()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
alignment_heads = _ALIGNMENT_HEADS.get(name, None)
|
||||||
|
if custom_alignment_heads:
|
||||||
|
alignment_heads = custom_alignment_heads.encode()
|
||||||
|
|
||||||
|
if isinstance(checkpoint_file, Path) and checkpoint_file.suffix == '.safetensors':
|
||||||
|
try:
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("Please install safetensors to load .safetensors model files: `pip install safetensors`")
|
||||||
|
if in_memory:
|
||||||
|
checkpoint = load_file(checkpoint_file, device=device)
|
||||||
|
else:
|
||||||
|
checkpoint = load_file(checkpoint_file, device=device)
|
||||||
|
else:
|
||||||
|
with (
|
||||||
|
io.BytesIO(checkpoint_file) if in_memory else open(checkpoint_file, "rb")
|
||||||
|
) as fp:
|
||||||
|
checkpoint = torch.load(fp, map_location=device)
|
||||||
|
del checkpoint_file
|
||||||
|
|
||||||
|
dims_cfg = checkpoint.get("dims") if isinstance(checkpoint, dict) else None
|
||||||
|
if isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
|
||||||
|
state_dict = checkpoint["model_state_dict"]
|
||||||
|
else:
|
||||||
|
state_dict = checkpoint
|
||||||
|
state_dict = _convert_hf_state_dict(state_dict)
|
||||||
|
_apply_lora_adapter(state_dict, lora_path)
|
||||||
|
|
||||||
|
if dims_cfg is not None:
|
||||||
|
dims = ModelDimensions(**dims_cfg)
|
||||||
|
else:
|
||||||
|
dims = _infer_dims_from_config(name)
|
||||||
|
if dims is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Could not determine model dimensions. "
|
||||||
|
"Ensure the checkpoint includes 'dims' or a HuggingFace config.json is present."
|
||||||
|
)
|
||||||
|
if not isinstance(state_dict, dict):
|
||||||
|
state_dict = checkpoint
|
||||||
|
|
||||||
|
model = Whisper(dims, decoder_only=decoder_only)
|
||||||
|
|
||||||
|
if decoder_only:
|
||||||
|
state_dict = {
|
||||||
|
k: v for k, v in state_dict.items()
|
||||||
|
if 'encoder' not in k
|
||||||
|
}
|
||||||
|
|
||||||
|
model.load_state_dict(state_dict)
|
||||||
|
|
||||||
|
if alignment_heads is not None:
|
||||||
|
model.set_alignment_heads(alignment_heads)
|
||||||
|
|
||||||
|
return model.to(device)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_encoder_to_coreml(
|
||||||
|
model_name = "base",
|
||||||
|
output_path= "whisper_encoder.mlpackage",
|
||||||
|
dummy_frames = 3000, #Number of time frames to use for the dummy mel input during tracing
|
||||||
|
precision = "float16",
|
||||||
|
):
|
||||||
|
|
||||||
|
import coremltools as ct
|
||||||
|
model = load_model(model_name, device="cpu", decoder_only=False)
|
||||||
|
encoder = model.encoder.eval().cpu()
|
||||||
|
|
||||||
|
dummy_input = torch.randn(
|
||||||
|
1,
|
||||||
|
model.dims.n_mels,
|
||||||
|
dummy_frames,
|
||||||
|
dtype=next(encoder.parameters()).dtype,
|
||||||
|
)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
traced_encoder = torch.jit.trace(encoder, dummy_input)
|
||||||
|
|
||||||
|
precision_map = {
|
||||||
|
"float16": ct.precision.FLOAT16,
|
||||||
|
"fp16": ct.precision.FLOAT16,
|
||||||
|
"float32": ct.precision.FLOAT32,
|
||||||
|
"fp32": ct.precision.FLOAT32,
|
||||||
|
}
|
||||||
|
coreml_precision = precision_map[precision.lower()]
|
||||||
|
|
||||||
|
mlmodel = ct.convert(
|
||||||
|
traced_encoder,
|
||||||
|
inputs=[ct.TensorType(name="mel", shape=dummy_input.shape)],
|
||||||
|
convert_to= "mlprogram",
|
||||||
|
compute_precision=coreml_precision,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_path = Path(output_path)
|
||||||
|
mlmodel.save(str(output_path))
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
# if __name__ == "__main__":
|
||||||
|
# convert_encoder_to_coreml(model_name="tiny", output_path="whisper_encoder.mlpackage", dummy_frames=3000, precision="float16", convert_to="mlprogram")
|
||||||
0
whisperlivekit/whisper/assets/__init__.py
Normal file
@@ -253,16 +253,18 @@ class TextDecoder(nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
class Whisper(nn.Module):
|
class Whisper(nn.Module):
|
||||||
def __init__(self, dims: ModelDimensions):
|
def __init__(self, dims: ModelDimensions, decoder_only: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.dims = dims
|
self.dims = dims
|
||||||
self.encoder = AudioEncoder(
|
|
||||||
self.dims.n_mels,
|
if not decoder_only:
|
||||||
self.dims.n_audio_ctx,
|
self.encoder = AudioEncoder(
|
||||||
self.dims.n_audio_state,
|
self.dims.n_mels,
|
||||||
self.dims.n_audio_head,
|
self.dims.n_audio_ctx,
|
||||||
self.dims.n_audio_layer,
|
self.dims.n_audio_state,
|
||||||
)
|
self.dims.n_audio_head,
|
||||||
|
self.dims.n_audio_layer,
|
||||||
|
)
|
||||||
self.decoder = TextDecoder(
|
self.decoder = TextDecoder(
|
||||||
self.dims.n_vocab,
|
self.dims.n_vocab,
|
||||||
self.dims.n_text_ctx,
|
self.dims.n_text_ctx,
|
||||||