refactor(compute): remove legacy app whisper duplicate and unify core whisper boundary

This commit is contained in:
Richard R 2026-05-21 21:15:10 -06:00
parent 1612e6694b
commit 37d11bf9b8
18 changed files with 29 additions and 1887 deletions

View file

@ -65,7 +65,7 @@ COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
RUN chmod +x /usr/local/bin/weed
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
COPY --from=app-builder /app/src/lib/server/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
COPY --from=app-builder /app/compute/core/src/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
# Expose the port the app runs on
EXPOSE 3003

View file

@ -16,6 +16,7 @@
"./contracts": "./src/contracts.ts",
"./local-runtime": "./src/local-runtime.ts",
"./runtime": "./src/runtime/index.ts",
"./whisper": "./src/whisper/index.ts",
"./pdf-layout": "./src/pdf-layout/index.ts",
"./pdf-layout/parse": "./src/pdf-layout/parsePdf.ts"
}

View file

@ -0,0 +1,18 @@
export {
alignAudioWithText,
makeWhisperCacheKey,
type WhisperRequestBody,
} from './alignment';
export {
ensureWhisperModel,
ensureWhisperArtifacts,
createSingleflightRunner,
type WhisperArtifactSpec,
type WhisperStaticArtifactSpec,
type WhisperFetch,
} from './ensureModel';
export { mapWordsToSentenceOffsets, type WhisperWord } from './alignment-mapping';
export { buildGoertzelCoefficients, goertzelPower } from './spectral';
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './token-timestamps';

View file

@ -1,5 +1,4 @@
import { createHash } from 'node:crypto';
import os from 'node:os';
import Fastify, { type FastifyRequest } from 'fastify';
import { z } from 'zod';
import {
@ -29,6 +28,8 @@ import {
} from '@openreader/compute-core/local-runtime';
import {
getComputeTimeoutConfig,
getAvailableCpuCores,
getOnnxThreadsPerJob,
withIdleTimeoutAndHardCap,
withTimeout,
} from '@openreader/compute-core/runtime';
@ -117,20 +118,6 @@ function parseBoolEnv(name: string, fallback: boolean): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}
function getAvailableCpuCores(): number {
if (typeof os.availableParallelism === 'function') {
const value = os.availableParallelism();
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
}
const fallback = os.cpus().length;
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
}
function getOnnxThreadsPerJob(jobConcurrency: number): number {
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
return Math.max(1, Math.floor(usableCores / Math.max(1, jobConcurrency)));
}
function buildLoggerConfig(): boolean | Record<string, unknown> {
const format = (process.env.COMPUTE_LOG_FORMAT?.trim().toLowerCase() || 'pretty');
if (format === 'json') return true;
@ -528,7 +515,7 @@ async function main(): Promise<void> {
pdfAttempts,
opStaleMs,
availableCpuCores: getAvailableCpuCores(),
onnxThreadsPerJob: getOnnxThreadsPerJob(jobConcurrency),
onnxThreadsPerJob: getOnnxThreadsPerJob(),
natsApiTimeoutMs: NATS_API_TIMEOUT_MS,
pdfLayoutHardCapMs: pdfHardCapMs,
}, 'compute runtime config');

View file

@ -1,46 +0,0 @@
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

View file

@ -1,240 +0,0 @@
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 WHISPER_MODEL_BASE_URL_ENV = 'WHISPER_MODEL_BASE_URL';
const MODEL_RELATIVE_PATHS: string[] = [
'config.json',
'generation_config.json',
'tokenizer.json',
'tokenizer_config.json',
'merges.txt',
'vocab.json',
'normalizer.json',
'added_tokens.json',
'preprocessor_config.json',
'special_tokens_map.json',
'onnx/encoder_model_int8.onnx',
'onnx/decoder_model_merged_int8.onnx',
'onnx/decoder_with_past_model_int8.onnx',
];
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`,
};
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 joinModelUrl(baseUrl: string, relativePath: string): string {
return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`;
}
function resolveUrl(relativePath: string): string {
const overrideBase = process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim();
if (overrideBase) {
return joinModelUrl(overrideBase, relativePath);
}
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> {
if (process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim()) {
for (const relativePath of MODEL_RELATIVE_PATHS) {
if (!(relativePath in DEFAULT_URLS)) {
throw new Error(`Missing default URL path mapping for Whisper artifact: ${relativePath}`);
}
}
}
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();
}

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,76 +0,0 @@
{
"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
}
]
}

View file

@ -1,21 +0,0 @@
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;
}

View file

@ -1,449 +0,0 @@
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);
}

View file

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import {
mapWordsToSentenceOffsets,
} from '../../src/lib/server/whisper/alignment-mapping';
} from '@openreader/compute-core/whisper';
test.describe('whisper alignment mapping', () => {
test('maps words to sentence offsets with punctuation and repeated spaces', () => {

View file

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { readFile } from 'fs/promises';
import path from 'path';
import { alignAudioWithText } from '../../src/lib/server/whisper/alignment';
import { alignAudioWithText } from '@openreader/compute-core/whisper';
test.describe('whisper alignment smoke', () => {
test('runs ONNX alignment end-to-end without decoder reshape errors', async () => {

View file

@ -6,7 +6,7 @@ import path from 'path';
import {
createSingleflightRunner,
ensureWhisperArtifacts,
} from '../../src/lib/server/whisper/ensureModel';
} from '@openreader/compute-core/whisper';
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');

View file

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { buildGoertzelCoefficients, goertzelPower } from '../../src/lib/server/whisper/spectral';
import { buildGoertzelCoefficients, goertzelPower } from '@openreader/compute-core/whisper';
function dftPower(samples: Float32Array, k: number): number {
const n = samples.length;

View file

@ -3,7 +3,7 @@ import * as ort from 'onnxruntime-node';
import {
buildWordsFromTimestampedTokens,
extractTokenStartTimestamps,
} from '../../src/lib/server/whisper/token-timestamps';
} from '@openreader/compute-core/whisper';
test.describe('whisper token timestamp alignment', () => {
test('extracts monotonic token timestamps from cross-attention maps', () => {

View file

@ -24,6 +24,7 @@
"@openreader/compute-core/contracts": ["./compute/core/src/contracts.ts"],
"@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"],
"@openreader/compute-core/runtime": ["./compute/core/src/runtime/index.ts"],
"@openreader/compute-core/whisper": ["./compute/core/src/whisper/index.ts"],
"@openreader/compute-core/pdf-layout": ["./compute/core/src/pdf-layout/index.ts"],
"@openreader/compute-core/pdf-layout/parse": ["./compute/core/src/pdf-layout/parsePdf.ts"]
}