refactor(whisper): migrate word alignment to ONNX backend and remove whisper.cpp integration
Replace the previous whisper.cpp-based word alignment with a fully ONNX-based implementation using onnxruntime-node and @huggingface/tokenizers. Add new Whisper ONNX model management, alignment mapping, and spectral analysis modules. Remove all code and documentation referencing whisper.cpp, update environment variables, Dockerfile, and docs to reflect ONNX-only alignment. Add unit tests for alignment and ONNX model logic.
This commit is contained in:
parent
6ab5c230c2
commit
874e5ef359
33 changed files with 2131 additions and 511 deletions
21
.env.example
21
.env.example
|
|
@ -77,10 +77,7 @@ RUN_FS_MIGRATIONS=
|
|||
IMPORT_LIBRARY_DIR=
|
||||
IMPORT_LIBRARY_DIRS=
|
||||
|
||||
# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation
|
||||
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
|
||||
|
||||
# Heavy compute backend mode for whisper alignment + PDF layout parsing.
|
||||
# Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing.
|
||||
# local = run compute in-process (default)
|
||||
# none = disable both capabilities (good for preview/serverless)
|
||||
# worker = reserved for future external worker mode (not implemented in v1)
|
||||
|
|
@ -88,6 +85,22 @@ OPENREADER_COMPUTE_MODE=local
|
|||
# OPENREADER_COMPUTE_WORKER_URL=
|
||||
# OPENREADER_COMPUTE_WORKER_TOKEN=
|
||||
|
||||
# Optional overrides for Whisper ONNX artifacts
|
||||
# Defaults target: onnx-community/whisper-base_timestamped int8
|
||||
# OPENREADER_WHISPER_MODEL_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/config.json
|
||||
# OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/generation_config.json
|
||||
# OPENREADER_WHISPER_MODEL_TOKENIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer.json
|
||||
# OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer_config.json
|
||||
# OPENREADER_WHISPER_MODEL_MERGES_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/merges.txt
|
||||
# OPENREADER_WHISPER_MODEL_VOCAB_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/vocab.json
|
||||
# OPENREADER_WHISPER_MODEL_NORMALIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/normalizer.json
|
||||
# OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/added_tokens.json
|
||||
# OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/preprocessor_config.json
|
||||
# OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/special_tokens_map.json
|
||||
# OPENREADER_WHISPER_MODEL_ENCODER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/encoder_model_int8.onnx
|
||||
# OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_model_merged_int8.onnx
|
||||
# OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_with_past_model_int8.onnx
|
||||
|
||||
# Optional overrides for PDF layout model artifacts
|
||||
# OPENREADER_PDF_LAYOUT_MODEL_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx
|
||||
# OPENREADER_PDF_LAYOUT_MODEL_DATA_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data
|
||||
|
|
|
|||
26
Dockerfile
26
Dockerfile
|
|
@ -1,19 +1,4 @@
|
|||
# Stage 1: build whisper.cpp (no model download – the app handles that)
|
||||
FROM alpine:3.23 AS whisper-builder
|
||||
|
||||
RUN apk add --no-cache git cmake build-base
|
||||
|
||||
WORKDIR /opt
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \
|
||||
cd whisper.cpp && \
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \
|
||||
cmake --build build -j
|
||||
RUN wget -qO /tmp/whisper.cpp-LICENSE.txt "https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/LICENSE"
|
||||
|
||||
# Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini)
|
||||
# Stage 1: extract seaweedfs weed binary (for optional embedded weed mini)
|
||||
# Pin to 4.18 because CI observed upload regressions on 4.19.
|
||||
FROM chrislusf/seaweedfs:4.18 AS seaweedfs-builder
|
||||
RUN cp "$(command -v weed)" /tmp/weed && \
|
||||
|
|
@ -75,17 +60,12 @@ COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LIC
|
|||
# Include static model notices for runtime-downloaded assets.
|
||||
COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
||||
|
||||
# Copy the compiled whisper.cpp build output into the runtime image
|
||||
# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so)
|
||||
COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build
|
||||
COPY --from=whisper-builder /tmp/whisper.cpp-LICENSE.txt /licenses/whisper.cpp-LICENSE.txt
|
||||
# Copy seaweedfs weed binary for optional embedded local S3.
|
||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||
RUN chmod +x /usr/local/bin/weed
|
||||
|
||||
# Point the app at the compiled whisper-cli binary and ensure its libs are discoverable
|
||||
ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli
|
||||
ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
|
||||
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||
COPY src/lib/server/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3003
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader
|
|||
|
||||
- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra).
|
||||
- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration.
|
||||
- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`).
|
||||
- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment in local compute mode (`OPENREADER_COMPUTE_MODE=local`).
|
||||
- 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering.
|
||||
- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders.
|
||||
- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ This project is built with support from the following open-source projects and t
|
|||
- [SQLite](https://www.sqlite.org/)
|
||||
- [PostgreSQL](https://www.postgresql.org/)
|
||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
|
||||
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
|
||||
- [OpenAI Whisper](https://github.com/openai/whisper)
|
||||
- [ffmpeg](https://ffmpeg.org)
|
||||
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
||||
- [react-reader](https://github.com/happyr/react-reader)
|
||||
|
|
|
|||
|
|
@ -114,50 +114,13 @@ sudo apt install -y libreoffice
|
|||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>whisper.cpp (optional, for word-by-word highlighting)</strong></summary>
|
||||
<summary><strong>Word-by-word highlighting (optional)</strong></summary>
|
||||
|
||||
Install build dependencies:
|
||||
No extra native Whisper CLI build step is required.
|
||||
|
||||
<Tabs groupId="local-dev-whisper-deps-os">
|
||||
<TabItem value="macos" label="macOS" default>
|
||||
Set `OPENREADER_COMPUTE_MODE=local` to enable built-in ONNX word alignment in-process.
|
||||
|
||||
```bash
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="linux" label="Linux">
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu example
|
||||
sudo apt update
|
||||
sudo apt install -y git build-essential cmake
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Build whisper.cpp:
|
||||
|
||||
```bash
|
||||
# clone and build whisper.cpp (no model download needed – OpenReader handles that)
|
||||
git clone https://github.com/ggml-org/whisper.cpp.git
|
||||
cd whisper.cpp
|
||||
cmake -B build
|
||||
cmake --build build -j --config Release
|
||||
|
||||
# point OpenReader to the compiled whisper-cli binary
|
||||
echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli"
|
||||
```
|
||||
|
||||
If you are not on Debian/Ubuntu, install equivalent packages with your distro package manager:
|
||||
|
||||
- Fedora/RHEL: use `dnf` (`gcc gcc-c++ make cmake curl git tar xz`)
|
||||
- Arch: use `pacman` (`base-devel cmake curl git tar xz`)
|
||||
|
||||
:::tip
|
||||
Set `OPENREADER_COMPUTE_MODE=local` and `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting.
|
||||
:::
|
||||
If you need mirrors or pinned artifact locations, set `OPENREADER_WHISPER_MODEL_*_URL` overrides in `.env`.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in
|
|||
|
||||
# Heavy compute (recommended on Vercel in v1)
|
||||
# local = requires native binaries/models in-process
|
||||
# none = disable whisper alignment + PDF layout parsing
|
||||
# none = disable ONNX whisper alignment + PDF layout parsing
|
||||
OPENREADER_COMPUTE_MODE=none
|
||||
|
||||
# First-boot seed for the TTS shared provider (optional; manage in-app afterwards)
|
||||
|
|
@ -95,8 +95,7 @@ Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic
|
|||
|
||||
- `/api/audiobook`
|
||||
- `/api/audiobook/chapter`
|
||||
- `/api/audiobook/status`
|
||||
- `/api/whisper`
|
||||
- `/api/tts/segments/ensure`
|
||||
|
||||
:::info
|
||||
`serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output.
|
||||
|
|
@ -113,7 +112,7 @@ FFmpeg workloads benefit from more memory/CPU. This repo includes:
|
|||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"functions": {
|
||||
"app/api/audiobook/route.ts": { "memory": 3009 },
|
||||
"app/api/whisper/route.ts": { "memory": 3009 }
|
||||
"app/api/tts/segments/ensure/route.ts": { "memory": 3009 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -130,4 +129,4 @@ Adjust memory per route if your files are larger or your plan differs.
|
|||
1. Upload and read a PDF/EPUB document.
|
||||
2. Confirm sync/blob fetch works across refreshes/devices.
|
||||
3. Generate at least one audiobook chapter and play/download it.
|
||||
4. If you later enable compute locally (`OPENREADER_COMPUTE_MODE=local`), verify word highlighting timestamps on a TTS run.
|
||||
4. If you run with local compute (`OPENREADER_COMPUTE_MODE=local`) outside Vercel, verify word highlighting timestamps on a TTS run.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
|
|||
- [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models
|
||||
- [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts`
|
||||
- 📖 **Read Along Experience**
|
||||
- Real-time highlighting for PDF/EPUB, with optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps
|
||||
- Real-time highlighting for PDF/EPUB, with built-in ONNX Whisper word-level timestamps in local compute mode
|
||||
- 🛜 **Document Storage**
|
||||
- Documents are persisted in server blob/object storage for consistent access
|
||||
- ⚡ **Segment-based TTS Playback** for reusable generation + preloading
|
||||
|
|
|
|||
|
|
@ -53,14 +53,14 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
|||
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
|
||||
| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
|
||||
| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
|
||||
| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing |
|
||||
| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing |
|
||||
| `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) |
|
||||
| `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) |
|
||||
| `OPENREADER_PDF_LAYOUT_MODEL_URL` | PDF layout model | PP-DocLayoutV3 ONNX URL | Override ONNX model URL for `ensureModel()` |
|
||||
| `OPENREADER_PDF_LAYOUT_MODEL_DATA_URL` | PDF layout model | PP-DocLayoutV3 ONNX data URL | Override ONNX external data URL for `ensureModel()` |
|
||||
| `OPENREADER_PDF_LAYOUT_CONFIG_URL` | PDF layout model | PP-DocLayoutV3 config URL | Override model config URL for `ensureModel()` |
|
||||
| `OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL` | PDF layout model | PP-DocLayoutV3 preprocessor URL | Override model preprocessor URL for `ensureModel()` |
|
||||
| `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` |
|
||||
| `OPENREADER_WHISPER_MODEL_*_URL` | Whisper ONNX model | onnx-community defaults | Optional per-artifact URL overrides for ONNX whisper-base_timestamped int8 downloads |
|
||||
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
|
||||
|
||||
|
||||
|
|
@ -355,7 +355,7 @@ Multiple library roots for server library import.
|
|||
|
||||
### OPENREADER_COMPUTE_MODE
|
||||
|
||||
Selects the backend for heavy compute features (word alignment + PDF layout parsing).
|
||||
Selects the backend for heavy compute features (ONNX word alignment + PDF layout parsing).
|
||||
|
||||
- Default: `local`
|
||||
- Supported in v1:
|
||||
|
|
@ -403,12 +403,29 @@ Override URL for the PP-DocLayoutV3 `preprocessor_config.json` downloaded by `en
|
|||
- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json`
|
||||
- You can pre-populate the model cache via `pnpm fetch-models`
|
||||
|
||||
### WHISPER_CPP_BIN
|
||||
### OPENREADER_WHISPER_MODEL_*_URL
|
||||
|
||||
Absolute path to compiled `whisper.cpp` binary for word-level timestamps.
|
||||
Optional per-artifact override URLs for the built-in ONNX Whisper alignment model downloader.
|
||||
|
||||
- Example: `/whisper.cpp/build/bin/whisper-cli`
|
||||
- Required only for optional word-by-word highlighting
|
||||
- Default base: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main`
|
||||
- Default model variant: int8 (`encoder_model_int8.onnx`, `decoder_model_merged_int8.onnx`, `decoder_with_past_model_int8.onnx`)
|
||||
- Use these when you need mirrors, pinned snapshots, or air-gapped fetch routing.
|
||||
|
||||
Supported override vars:
|
||||
|
||||
- `OPENREADER_WHISPER_MODEL_CONFIG_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_TOKENIZER_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_MERGES_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_VOCAB_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_NORMALIZER_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_ENCODER_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL`
|
||||
- `OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL`
|
||||
|
||||
### FFMPEG_BIN
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ title: Stack
|
|||
- App tables are manually maintained in Drizzle schema files
|
||||
- Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle
|
||||
- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3
|
||||
- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps
|
||||
- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, built-in ONNX Whisper (`onnx-community/whisper-base_timestamped` int8) for word timestamps
|
||||
|
||||
## Tooling and testing
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ const securityHeaders = [
|
|||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
|
|
@ -30,7 +29,13 @@ const nextConfig: NextConfig = {
|
|||
canvas: '@napi-rs/canvas',
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
|
||||
serverExternalPackages: [
|
||||
"@napi-rs/canvas",
|
||||
"ffmpeg-static",
|
||||
"better-sqlite3",
|
||||
"onnxruntime-node",
|
||||
"@huggingface/tokenizers",
|
||||
],
|
||||
outputFileTracingIncludes: {
|
||||
'/api/audiobook': [
|
||||
'./node_modules/ffmpeg-static/ffmpeg',
|
||||
|
|
@ -38,7 +43,7 @@ const nextConfig: NextConfig = {
|
|||
'/api/audiobook/chapter': [
|
||||
'./node_modules/ffmpeg-static/ffmpeg',
|
||||
],
|
||||
'/api/whisper': [
|
||||
'/api/tts/segments/ensure': [
|
||||
'./node_modules/ffmpeg-static/ffmpeg',
|
||||
],
|
||||
'/api/documents/blob/preview/ensure': [
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"@napi-rs/canvas": "^0.1.100",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ importers:
|
|||
'@headlessui/react':
|
||||
specifier: ^2.2.10
|
||||
version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@huggingface/tokenizers':
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
'@napi-rs/canvas':
|
||||
specifier: ^0.1.100
|
||||
version: 0.1.100
|
||||
|
|
@ -973,6 +976,9 @@ packages:
|
|||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
'@huggingface/tokenizers@0.1.3':
|
||||
resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -5210,6 +5216,8 @@ snapshots:
|
|||
react-dom: 19.2.6(react@19.2.6)
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
|
||||
'@huggingface/tokenizers@0.1.3': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
|
|
|
|||
|
|
@ -181,19 +181,20 @@ function spawnMainCommand(command, env) {
|
|||
const exitPromise = new Promise((resolve) => {
|
||||
child.on('error', (error) => {
|
||||
console.error('Failed to launch command:', error);
|
||||
resolve(1);
|
||||
resolve({ code: 1, signal: null, launchError: true });
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
console.error(`Main command exit event: code=${code ?? 'null'} signal=${signal ?? 'null'}.`);
|
||||
if (typeof code === 'number') {
|
||||
resolve(code);
|
||||
resolve({ code, signal: null, launchError: false });
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
resolve(1);
|
||||
resolve({ code: 1, signal, launchError: false });
|
||||
return;
|
||||
}
|
||||
resolve(0);
|
||||
resolve({ code: 0, signal: null, launchError: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -421,7 +422,11 @@ async function main() {
|
|||
|
||||
const { child, exitPromise } = spawnMainCommand(command, runtimeEnv);
|
||||
appProc = child;
|
||||
const exitCode = await exitPromise;
|
||||
const exitInfo = await exitPromise;
|
||||
const exitCode = typeof exitInfo?.code === 'number' ? exitInfo.code : 1;
|
||||
console.error(
|
||||
`Main command finished with code=${exitInfo?.code ?? 'null'} signal=${exitInfo?.signal ?? 'null'} launchError=${Boolean(exitInfo?.launchError)}.`,
|
||||
);
|
||||
|
||||
await shutdown('SIGTERM');
|
||||
exitOnce(exitCode);
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||
import { auth } from '@/lib/server/auth/auth';
|
||||
import { makeWhisperCacheKey, type WhisperRequestBody } from '@/lib/server/whisper/alignment';
|
||||
import { getCompute } from '@/lib/server/compute';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth?.api.getSession({ headers: req.headers });
|
||||
if (auth && !session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as WhisperRequestBody;
|
||||
const { text, audio, lang } = body;
|
||||
|
||||
if (!text || !audio || !Array.isArray(audio)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing text or audio in request body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = makeWhisperCacheKey(body);
|
||||
const audioBuffer = new Uint8Array(audio).buffer;
|
||||
|
||||
const alignments: TTSSentenceAlignment[] = (await getCompute().alignWords({
|
||||
audioBuffer,
|
||||
text,
|
||||
cacheKey,
|
||||
lang,
|
||||
})).alignments;
|
||||
|
||||
return NextResponse.json({ alignments }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Error in whisper route:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'WHISPER_ALIGNMENT_FAILED',
|
||||
message: 'Failed to compute word-level alignment',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ import type {
|
|||
AudiobookStatusResponse,
|
||||
CreateChapterPayload,
|
||||
VoicesResponse,
|
||||
AlignmentPayload,
|
||||
AlignmentResponse,
|
||||
TTSSegmentsEnsureRequest,
|
||||
TTSSegmentsEnsureResponse,
|
||||
} from '@/types/client';
|
||||
|
|
@ -208,23 +206,6 @@ export const getVoices = async (headers: HeadersInit): Promise<VoicesResponse> =
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
// --- Whisper API ---
|
||||
|
||||
|
||||
|
||||
export const alignAudio = async (payload: AlignmentPayload): Promise<AlignmentResponse | null> => {
|
||||
const response = await fetch('/api/whisper', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const ensureTtsSegments = async (
|
||||
payload: TTSSegmentsEnsureRequest,
|
||||
headers: TTSRequestHeaders,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types';
|
||||
import { LocalComputeBackend } from '@/lib/server/compute/local';
|
||||
import { NoneComputeBackend } from '@/lib/server/compute/none';
|
||||
import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode';
|
||||
|
||||
let backend: ComputeBackend | null = null;
|
||||
|
||||
function readMode(): ComputeMode {
|
||||
const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase();
|
||||
if (raw === 'local' || raw === 'none' || raw === 'worker') return raw;
|
||||
return 'local';
|
||||
}
|
||||
|
||||
function createBackend(): ComputeBackend {
|
||||
const mode = readMode();
|
||||
const mode: ComputeMode = readComputeMode();
|
||||
if (mode === 'none') return new NoneComputeBackend();
|
||||
if (mode === 'worker') {
|
||||
throw new Error(
|
||||
|
|
@ -27,11 +22,5 @@ export function getCompute(): ComputeBackend {
|
|||
}
|
||||
|
||||
export function isComputeAvailable(): boolean {
|
||||
const mode = readMode();
|
||||
if (mode === 'worker') {
|
||||
throw new Error(
|
||||
'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).',
|
||||
);
|
||||
}
|
||||
return mode !== 'none';
|
||||
return isComputeModeAvailable(readComputeMode());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types';
|
||||
import { alignAudioWithText } from '@/lib/server/whisper/alignment';
|
||||
import { parsePdf } from '@/lib/server/pdf-layout/parsePdf';
|
||||
|
||||
export class LocalComputeBackend implements ComputeBackend {
|
||||
readonly mode = 'local' as const;
|
||||
|
||||
async alignWords(input: WhisperAlignInput): Promise<WhisperAlignResult> {
|
||||
const { alignAudioWithText } = await import('@/lib/server/whisper/alignment');
|
||||
const alignments = await alignAudioWithText(
|
||||
input.audioBuffer,
|
||||
input.text,
|
||||
input.cacheKey,
|
||||
{ engine: 'whisper.cpp', lang: input.lang },
|
||||
{ lang: input.lang },
|
||||
);
|
||||
return { alignments };
|
||||
}
|
||||
|
||||
async parsePdfLayout(input: PdfLayoutInput) {
|
||||
const { parsePdf } = await import('@/lib/server/pdf-layout/parsePdf');
|
||||
return parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
src/lib/server/compute/mode.ts
Normal file
16
src/lib/server/compute/mode.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ComputeMode } from '@/lib/server/compute/types';
|
||||
|
||||
export function readComputeMode(): ComputeMode {
|
||||
const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase();
|
||||
if (raw === 'local' || raw === 'none' || raw === 'worker') return raw;
|
||||
return 'local';
|
||||
}
|
||||
|
||||
export function isComputeModeAvailable(mode: ComputeMode): boolean {
|
||||
if (mode === 'worker') {
|
||||
throw new Error(
|
||||
'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).',
|
||||
);
|
||||
}
|
||||
return mode !== 'none';
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type RuntimeConfigKey,
|
||||
type RuntimeConfigSource,
|
||||
} from '@/lib/server/admin/settings';
|
||||
import { isComputeAvailable } from '@/lib/server/compute';
|
||||
import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode';
|
||||
|
||||
export type ResolvedRuntimeConfig = RuntimeConfig & {
|
||||
computeAvailable: boolean;
|
||||
|
|
@ -29,7 +29,7 @@ export async function getResolvedRuntimeConfig(): Promise<ResolvedRuntimeConfig>
|
|||
const values = await getRuntimeConfig();
|
||||
return {
|
||||
...values,
|
||||
computeAvailable: isComputeAvailable(),
|
||||
computeAvailable: isComputeModeAvailable(readComputeMode()),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
46
src/lib/server/whisper/alignment-mapping.ts
Normal file
46
src/lib/server/whisper/alignment-mapping.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
|
||||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||
|
||||
export interface WhisperWord {
|
||||
start: number;
|
||||
end: number;
|
||||
word: string;
|
||||
}
|
||||
|
||||
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
|
||||
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||||
const lowerSentence = normalizedSentence.toLowerCase();
|
||||
let cursor = 0;
|
||||
|
||||
const alignedWords: TTSSentenceWord[] = words.map((w) => {
|
||||
const token = w.word.trim();
|
||||
if (!token) {
|
||||
return {
|
||||
text: '',
|
||||
startSec: w.start,
|
||||
endSec: w.end,
|
||||
charStart: cursor,
|
||||
charEnd: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
const idx = lowerSentence.indexOf(token.toLowerCase(), cursor);
|
||||
const start = idx >= 0 ? idx : cursor;
|
||||
const end = Math.min(normalizedSentence.length, start + token.length);
|
||||
cursor = Math.max(cursor, end);
|
||||
|
||||
return {
|
||||
text: token,
|
||||
startSec: w.start,
|
||||
endSec: w.end,
|
||||
charStart: start,
|
||||
charEnd: end,
|
||||
};
|
||||
}).filter((word) => word.text.length > 0);
|
||||
|
||||
return {
|
||||
sentence,
|
||||
sentenceIndex: 0,
|
||||
words: alignedWords,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
226
src/lib/server/whisper/ensureModel.ts
Normal file
226
src/lib/server/whisper/ensureModel.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
||||
import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount';
|
||||
import manifest from '@/lib/server/whisper/model/manifest.json';
|
||||
|
||||
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped');
|
||||
const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/whisper/model/LICENSE.txt');
|
||||
|
||||
export const WHISPER_CONFIG_PATH = path.join(MODEL_DIR, 'config.json');
|
||||
export const WHISPER_GENERATION_CONFIG_PATH = path.join(MODEL_DIR, 'generation_config.json');
|
||||
export const WHISPER_TOKENIZER_PATH = path.join(MODEL_DIR, 'tokenizer.json');
|
||||
export const WHISPER_TOKENIZER_CONFIG_PATH = path.join(MODEL_DIR, 'tokenizer_config.json');
|
||||
export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_int8.onnx');
|
||||
export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_int8.onnx');
|
||||
export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_int8.onnx');
|
||||
|
||||
const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main';
|
||||
|
||||
const DEFAULT_URLS: Record<string, string> = {
|
||||
'config.json': `${BASE_MODEL_URL}/config.json`,
|
||||
'generation_config.json': `${BASE_MODEL_URL}/generation_config.json`,
|
||||
'tokenizer.json': `${BASE_MODEL_URL}/tokenizer.json`,
|
||||
'tokenizer_config.json': `${BASE_MODEL_URL}/tokenizer_config.json`,
|
||||
'merges.txt': `${BASE_MODEL_URL}/merges.txt`,
|
||||
'vocab.json': `${BASE_MODEL_URL}/vocab.json`,
|
||||
'normalizer.json': `${BASE_MODEL_URL}/normalizer.json`,
|
||||
'added_tokens.json': `${BASE_MODEL_URL}/added_tokens.json`,
|
||||
'preprocessor_config.json': `${BASE_MODEL_URL}/preprocessor_config.json`,
|
||||
'special_tokens_map.json': `${BASE_MODEL_URL}/special_tokens_map.json`,
|
||||
'onnx/encoder_model_int8.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_int8.onnx`,
|
||||
'onnx/decoder_model_merged_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_int8.onnx`,
|
||||
'onnx/decoder_with_past_model_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_int8.onnx`,
|
||||
};
|
||||
|
||||
const ENV_URL_OVERRIDES: Record<string, string> = {
|
||||
'config.json': 'OPENREADER_WHISPER_MODEL_CONFIG_URL',
|
||||
'generation_config.json': 'OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL',
|
||||
'tokenizer.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_URL',
|
||||
'tokenizer_config.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL',
|
||||
'merges.txt': 'OPENREADER_WHISPER_MODEL_MERGES_URL',
|
||||
'vocab.json': 'OPENREADER_WHISPER_MODEL_VOCAB_URL',
|
||||
'normalizer.json': 'OPENREADER_WHISPER_MODEL_NORMALIZER_URL',
|
||||
'added_tokens.json': 'OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL',
|
||||
'preprocessor_config.json': 'OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL',
|
||||
'special_tokens_map.json': 'OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL',
|
||||
'onnx/encoder_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_ENCODER_URL',
|
||||
'onnx/decoder_model_merged_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL',
|
||||
'onnx/decoder_with_past_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL',
|
||||
};
|
||||
|
||||
type ManifestEntry = { path: string; sha256?: string; size?: number };
|
||||
|
||||
export interface WhisperArtifactSpec {
|
||||
path: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface WhisperStaticArtifactSpec {
|
||||
path: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export type WhisperFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const MANIFEST_FILES = manifest.files as ManifestEntry[];
|
||||
const MODEL_FILES = MANIFEST_FILES.filter((entry) => entry.path !== 'LICENSE.txt');
|
||||
const LICENSE_FILE = MANIFEST_FILES.find((entry) => entry.path === 'LICENSE.txt');
|
||||
|
||||
function normalizeExpected(entry: { sha256?: string; size?: number }): { sha256: string | null; size: number } {
|
||||
return {
|
||||
sha256: typeof entry.sha256 === 'string' ? entry.sha256.toLowerCase() : null,
|
||||
size: Number(entry.size ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePath(relativePath: string, modelDir: string): string {
|
||||
return path.join(modelDir, relativePath);
|
||||
}
|
||||
|
||||
function resolveUrl(relativePath: string): string {
|
||||
const envKey = ENV_URL_OVERRIDES[relativePath];
|
||||
const override = envKey ? process.env[envKey]?.trim() : '';
|
||||
if (override) return override;
|
||||
const fallback = DEFAULT_URLS[relativePath];
|
||||
if (!fallback) {
|
||||
throw new Error(`No default URL configured for Whisper model artifact: ${relativePath}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function sha256OfBytes(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function verifyBytes(bytes: Uint8Array, expected: { sha256?: string; size?: number }): boolean {
|
||||
const normalized = normalizeExpected(expected);
|
||||
if (Number.isFinite(normalized.size) && normalized.size > 0 && bytes.byteLength !== normalized.size) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized.sha256) return true;
|
||||
return sha256OfBytes(bytes) === normalized.sha256;
|
||||
}
|
||||
|
||||
async function verifyFile(filePath: string, expected: { sha256?: string; size?: number }): Promise<boolean> {
|
||||
const bytes = await readFile(filePath);
|
||||
return verifyBytes(bytes, expected);
|
||||
}
|
||||
|
||||
async function downloadToFile(fetchImpl: WhisperFetch, url: string, outPath: string): Promise<void> {
|
||||
const res = await fetchImpl(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
await writeFile(outPath, bytes);
|
||||
}
|
||||
|
||||
export async function ensureWhisperArtifacts(options: {
|
||||
modelDir: string;
|
||||
artifacts: WhisperArtifactSpec[];
|
||||
staticArtifacts?: WhisperStaticArtifactSpec[];
|
||||
fetchImpl?: WhisperFetch;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
modelDir,
|
||||
artifacts,
|
||||
staticArtifacts = [],
|
||||
fetchImpl = fetch,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
await Promise.all(artifacts.map(async (artifact) => {
|
||||
const target = resolvePath(artifact.path, modelDir);
|
||||
await access(target);
|
||||
const valid = await verifyFile(target, artifact);
|
||||
if (!valid) {
|
||||
throw new Error(`Checksum mismatch for existing Whisper artifact: ${artifact.path}`);
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all(staticArtifacts.map(async (artifact) => {
|
||||
const target = resolvePath(artifact.path, modelDir);
|
||||
await access(target);
|
||||
const valid = await verifyFile(target, artifact);
|
||||
if (!valid) {
|
||||
throw new Error(`Checksum mismatch for existing Whisper static artifact: ${artifact.path}`);
|
||||
}
|
||||
}));
|
||||
|
||||
return;
|
||||
} catch {
|
||||
// Continue to repair/download.
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const target = resolvePath(artifact.path, modelDir);
|
||||
const targetDir = path.dirname(target);
|
||||
const tmp = `${target}.tmp`;
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await downloadToFile(fetchImpl, artifact.url, tmp);
|
||||
if (!(await verifyFile(tmp, artifact))) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
throw new Error(`Whisper artifact checksum verification failed: ${artifact.path}`);
|
||||
}
|
||||
await rename(tmp, target);
|
||||
}
|
||||
|
||||
for (const artifact of staticArtifacts) {
|
||||
const target = resolvePath(artifact.path, modelDir);
|
||||
const targetDir = path.dirname(target);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await copyFile(artifact.sourcePath, target);
|
||||
if (!(await verifyFile(target, artifact))) {
|
||||
throw new Error(`Whisper static artifact checksum verification failed: ${artifact.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSingleflightRunner<T>(work: () => Promise<T>): () => Promise<T> {
|
||||
let inflight: Promise<T> | null = null;
|
||||
return async () => {
|
||||
if (inflight) return inflight;
|
||||
inflight = work().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureModelInternal(): Promise<string> {
|
||||
const artifacts: WhisperArtifactSpec[] = MODEL_FILES.map((entry) => ({
|
||||
path: entry.path,
|
||||
sha256: entry.sha256,
|
||||
size: entry.size,
|
||||
url: resolveUrl(entry.path),
|
||||
}));
|
||||
|
||||
const staticArtifacts: WhisperStaticArtifactSpec[] = LICENSE_FILE
|
||||
? [{
|
||||
path: LICENSE_FILE.path,
|
||||
sha256: LICENSE_FILE.sha256,
|
||||
size: LICENSE_FILE.size,
|
||||
sourcePath: STATIC_LICENSE_PATH,
|
||||
}]
|
||||
: [];
|
||||
|
||||
await ensureWhisperArtifacts({
|
||||
modelDir: MODEL_DIR,
|
||||
artifacts,
|
||||
staticArtifacts,
|
||||
});
|
||||
|
||||
return WHISPER_ENCODER_MODEL_PATH;
|
||||
}
|
||||
|
||||
const ensureWhisperModelSingleflight = createSingleflightRunner(ensureModelInternal);
|
||||
|
||||
export async function ensureWhisperModel(): Promise<string> {
|
||||
return ensureWhisperModelSingleflight();
|
||||
}
|
||||
21
src/lib/server/whisper/model/LICENSE.txt
Normal file
21
src/lib/server/whisper/model/LICENSE.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 OpenAI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
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
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
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.
|
||||
76
src/lib/server/whisper/model/manifest.json
Normal file
76
src/lib/server/whisper/model/manifest.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "whisper-base_timestamped-int8",
|
||||
"version": "onnx-community/whisper-base_timestamped@608c49e61301901684bc36cac8f74b95ff6b5a8e",
|
||||
"files": [
|
||||
{
|
||||
"path": "config.json",
|
||||
"sha256": "f4d0608f7d918166da7edb3e188de5ef1bfe70d9802e785d271fd88111e9cf4b",
|
||||
"size": 2243
|
||||
},
|
||||
{
|
||||
"path": "generation_config.json",
|
||||
"sha256": "61070cf8de25b1e9256e8e102ded49d8d24a8369ed36ef84fdf21549e68125a0",
|
||||
"size": 3832
|
||||
},
|
||||
{
|
||||
"path": "tokenizer.json",
|
||||
"sha256": "27fc476bfe7f17299480be2273fc0608e4d5a99aba2ab5dec5374b4482d1a566",
|
||||
"size": 2480466
|
||||
},
|
||||
{
|
||||
"path": "tokenizer_config.json",
|
||||
"sha256": "2e036e4dbacfdeb7242c7d4ec4149f4a16e86026048f94d1637e3a8ee9c6a573",
|
||||
"size": 282682
|
||||
},
|
||||
{
|
||||
"path": "merges.txt",
|
||||
"sha256": "2df2990a395e35e8dfbc7511e08c12d56018d8d04691e0133e5d63b21e154dc6",
|
||||
"size": 493869
|
||||
},
|
||||
{
|
||||
"path": "vocab.json",
|
||||
"sha256": "50d6a919f0a0601d56a04eb583c780d18553aa388254ba3158eb6a00f13e2c1a",
|
||||
"size": 1036584
|
||||
},
|
||||
{
|
||||
"path": "normalizer.json",
|
||||
"sha256": "bf1c507dc8724ca9cf9903640dacfb69dae2f00edee4f21ceba106a7392f26dd",
|
||||
"size": 52666
|
||||
},
|
||||
{
|
||||
"path": "added_tokens.json",
|
||||
"sha256": "9715fd2243b6f06a5858b5e32950d2853f73dd5bc201aafcf76f5082a2d8acd1",
|
||||
"size": 34604
|
||||
},
|
||||
{
|
||||
"path": "preprocessor_config.json",
|
||||
"sha256": "a6a76d28c93edb273669eb9e0b0636a2bddbb1272c3261e47b7ca6dfdbac1b8d",
|
||||
"size": 339
|
||||
},
|
||||
{
|
||||
"path": "special_tokens_map.json",
|
||||
"sha256": "e67ae3a0aaa99abcd9f187138e12db1f65c16a14761c50ef10eef2c174a7a691",
|
||||
"size": 2194
|
||||
},
|
||||
{
|
||||
"path": "onnx/encoder_model_int8.onnx",
|
||||
"sha256": "152da96dd8ff3f28f3fadabc2e8960405a277846453ff94ed411fe935a72917f",
|
||||
"size": 23159150
|
||||
},
|
||||
{
|
||||
"path": "onnx/decoder_model_merged_int8.onnx",
|
||||
"sha256": "cf9a8d5bcddc0917a0078135b484cedcaf44f28909cd91910abd29dced9171db",
|
||||
"size": 53712708
|
||||
},
|
||||
{
|
||||
"path": "onnx/decoder_with_past_model_int8.onnx",
|
||||
"sha256": "bdd92860d0ed7dff2aca623963378cbba1b617bfae127356db1c8aa8baa930ef",
|
||||
"size": 50131672
|
||||
},
|
||||
{
|
||||
"path": "LICENSE.txt",
|
||||
"sha256": "b5d65a59060e68c4ff940e1eddfa6f94b2d68fdf58ed7f4dd57721c997e35e9d",
|
||||
"size": 1063
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
src/lib/server/whisper/model/mel_filters.npz
Normal file
BIN
src/lib/server/whisper/model/mel_filters.npz
Normal file
Binary file not shown.
21
src/lib/server/whisper/spectral.ts
Normal file
21
src/lib/server/whisper/spectral.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
|
||||
const coeffs = new Float64Array(freqBins);
|
||||
for (let k = 0; k < freqBins; k += 1) {
|
||||
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
|
||||
}
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
export function goertzelPower(samples: Float32Array, coeff: number): number {
|
||||
let s1 = 0;
|
||||
let s2 = 0;
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const s0 = samples[i] + (coeff * s1) - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
|
||||
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
|
||||
if (!Number.isFinite(power) || power < 0) return 0;
|
||||
return power;
|
||||
}
|
||||
449
src/lib/server/whisper/token-timestamps.ts
Normal file
449
src/lib/server/whisper/token-timestamps.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import type { Tokenizer } from '@huggingface/tokenizers';
|
||||
import type * as ort from 'onnxruntime-node';
|
||||
|
||||
const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E';
|
||||
const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu');
|
||||
|
||||
type TokenTimestamp = [start: number, end: number];
|
||||
|
||||
export interface WhisperWordTiming {
|
||||
word: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
}
|
||||
|
||||
function medianFilter(data: Float32Array, windowSize: number): Float32Array {
|
||||
if (windowSize % 2 === 0 || windowSize <= 0) {
|
||||
throw new Error('Window size must be a positive odd number');
|
||||
}
|
||||
|
||||
const output = new Float32Array(data.length);
|
||||
const buffer = new Float32Array(windowSize);
|
||||
const halfWindow = Math.floor(windowSize / 2);
|
||||
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
let valuesIndex = 0;
|
||||
for (let j = -halfWindow; j <= halfWindow; j += 1) {
|
||||
let index = i + j;
|
||||
if (index < 0) {
|
||||
index = Math.abs(index);
|
||||
} else if (index >= data.length) {
|
||||
index = (2 * (data.length - 1)) - index;
|
||||
}
|
||||
buffer[valuesIndex] = data[index];
|
||||
valuesIndex += 1;
|
||||
}
|
||||
|
||||
const sortable = Array.from(buffer);
|
||||
sortable.sort((a, b) => a - b);
|
||||
output[i] = sortable[halfWindow] ?? 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function dynamicTimeWarping(matrix: Float32Array[], rows: number, cols: number): [number[], number[]] {
|
||||
const cost: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(Number.POSITIVE_INFINITY));
|
||||
const trace: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(-1));
|
||||
cost[0][0] = 0;
|
||||
|
||||
for (let j = 1; j <= cols; j += 1) {
|
||||
for (let i = 1; i <= rows; i += 1) {
|
||||
const c0 = cost[i - 1][j - 1];
|
||||
const c1 = cost[i - 1][j];
|
||||
const c2 = cost[i][j - 1];
|
||||
let c: number;
|
||||
let t: number;
|
||||
if (c0 < c1 && c0 < c2) {
|
||||
c = c0;
|
||||
t = 0;
|
||||
} else if (c1 < c0 && c1 < c2) {
|
||||
c = c1;
|
||||
t = 1;
|
||||
} else {
|
||||
c = c2;
|
||||
t = 2;
|
||||
}
|
||||
cost[i][j] = matrix[i - 1][j - 1] + c;
|
||||
trace[i][j] = t;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i <= cols; i += 1) trace[0][i] = 2;
|
||||
for (let i = 0; i <= rows; i += 1) trace[i][0] = 1;
|
||||
|
||||
let i = rows;
|
||||
let j = cols;
|
||||
const textIndices: number[] = [];
|
||||
const timeIndices: number[] = [];
|
||||
while (i > 0 || j > 0) {
|
||||
textIndices.push(i - 1);
|
||||
timeIndices.push(j - 1);
|
||||
const step = trace[i][j];
|
||||
if (step === 0) {
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if (step === 1) {
|
||||
i -= 1;
|
||||
} else if (step === 2) {
|
||||
j -= 1;
|
||||
} else {
|
||||
throw new Error(`Unexpected DTW trace state at [${i}, ${j}]`);
|
||||
}
|
||||
}
|
||||
|
||||
textIndices.reverse();
|
||||
timeIndices.reverse();
|
||||
return [textIndices, timeIndices];
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function decodeTokens(tokenizer: Pick<Tokenizer, 'decode'>, tokens: number[]): string {
|
||||
return tokenizer.decode(tokens, { skip_special_tokens: false });
|
||||
}
|
||||
|
||||
function splitTokensOnUnicode(
|
||||
tokenizer: Pick<Tokenizer, 'decode'>,
|
||||
tokens: number[],
|
||||
): [string[], number[][], number[][]] {
|
||||
const decodedFull = decodeTokens(tokenizer, tokens);
|
||||
const replacementChar = '\uFFFD';
|
||||
const words: string[] = [];
|
||||
const wordTokens: number[][] = [];
|
||||
const tokenIndices: number[][] = [];
|
||||
let currentTokens: number[] = [];
|
||||
let currentIndices: number[] = [];
|
||||
let unicodeOffset = 0;
|
||||
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
currentTokens.push(tokens[i]);
|
||||
currentIndices.push(i);
|
||||
|
||||
const decoded = decodeTokens(tokenizer, currentTokens);
|
||||
if (
|
||||
!decoded.includes(replacementChar)
|
||||
|| decodedFull[unicodeOffset + decoded.indexOf(replacementChar)] === replacementChar
|
||||
) {
|
||||
words.push(decoded);
|
||||
wordTokens.push(currentTokens);
|
||||
tokenIndices.push(currentIndices);
|
||||
currentTokens = [];
|
||||
currentIndices = [];
|
||||
unicodeOffset += decoded.length;
|
||||
}
|
||||
}
|
||||
|
||||
return [words, wordTokens, tokenIndices];
|
||||
}
|
||||
|
||||
function splitTokensOnSpaces(
|
||||
tokenizer: Pick<Tokenizer, 'decode'>,
|
||||
tokens: number[],
|
||||
eosTokenId: number,
|
||||
): [string[], number[][], number[][]] {
|
||||
const [subwords, subwordTokens, subwordIndices] = splitTokensOnUnicode(tokenizer, tokens);
|
||||
const words: string[] = [];
|
||||
const wordTokens: number[][] = [];
|
||||
const tokenIndices: number[][] = [];
|
||||
|
||||
for (let i = 0; i < subwords.length; i += 1) {
|
||||
const subword = subwords[i];
|
||||
const tokenList = subwordTokens[i];
|
||||
const indices = subwordIndices[i];
|
||||
const special = tokenList[0] >= eosTokenId;
|
||||
const withSpace = subword.startsWith(' ');
|
||||
const trimmed = subword.trim();
|
||||
const punctuation = PUNCTUATION_ONLY_REGEX.test(trimmed);
|
||||
|
||||
if (special || withSpace || punctuation || words.length === 0) {
|
||||
words.push(subword);
|
||||
wordTokens.push([...tokenList]);
|
||||
tokenIndices.push([...indices]);
|
||||
} else {
|
||||
const ix = words.length - 1;
|
||||
words[ix] += subword;
|
||||
wordTokens[ix].push(...tokenList);
|
||||
tokenIndices[ix].push(...indices);
|
||||
}
|
||||
}
|
||||
|
||||
return [words, wordTokens, tokenIndices];
|
||||
}
|
||||
|
||||
function mergePunctuations(
|
||||
words: string[],
|
||||
tokens: number[][],
|
||||
indices: number[][],
|
||||
prependPunctuations = '"\'“¡¿([{-',
|
||||
appendPunctuations = '"\'.。,,!!??::”)]}、',
|
||||
): [string[], number[][], number[][]] {
|
||||
const newWords = words.map((w) => `${w}`);
|
||||
const newTokens = tokens.map((t) => [...t]);
|
||||
const newIndices = indices.map((idx) => [...idx]);
|
||||
|
||||
let i = newWords.length - 2;
|
||||
let j = newWords.length - 1;
|
||||
while (i >= 0) {
|
||||
if (newWords[i].startsWith(' ') && prependPunctuations.includes(newWords[i].trim())) {
|
||||
newWords[j] = newWords[i] + newWords[j];
|
||||
newTokens[j] = [...newTokens[i], ...newTokens[j]];
|
||||
newIndices[j] = [...newIndices[i], ...newIndices[j]];
|
||||
newWords[i] = '';
|
||||
newTokens[i] = [];
|
||||
newIndices[i] = [];
|
||||
} else {
|
||||
j = i;
|
||||
}
|
||||
i -= 1;
|
||||
}
|
||||
|
||||
i = 0;
|
||||
j = 1;
|
||||
while (j < newWords.length) {
|
||||
if (!newWords[i].endsWith(' ') && appendPunctuations.includes(newWords[j])) {
|
||||
newWords[i] += newWords[j];
|
||||
newTokens[i] = [...newTokens[i], ...newTokens[j]];
|
||||
newIndices[i] = [...newIndices[i], ...newIndices[j]];
|
||||
newWords[j] = '';
|
||||
newTokens[j] = [];
|
||||
newIndices[j] = [];
|
||||
} else {
|
||||
i = j;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return [
|
||||
newWords.filter((w) => w.length > 0),
|
||||
newTokens.filter((t) => t.length > 0),
|
||||
newIndices.filter((t) => t.length > 0),
|
||||
];
|
||||
}
|
||||
|
||||
function combineTokensIntoWords(
|
||||
tokenizer: Pick<Tokenizer, 'decode'>,
|
||||
tokens: number[],
|
||||
eosTokenId: number,
|
||||
language = 'english',
|
||||
): [string[], number[][], number[][]] {
|
||||
let words: string[];
|
||||
let wordTokens: number[][];
|
||||
let tokenIndices: number[][];
|
||||
|
||||
if (['chinese', 'japanese', 'thai', 'lao', 'myanmar', 'zh', 'ja', 'th', 'lo', 'my'].includes(language)) {
|
||||
[words, wordTokens, tokenIndices] = splitTokensOnUnicode(tokenizer, tokens);
|
||||
} else {
|
||||
[words, wordTokens, tokenIndices] = splitTokensOnSpaces(tokenizer, tokens, eosTokenId);
|
||||
}
|
||||
|
||||
return mergePunctuations(words, wordTokens, tokenIndices);
|
||||
}
|
||||
|
||||
export function extractTokenStartTimestamps(input: {
|
||||
crossAttentions: Record<string, ort.Tensor>;
|
||||
decoderLayers: number;
|
||||
alignmentHeads: Array<[number, number]>;
|
||||
numFrames: number;
|
||||
numInputIds: number;
|
||||
timePrecision?: number;
|
||||
sequenceLength: number;
|
||||
}): number[] {
|
||||
const {
|
||||
crossAttentions,
|
||||
decoderLayers,
|
||||
alignmentHeads,
|
||||
numFrames,
|
||||
numInputIds,
|
||||
timePrecision = 0.02,
|
||||
sequenceLength,
|
||||
} = input;
|
||||
|
||||
const frameCount = Math.max(1, numFrames);
|
||||
const perLayer: Float32Array[] = [];
|
||||
for (let layer = 0; layer < decoderLayers; layer += 1) {
|
||||
const key = `cross_attentions.${layer}`;
|
||||
const tensor = crossAttentions[key];
|
||||
if (!tensor) continue;
|
||||
perLayer[layer] = tensor.data as Float32Array;
|
||||
}
|
||||
|
||||
const selected: Float32Array[] = [];
|
||||
let seqLen = 0;
|
||||
let attnFrames = 0;
|
||||
for (const [layer, head] of alignmentHeads) {
|
||||
const flat = perLayer[layer];
|
||||
if (!flat) continue;
|
||||
const layerTensor = crossAttentions[`cross_attentions.${layer}`];
|
||||
if (!layerTensor || layerTensor.dims.length < 4) continue;
|
||||
const [, numHeads, currentSeqLen, currentFrames] = layerTensor.dims;
|
||||
if (head >= numHeads) continue;
|
||||
seqLen = currentSeqLen;
|
||||
attnFrames = Math.min(currentFrames, frameCount);
|
||||
const headSlice = new Float32Array(seqLen * attnFrames);
|
||||
for (let s = 0; s < seqLen; s += 1) {
|
||||
for (let f = 0; f < attnFrames; f += 1) {
|
||||
const flatIndex = (((head * currentSeqLen) + s) * currentFrames) + f;
|
||||
headSlice[(s * attnFrames) + f] = flat[flatIndex] ?? 0;
|
||||
}
|
||||
}
|
||||
selected.push(headSlice);
|
||||
}
|
||||
|
||||
if (!selected.length || seqLen === 0 || attnFrames === 0) {
|
||||
return new Array(sequenceLength).fill(0);
|
||||
}
|
||||
|
||||
const normalizedHeads = selected.map((headData) => {
|
||||
const means = new Float32Array(attnFrames);
|
||||
const stds = new Float32Array(attnFrames);
|
||||
|
||||
for (let f = 0; f < attnFrames; f += 1) {
|
||||
let sum = 0;
|
||||
for (let s = 0; s < seqLen; s += 1) sum += headData[(s * attnFrames) + f];
|
||||
const mean = sum / seqLen;
|
||||
means[f] = mean;
|
||||
let varSum = 0;
|
||||
for (let s = 0; s < seqLen; s += 1) {
|
||||
const d = headData[(s * attnFrames) + f] - mean;
|
||||
varSum += d * d;
|
||||
}
|
||||
stds[f] = Math.sqrt(varSum / seqLen) || 1;
|
||||
}
|
||||
|
||||
const out = new Float32Array(headData.length);
|
||||
for (let s = 0; s < seqLen; s += 1) {
|
||||
const row = new Float32Array(attnFrames);
|
||||
for (let f = 0; f < attnFrames; f += 1) {
|
||||
row[f] = (headData[(s * attnFrames) + f] - means[f]) / stds[f];
|
||||
}
|
||||
const filtered = medianFilter(row, 7);
|
||||
out.set(filtered, s * attnFrames);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const croppedRows = Math.max(0, seqLen - numInputIds);
|
||||
if (croppedRows === 0) return new Array(sequenceLength).fill(0);
|
||||
|
||||
const matrix: Float32Array[] = Array.from({ length: croppedRows }, () => new Float32Array(attnFrames));
|
||||
for (const headData of normalizedHeads) {
|
||||
for (let r = 0; r < croppedRows; r += 1) {
|
||||
const srcRow = r + numInputIds;
|
||||
for (let f = 0; f < attnFrames; f += 1) {
|
||||
matrix[r][f] += headData[(srcRow * attnFrames) + f];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scale = 1 / normalizedHeads.length;
|
||||
for (let r = 0; r < croppedRows; r += 1) {
|
||||
for (let f = 0; f < attnFrames; f += 1) {
|
||||
matrix[r][f] = -matrix[r][f] * scale;
|
||||
}
|
||||
}
|
||||
|
||||
const [textIndices, timeIndices] = dynamicTimeWarping(matrix, croppedRows, attnFrames);
|
||||
const jumps = new Array(textIndices.length).fill(false);
|
||||
for (let i = 0; i < textIndices.length; i += 1) {
|
||||
jumps[i] = i === 0 ? true : textIndices[i] !== textIndices[i - 1];
|
||||
}
|
||||
|
||||
const jumpTimes: number[] = [];
|
||||
for (let i = 0; i < jumps.length; i += 1) {
|
||||
if (jumps[i]) jumpTimes.push(timeIndices[i] * timePrecision);
|
||||
}
|
||||
|
||||
const timestamps = new Array(sequenceLength).fill(0);
|
||||
for (let i = 0; i < numInputIds && i < timestamps.length; i += 1) timestamps[i] = 0;
|
||||
for (let i = 0; i < jumpTimes.length && (numInputIds + i) < timestamps.length; i += 1) {
|
||||
timestamps[numInputIds + i] = jumpTimes[i];
|
||||
}
|
||||
if (timestamps.length > 0 && jumpTimes.length > 0) {
|
||||
timestamps[timestamps.length - 1] = jumpTimes[jumpTimes.length - 1];
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
export function buildWordsFromTimestampedTokens(input: {
|
||||
tokens: number[];
|
||||
tokenStartTimestamps: number[];
|
||||
tokenizer: Pick<Tokenizer, 'decode'>;
|
||||
eosTokenId: number;
|
||||
promptLength: number;
|
||||
timestampBeginTokenId: number;
|
||||
timePrecision?: number;
|
||||
language?: string;
|
||||
}): WhisperWordTiming[] {
|
||||
const {
|
||||
tokens,
|
||||
tokenStartTimestamps,
|
||||
tokenizer,
|
||||
eosTokenId,
|
||||
promptLength,
|
||||
timestampBeginTokenId,
|
||||
timePrecision = 0.02,
|
||||
language = 'english',
|
||||
} = input;
|
||||
|
||||
const limit = Math.min(tokens.length, tokenStartTimestamps.length);
|
||||
const tokenRanges: TokenTimestamp[] = [];
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const start = tokenStartTimestamps[i] ?? 0;
|
||||
const end = i + 1 < limit ? (tokenStartTimestamps[i + 1] ?? (start + timePrecision)) : (start + timePrecision);
|
||||
tokenRanges.push([start, Math.max(start, end)]);
|
||||
}
|
||||
|
||||
const words: WhisperWordTiming[] = [];
|
||||
let segmentStart: number | null = null;
|
||||
let textTokens: number[] = [];
|
||||
let textRanges: TokenTimestamp[] = [];
|
||||
|
||||
const flushSegment = (segmentEnd: number | null) => {
|
||||
if (!textTokens.length) return;
|
||||
const [wordTexts, , tokenIndices] = combineTokensIntoWords(tokenizer, textTokens, eosTokenId, language);
|
||||
for (let i = 0; i < wordTexts.length; i += 1) {
|
||||
const indices = tokenIndices[i];
|
||||
if (!indices.length) continue;
|
||||
const start = textRanges[indices[0]]?.[0] ?? segmentStart ?? 0;
|
||||
const end = textRanges[indices[indices.length - 1]]?.[1] ?? segmentEnd ?? start;
|
||||
const clampedStart = segmentStart == null ? start : Math.max(segmentStart, start);
|
||||
const clampedEndBase = segmentEnd == null ? end : Math.min(segmentEnd, end);
|
||||
const clampedEnd = Math.max(
|
||||
clampedStart + (clampedEndBase <= clampedStart ? timePrecision : 0),
|
||||
clampedEndBase,
|
||||
);
|
||||
words.push({
|
||||
word: wordTexts[i].trim(),
|
||||
startSec: round2(clampedStart),
|
||||
endSec: round2(clampedEnd),
|
||||
});
|
||||
}
|
||||
textTokens = [];
|
||||
textRanges = [];
|
||||
};
|
||||
|
||||
for (let i = promptLength; i < limit; i += 1) {
|
||||
const token = tokens[i];
|
||||
if (token === eosTokenId) break;
|
||||
|
||||
if (token >= timestampBeginTokenId) {
|
||||
const ts = (token - timestampBeginTokenId) * timePrecision;
|
||||
if (segmentStart == null) {
|
||||
segmentStart = ts;
|
||||
} else {
|
||||
flushSegment(ts);
|
||||
segmentStart = ts;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
textTokens.push(token);
|
||||
textRanges.push(tokenRanges[i]);
|
||||
}
|
||||
|
||||
flushSegment(null);
|
||||
return words.filter((w) => w.word.length > 0);
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type {
|
||||
TTSAudiobookChapter,
|
||||
TTSSentenceAlignment,
|
||||
TTSAudioBytes,
|
||||
TTSAudiobookFormat,
|
||||
TTSSentenceAlignment,
|
||||
} from '@/types/tts';
|
||||
import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
|
|
@ -64,17 +63,6 @@ export interface VoicesResponse {
|
|||
voices: string[];
|
||||
}
|
||||
|
||||
// --- Whisper API Types ---
|
||||
|
||||
export interface AlignmentPayload {
|
||||
text: string;
|
||||
audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer))
|
||||
}
|
||||
|
||||
export interface AlignmentResponse {
|
||||
alignments: TTSSentenceAlignment[];
|
||||
}
|
||||
|
||||
export interface TTSSegmentSettings {
|
||||
providerRef: string;
|
||||
providerType: TtsProviderType;
|
||||
|
|
|
|||
21
tests/unit/whisper-alignment-mapping.spec.ts
Normal file
21
tests/unit/whisper-alignment-mapping.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
mapWordsToSentenceOffsets,
|
||||
} from '../../src/lib/server/whisper/alignment-mapping';
|
||||
|
||||
test.describe('whisper alignment mapping', () => {
|
||||
test('maps words to sentence offsets with punctuation and repeated spaces', () => {
|
||||
const aligned = mapWordsToSentenceOffsets('Hello, world again.', [
|
||||
{ word: 'Hello', start: 0, end: 0.25 },
|
||||
{ word: 'world', start: 0.25, end: 0.5 },
|
||||
{ word: 'again', start: 0.5, end: 1.0 },
|
||||
]);
|
||||
|
||||
expect(aligned.words).toHaveLength(3);
|
||||
expect(aligned.words[0].charStart).toBe(0);
|
||||
expect(aligned.words[0].charEnd).toBe(5);
|
||||
expect(aligned.words[1].charStart).toBeGreaterThan(aligned.words[0].charEnd);
|
||||
expect(aligned.words[2].charStart).toBeGreaterThan(aligned.words[1].charEnd);
|
||||
expect(aligned.words[2].charEnd).toBeLessThanOrEqual('Hello, world again.'.length);
|
||||
});
|
||||
});
|
||||
37
tests/unit/whisper-alignment-smoke.spec.ts
Normal file
37
tests/unit/whisper-alignment-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { alignAudioWithText } from '../../src/lib/server/whisper/alignment';
|
||||
|
||||
test.describe('whisper alignment smoke', () => {
|
||||
test('runs ONNX alignment end-to-end without decoder reshape errors', async () => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
const audioPath = path.join(process.cwd(), 'tests/files/sample.mp3');
|
||||
const audioBytes = await readFile(audioPath);
|
||||
const buffer = audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength);
|
||||
|
||||
const alignments = await alignAudioWithText(
|
||||
buffer,
|
||||
'This is a sample sentence used to validate whisper alignment execution.',
|
||||
undefined,
|
||||
{ lang: 'en' },
|
||||
);
|
||||
|
||||
expect(alignments.length).toBe(1);
|
||||
expect(Array.isArray(alignments[0].words)).toBe(true);
|
||||
expect(alignments[0].words.length).toBeGreaterThan(0);
|
||||
|
||||
let maxEnd = 0;
|
||||
let positiveDurationWordCount = 0;
|
||||
for (const word of alignments[0].words) {
|
||||
expect(Number.isFinite(word.startSec)).toBe(true);
|
||||
expect(Number.isFinite(word.endSec)).toBe(true);
|
||||
expect(word.endSec).toBeGreaterThanOrEqual(word.startSec);
|
||||
maxEnd = Math.max(maxEnd, word.endSec);
|
||||
if (word.endSec > word.startSec) positiveDurationWordCount += 1;
|
||||
}
|
||||
expect(maxEnd).toBeLessThanOrEqual(10.2);
|
||||
expect(positiveDurationWordCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
71
tests/unit/whisper-ensure-model.spec.ts
Normal file
71
tests/unit/whisper-ensure-model.spec.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
createSingleflightRunner,
|
||||
ensureWhisperArtifacts,
|
||||
} from '../../src/lib/server/whisper/ensureModel';
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
test.describe('whisper ensure model helpers', () => {
|
||||
test('downloads and verifies artifacts, and repairs checksum mismatch', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'openreader-whisper-model-test-'));
|
||||
const artifactBytes = new TextEncoder().encode('artifact-content-v1');
|
||||
const artifactHash = sha256(artifactBytes);
|
||||
const artifactPath = 'onnx/encoder_model_int8.onnx';
|
||||
const target = path.join(root, artifactPath);
|
||||
|
||||
try {
|
||||
// Seed a corrupted file to verify repair behavior.
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, new Uint8Array([0, 1, 2, 3]));
|
||||
|
||||
let fetchCount = 0;
|
||||
await ensureWhisperArtifacts({
|
||||
modelDir: root,
|
||||
artifacts: [
|
||||
{
|
||||
path: artifactPath,
|
||||
sha256: artifactHash,
|
||||
size: artifactBytes.byteLength,
|
||||
url: 'https://example.local/fake-artifact',
|
||||
},
|
||||
],
|
||||
fetchImpl: async () => {
|
||||
fetchCount += 1;
|
||||
return new Response(artifactBytes, { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
const repaired = await readFile(target);
|
||||
expect(repaired.byteLength).toBe(artifactBytes.byteLength);
|
||||
expect(sha256(repaired)).toBe(artifactHash);
|
||||
expect(fetchCount).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('singleflight runner deduplicates concurrent work', async () => {
|
||||
let runs = 0;
|
||||
const run = createSingleflightRunner(async () => {
|
||||
runs += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
const [a, b, c] = await Promise.all([run(), run(), run()]);
|
||||
expect(a).toBe('ok');
|
||||
expect(b).toBe('ok');
|
||||
expect(c).toBe('ok');
|
||||
expect(runs).toBe(1);
|
||||
|
||||
await run();
|
||||
expect(runs).toBe(2);
|
||||
});
|
||||
});
|
||||
34
tests/unit/whisper-spectral.spec.ts
Normal file
34
tests/unit/whisper-spectral.spec.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { buildGoertzelCoefficients, goertzelPower } from '../../src/lib/server/whisper/spectral';
|
||||
|
||||
function dftPower(samples: Float32Array, k: number): number {
|
||||
const n = samples.length;
|
||||
let re = 0;
|
||||
let im = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const angle = (-2 * Math.PI * k * i) / n;
|
||||
re += samples[i] * Math.cos(angle);
|
||||
im += samples[i] * Math.sin(angle);
|
||||
}
|
||||
return (re * re) + (im * im);
|
||||
}
|
||||
|
||||
test.describe('whisper spectral helpers', () => {
|
||||
test('goertzel power matches direct DFT for non-power-of-two frame size', () => {
|
||||
const frameSize = 400;
|
||||
const bins = 201;
|
||||
const coeffs = buildGoertzelCoefficients(bins, frameSize);
|
||||
const samples = new Float32Array(frameSize);
|
||||
for (let i = 0; i < frameSize; i += 1) {
|
||||
samples[i] = Math.sin((2 * Math.PI * 37 * i) / frameSize) + (0.2 * Math.cos((2 * Math.PI * 91 * i) / frameSize));
|
||||
}
|
||||
|
||||
const testBins = [0, 7, 37, 91, 150, 200];
|
||||
for (const k of testBins) {
|
||||
const expected = dftPower(samples, k);
|
||||
const actual = goertzelPower(samples, coeffs[k]);
|
||||
const rel = Math.abs(actual - expected) / Math.max(1, Math.abs(expected));
|
||||
expect(rel).toBeLessThan(1e-5);
|
||||
}
|
||||
});
|
||||
});
|
||||
85
tests/unit/whisper-token-timestamps.spec.ts
Normal file
85
tests/unit/whisper-token-timestamps.spec.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import * as ort from 'onnxruntime-node';
|
||||
import {
|
||||
buildWordsFromTimestampedTokens,
|
||||
extractTokenStartTimestamps,
|
||||
} from '../../src/lib/server/whisper/token-timestamps';
|
||||
|
||||
test.describe('whisper token timestamp alignment', () => {
|
||||
test('extracts monotonic token timestamps from cross-attention maps', () => {
|
||||
const seqLen = 6;
|
||||
const frames = 10;
|
||||
const heads = 8;
|
||||
const data = new Float32Array(1 * heads * seqLen * frames);
|
||||
for (let s = 0; s < seqLen; s += 1) {
|
||||
const peak = Math.min(frames - 1, s + 1);
|
||||
for (let f = 0; f < frames; f += 1) {
|
||||
const val = -Math.abs(f - peak);
|
||||
const idx = (((0 * seqLen) + s) * frames) + f;
|
||||
data[idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const cross = {
|
||||
'cross_attentions.0': new ort.Tensor('float32', data, [1, heads, seqLen, frames]),
|
||||
};
|
||||
|
||||
const ts = extractTokenStartTimestamps({
|
||||
crossAttentions: cross,
|
||||
decoderLayers: 6,
|
||||
alignmentHeads: [[0, 0]],
|
||||
numFrames: frames,
|
||||
numInputIds: 3,
|
||||
sequenceLength: seqLen,
|
||||
timePrecision: 0.02,
|
||||
});
|
||||
|
||||
expect(ts).toHaveLength(seqLen);
|
||||
expect(ts[0]).toBe(0);
|
||||
expect(ts[1]).toBe(0);
|
||||
expect(ts[2]).toBe(0);
|
||||
expect(ts[3]).toBeGreaterThanOrEqual(0);
|
||||
expect(ts[4]).toBeGreaterThanOrEqual(ts[3]);
|
||||
expect(ts[5]).toBeGreaterThanOrEqual(ts[4]);
|
||||
});
|
||||
|
||||
test('builds word timings from token timestamps with punctuation merge', () => {
|
||||
const tokenText: Record<number, string> = {
|
||||
100: ' hello',
|
||||
101: ' world',
|
||||
102: '!',
|
||||
};
|
||||
const tokenizer = {
|
||||
decode(tokens: number[]) {
|
||||
return tokens.map((t) => tokenText[t] ?? '').join('');
|
||||
},
|
||||
};
|
||||
|
||||
const timestampBeginTokenId = 50364;
|
||||
const tokens = [
|
||||
1, 2, 3,
|
||||
timestampBeginTokenId,
|
||||
100, 101, 102,
|
||||
timestampBeginTokenId + 50,
|
||||
];
|
||||
const starts = [0, 0, 0, 0, 0.1, 0.3, 0.5, 1.0];
|
||||
|
||||
const words = buildWordsFromTimestampedTokens({
|
||||
tokens,
|
||||
tokenStartTimestamps: starts,
|
||||
tokenizer,
|
||||
eosTokenId: 50257,
|
||||
promptLength: 3,
|
||||
timestampBeginTokenId,
|
||||
timePrecision: 0.02,
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
expect(words.length).toBe(2);
|
||||
expect(words[0].word.toLowerCase()).toContain('hello');
|
||||
expect(words[1].word.toLowerCase()).toContain('world');
|
||||
expect(words[1].word).toContain('!');
|
||||
expect(words[0].startSec).toBeGreaterThanOrEqual(0);
|
||||
expect(words[1].endSec).toBeGreaterThanOrEqual(words[1].startSec);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue