@khorsheed/dsh-reader 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29054 -6698
- package/lib/index.js +3416 -334
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +501 -28
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +71 -1
- package/lib/types/service.js +280 -59
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +87 -0
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +29 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { type Cue } from './captions.ts';
|
|
2
|
+
import type { ReaderMedia } from './media.ts';
|
|
3
|
+
import type { LocalTranscriptStatus } from './types.ts';
|
|
4
|
+
/** The part of the `transcribe` service the reader calls. */
|
|
5
|
+
export interface TranscribeEngine {
|
|
6
|
+
modelsFor(request: {
|
|
7
|
+
language?: 'zh' | 'en' | 'auto';
|
|
8
|
+
diarize?: false;
|
|
9
|
+
}): string[];
|
|
10
|
+
missingBytes(ids: readonly string[]): Promise<number>;
|
|
11
|
+
run(request: {
|
|
12
|
+
input: {
|
|
13
|
+
files: string[];
|
|
14
|
+
};
|
|
15
|
+
fromSec?: number;
|
|
16
|
+
language: 'zh' | 'en' | 'auto';
|
|
17
|
+
diarize: false;
|
|
18
|
+
owner: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
}, options: {
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
onProgress?: (progress: {
|
|
23
|
+
stage: string;
|
|
24
|
+
progress: number;
|
|
25
|
+
ahead?: number;
|
|
26
|
+
}) => void;
|
|
27
|
+
onSegments?: (segments: readonly EngineSegment[]) => void;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
segments: EngineSegment[];
|
|
30
|
+
language?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Who spoke when (an engine older than this has none: transcripts stay one voice). */
|
|
33
|
+
speakers?(request: {
|
|
34
|
+
input: {
|
|
35
|
+
files: string[];
|
|
36
|
+
};
|
|
37
|
+
speech?: {
|
|
38
|
+
start: number;
|
|
39
|
+
end: number;
|
|
40
|
+
}[];
|
|
41
|
+
owner: string;
|
|
42
|
+
label?: string;
|
|
43
|
+
}, options: {
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
onProgress?: (progress: {
|
|
46
|
+
stage: string;
|
|
47
|
+
progress: number;
|
|
48
|
+
ahead?: number;
|
|
49
|
+
}) => void;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
single: boolean;
|
|
52
|
+
turns: {
|
|
53
|
+
cluster: number;
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
}[];
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
/** A speaker turn as the engine reports it. */
|
|
60
|
+
export interface EngineTurn {
|
|
61
|
+
readonly cluster: number;
|
|
62
|
+
readonly start: number;
|
|
63
|
+
readonly end: number;
|
|
64
|
+
}
|
|
65
|
+
/** One finished stretch as the engine reports it. */
|
|
66
|
+
export interface EngineSegment {
|
|
67
|
+
readonly start: number;
|
|
68
|
+
readonly end: number;
|
|
69
|
+
readonly tokens: readonly {
|
|
70
|
+
readonly text: string;
|
|
71
|
+
readonly start: number;
|
|
72
|
+
readonly end: number;
|
|
73
|
+
}[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Engine stretches as caption-like cues: one per sentence (the engine's
|
|
77
|
+
* stretches are cut at pauses, not sentence ends), so the transcript merges
|
|
78
|
+
* into the same paragraphs platform captions do. A sentence that runs on
|
|
79
|
+
* without a period is closed at a comma or a pause once it is long, or a
|
|
80
|
+
* single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
|
|
81
|
+
*
|
|
82
|
+
* @param segments - the engine's segments, in order.
|
|
83
|
+
*/
|
|
84
|
+
export declare function cuesFromSegments(segments: readonly EngineSegment[], speakers?: readonly (number | undefined)[]): (Cue & {
|
|
85
|
+
speaker?: number;
|
|
86
|
+
})[];
|
|
87
|
+
/**
|
|
88
|
+
* Each token's speaker, as an index in order of first appearance (A, B, …):
|
|
89
|
+
* the turn covering the token's middle, else the nearest one; then runs of a
|
|
90
|
+
* voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
|
|
91
|
+
* them — a diarizer's boundary wobble would otherwise split sentences.
|
|
92
|
+
*
|
|
93
|
+
* @param segments - the transcript's segments.
|
|
94
|
+
* @param turns - the engine's turns.
|
|
95
|
+
*/
|
|
96
|
+
export declare function speakerPerToken(segments: readonly EngineSegment[], turns: readonly EngineTurn[]): number[];
|
|
97
|
+
/**
|
|
98
|
+
* The transcript with its speakers: paragraphs never cross a change of voice,
|
|
99
|
+
* and each carries `data-spk` (the speaker's index) beside its `data-t`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function renderSpeakerTranscript(segments: readonly EngineSegment[], turns: readonly EngineTurn[]): string;
|
|
102
|
+
/** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
|
|
103
|
+
export declare const LOCAL_MARK = "data-src=\"local\"";
|
|
104
|
+
/** The engine's segments as transcript markup, each paragraph marked local. */
|
|
105
|
+
export declare function renderLocalTranscript(segments: readonly EngineSegment[]): string;
|
|
106
|
+
/**
|
|
107
|
+
* The language to ask for: always detection (SenseVoice). Measured
|
|
108
|
+
* 2026-10-20: a platform says nothing reliable about the spoken language — a
|
|
109
|
+
* Bilibili upload of an English interview came out of the Chinese model as
|
|
110
|
+
* lower-case English with Chinese punctuation — and on Chinese speech
|
|
111
|
+
* SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
|
|
112
|
+
* two or three characters the other got right).
|
|
113
|
+
*/
|
|
114
|
+
export declare function languageFor(_media: ReaderMedia): 'auto';
|
|
115
|
+
/** What the transcriber needs from the reader. */
|
|
116
|
+
export interface LocalTranscriberDeps {
|
|
117
|
+
/** The engine, probed per start. */
|
|
118
|
+
readonly engine: () => TranscribeEngine | undefined;
|
|
119
|
+
/** Where temporary audio goes. */
|
|
120
|
+
readonly tmpRoot: string;
|
|
121
|
+
/** Fetch a video's audio track into `dir`. */
|
|
122
|
+
readonly videoAudio: (url: string, dir: string, options: {
|
|
123
|
+
signal: AbortSignal;
|
|
124
|
+
onProgress: (fraction: number) => void;
|
|
125
|
+
}) => Promise<string>;
|
|
126
|
+
/** How often a restart checks for the engine before resuming jobs (ms; default 2 s). */
|
|
127
|
+
readonly resumePollMs?: number;
|
|
128
|
+
/** Keep the finished transcript as the entry's body (`speakers`: how many voices, once known). */
|
|
129
|
+
readonly store: (entryId: string, url: string, html: string, language: string | undefined, speakers?: number) => Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
/** The reader's local transcription jobs, by entry. */
|
|
132
|
+
export declare class LocalTranscriber {
|
|
133
|
+
private readonly deps;
|
|
134
|
+
private readonly jobs;
|
|
135
|
+
/** Jobs a previous process left mid-way, waiting for the engine. */
|
|
136
|
+
private readonly resumable;
|
|
137
|
+
private resumeTimer;
|
|
138
|
+
private disposing;
|
|
139
|
+
constructor(deps: LocalTranscriberDeps);
|
|
140
|
+
/** An entry's job as the browser sees it. */
|
|
141
|
+
status(entryId: string): LocalTranscriptStatus;
|
|
142
|
+
/**
|
|
143
|
+
* Start (or rejoin) an entry's transcription.
|
|
144
|
+
*
|
|
145
|
+
* @param request - the entry, its media, and whether the model download was confirmed.
|
|
146
|
+
*/
|
|
147
|
+
start(request: {
|
|
148
|
+
entryId: string;
|
|
149
|
+
url: string;
|
|
150
|
+
media: ReaderMedia;
|
|
151
|
+
title?: string;
|
|
152
|
+
confirmModels?: boolean;
|
|
153
|
+
}): Promise<LocalTranscriptStatus>;
|
|
154
|
+
/**
|
|
155
|
+
* Leave or stop an entry's job: a queued (or still fetching) job is
|
|
156
|
+
* withdrawn either way; a running one only with `force`.
|
|
157
|
+
*/
|
|
158
|
+
cancel(entryId: string, force: boolean): LocalTranscriptStatus;
|
|
159
|
+
/** Whether the engine is installed right now. */
|
|
160
|
+
engineAvailable(): boolean;
|
|
161
|
+
/** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
|
|
162
|
+
dispose(): void;
|
|
163
|
+
/**
|
|
164
|
+
* Run (or resume) one job: fetch the audio unless the record has it,
|
|
165
|
+
* transcribe from where the saved segments end, store, then the speakers.
|
|
166
|
+
*
|
|
167
|
+
* @param resumeDir - a previous process's directory for this job.
|
|
168
|
+
*/
|
|
169
|
+
private execute;
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=local-transcribe.d.ts.map
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local transcription of a video or audio entry without captions, on the
|
|
3
|
+
* optional `transcribe` engine (@khorsheed/dsh-transcribe).
|
|
4
|
+
*
|
|
5
|
+
* Opening such an entry starts a job (2026-10-20 decision: automatic on open,
|
|
6
|
+
* never on feed refresh): the audio is fetched into a temporary directory —
|
|
7
|
+
* yt-dlp for a video, curl for an audio URL — handed to the engine, and the
|
|
8
|
+
* finished stretches render as transcript paragraphs while it runs. A job
|
|
9
|
+
* still QUEUED when the reader leaves is withdrawn; a RUNNING one finishes and
|
|
10
|
+
* is stored either way (`cancel` with `force`, the 字幕 switch and the 取消
|
|
11
|
+
* button, stops it). The engine's first use downloads its models: the job
|
|
12
|
+
* waits for one confirmation (`needs-models`) and then runs.
|
|
13
|
+
*
|
|
14
|
+
* The engine is restated here structurally (no code edge between the
|
|
15
|
+
* packages) and probed on every start.
|
|
16
|
+
*
|
|
17
|
+
* @module @khorsheed/dsh-reader/local-transcribe
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { mergeCues, renderTranscript } from "./captions.js";
|
|
23
|
+
/** Sentence ends in either script. */
|
|
24
|
+
const SENTENCE_END = /[。!?!?…]$|[^.\d]\.$/;
|
|
25
|
+
/** A run-on sentence (SenseVoice punctuates English sparsely) closes at a comma past this long (s)… */
|
|
26
|
+
const LONG_AT_COMMA_SEC = 20;
|
|
27
|
+
/** …at a pause between words past this long… */
|
|
28
|
+
const LONG_AT_PAUSE_SEC = 30;
|
|
29
|
+
/** …and in any case by this. */
|
|
30
|
+
const LONGEST_CUE_SEC = 45;
|
|
31
|
+
/**
|
|
32
|
+
* Engine stretches as caption-like cues: one per sentence (the engine's
|
|
33
|
+
* stretches are cut at pauses, not sentence ends), so the transcript merges
|
|
34
|
+
* into the same paragraphs platform captions do. A sentence that runs on
|
|
35
|
+
* without a period is closed at a comma or a pause once it is long, or a
|
|
36
|
+
* single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
|
|
37
|
+
*
|
|
38
|
+
* @param segments - the engine's segments, in order.
|
|
39
|
+
*/
|
|
40
|
+
export function cuesFromSegments(segments, speakers) {
|
|
41
|
+
const cues = [];
|
|
42
|
+
let text = '';
|
|
43
|
+
let start;
|
|
44
|
+
let end = 0;
|
|
45
|
+
let speaker;
|
|
46
|
+
const flush = () => {
|
|
47
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
48
|
+
if (start !== undefined && trimmed !== '')
|
|
49
|
+
cues.push({ start, end, text: trimmed, ...(speaker === undefined ? {} : { speaker }) });
|
|
50
|
+
text = '';
|
|
51
|
+
start = undefined;
|
|
52
|
+
};
|
|
53
|
+
const tokens = segments.flatMap(segment => segment.tokens);
|
|
54
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
55
|
+
const token = tokens[index];
|
|
56
|
+
// A new voice starts a new cue (and so a new paragraph).
|
|
57
|
+
const voice = speakers?.[index];
|
|
58
|
+
if (voice !== undefined && voice !== speaker) {
|
|
59
|
+
flush();
|
|
60
|
+
speaker = voice;
|
|
61
|
+
}
|
|
62
|
+
start ??= token.start;
|
|
63
|
+
text += token.text;
|
|
64
|
+
end = token.end;
|
|
65
|
+
const length = end - start;
|
|
66
|
+
const next = tokens[index + 1];
|
|
67
|
+
if (SENTENCE_END.test(text.trimEnd())
|
|
68
|
+
|| (length >= LONG_AT_COMMA_SEC && /[,,、;;]$/.test(token.text.trim()))
|
|
69
|
+
|| (length >= LONG_AT_PAUSE_SEC && next !== undefined && next.start - token.end >= 0.4)
|
|
70
|
+
|| length >= LONGEST_CUE_SEC)
|
|
71
|
+
flush();
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return cues;
|
|
75
|
+
}
|
|
76
|
+
/** A run of one voice shorter than this is the diarizer flickering, not a turn (s). */
|
|
77
|
+
const MIN_TURN_SEC = 2.5;
|
|
78
|
+
/**
|
|
79
|
+
* Each token's speaker, as an index in order of first appearance (A, B, …):
|
|
80
|
+
* the turn covering the token's middle, else the nearest one; then runs of a
|
|
81
|
+
* voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
|
|
82
|
+
* them — a diarizer's boundary wobble would otherwise split sentences.
|
|
83
|
+
*
|
|
84
|
+
* @param segments - the transcript's segments.
|
|
85
|
+
* @param turns - the engine's turns.
|
|
86
|
+
*/
|
|
87
|
+
export function speakerPerToken(segments, turns) {
|
|
88
|
+
const sorted = [...turns].sort((a, b) => a.start - b.start);
|
|
89
|
+
const order = new Map();
|
|
90
|
+
const tokens = segments.flatMap(segment => segment.tokens);
|
|
91
|
+
const raw = tokens.map(token => {
|
|
92
|
+
const at = (token.start + token.end) / 2;
|
|
93
|
+
let best;
|
|
94
|
+
let distance = Infinity;
|
|
95
|
+
for (const turn of sorted) {
|
|
96
|
+
const d = at < turn.start ? turn.start - at : at > turn.end ? at - turn.end : 0;
|
|
97
|
+
if (d < distance) {
|
|
98
|
+
distance = d;
|
|
99
|
+
best = turn;
|
|
100
|
+
}
|
|
101
|
+
if (turn.start > at + distance)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return best?.cluster ?? -1;
|
|
105
|
+
});
|
|
106
|
+
// Fold flickers: a run shorter than the minimum takes the previous voice.
|
|
107
|
+
const out = [...raw];
|
|
108
|
+
let runStart = 0;
|
|
109
|
+
for (let index = 1; index <= out.length; index++) {
|
|
110
|
+
if (index < out.length && out[index] === out[runStart])
|
|
111
|
+
continue;
|
|
112
|
+
const length = (tokens[index - 1]?.end ?? 0) - (tokens[runStart]?.start ?? 0);
|
|
113
|
+
if (runStart > 0 && length < MIN_TURN_SEC)
|
|
114
|
+
for (let k = runStart; k < index; k++)
|
|
115
|
+
out[k] = out[runStart - 1];
|
|
116
|
+
runStart = index;
|
|
117
|
+
}
|
|
118
|
+
// Punctuation belongs to the word before it, never opens the next voice's paragraph.
|
|
119
|
+
for (let index = 1; index < out.length; index++) {
|
|
120
|
+
if (/^\s*[\p{P}]+\s*$/u.test(tokens[index].text))
|
|
121
|
+
out[index] = out[index - 1];
|
|
122
|
+
}
|
|
123
|
+
return out.map(cluster => {
|
|
124
|
+
if (!order.has(cluster))
|
|
125
|
+
order.set(cluster, order.size);
|
|
126
|
+
return order.get(cluster);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Escape text for HTML content. */
|
|
130
|
+
function escapeText(text) {
|
|
131
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The transcript with its speakers: paragraphs never cross a change of voice,
|
|
135
|
+
* and each carries `data-spk` (the speaker's index) beside its `data-t`.
|
|
136
|
+
*/
|
|
137
|
+
export function renderSpeakerTranscript(segments, turns) {
|
|
138
|
+
const cues = cuesFromSegments(segments, speakerPerToken(segments, turns));
|
|
139
|
+
const out = [];
|
|
140
|
+
let run = [];
|
|
141
|
+
const flush = () => {
|
|
142
|
+
const speaker = run[0]?.speaker;
|
|
143
|
+
for (const paragraph of mergeCues(run)) {
|
|
144
|
+
out.push(`<p ${LOCAL_MARK} data-spk="${String(speaker ?? 0)}" data-t="${paragraph.start.toFixed(1)}">${escapeText(paragraph.text)}</p>`);
|
|
145
|
+
}
|
|
146
|
+
run = [];
|
|
147
|
+
};
|
|
148
|
+
for (const cue of cues) {
|
|
149
|
+
if (run.length > 0 && cue.speaker !== run[0].speaker)
|
|
150
|
+
flush();
|
|
151
|
+
run.push(cue);
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
return out.join('\n');
|
|
155
|
+
}
|
|
156
|
+
/** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
|
|
157
|
+
export const LOCAL_MARK = 'data-src="local"';
|
|
158
|
+
/** The engine's segments as transcript markup, each paragraph marked local. */
|
|
159
|
+
export function renderLocalTranscript(segments) {
|
|
160
|
+
return renderTranscript(mergeCues(cuesFromSegments(segments))).replace(/<p data-t=/g, `<p ${LOCAL_MARK} data-t=`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The language to ask for: always detection (SenseVoice). Measured
|
|
164
|
+
* 2026-10-20: a platform says nothing reliable about the spoken language — a
|
|
165
|
+
* Bilibili upload of an English interview came out of the Chinese model as
|
|
166
|
+
* lower-case English with Chinese punctuation — and on Chinese speech
|
|
167
|
+
* SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
|
|
168
|
+
* two or three characters the other got right).
|
|
169
|
+
*/
|
|
170
|
+
export function languageFor(_media) {
|
|
171
|
+
return 'auto';
|
|
172
|
+
}
|
|
173
|
+
/** Fetch a URL to a file with curl (it honors the deployment's proxy environment). */
|
|
174
|
+
function curlTo(url, file, signal) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
execFile('curl', ['-fsSL', '--retry', '2', '-o', file, url], { signal, timeout: 30 * 60_000 }, (error) => {
|
|
177
|
+
if (error === null)
|
|
178
|
+
resolve();
|
|
179
|
+
else
|
|
180
|
+
reject(new Error(`下载音频失败:${error.message.split('\n')[0] ?? ''}`));
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** A model download larger than this asks first; a small addition (the 2 MB VAD) just happens. */
|
|
185
|
+
const CONFIRM_BYTES = 20_000_000;
|
|
186
|
+
/** Share of the progress bar the audio download takes. */
|
|
187
|
+
const FETCH_SHARE = 0.1;
|
|
188
|
+
/** How often a running job writes its finished segments down (ms). */
|
|
189
|
+
const SAVE_EVERY_MS = 15_000;
|
|
190
|
+
/** How long a restart waits for the engine to be provided before giving up on resuming (ms). */
|
|
191
|
+
const RESUME_WAIT_MS = 10 * 60_000;
|
|
192
|
+
function readJson(path) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function writeJson(path, value) {
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value));
|
|
203
|
+
renameSync(`${path}.tmp`, path);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// A record that cannot be written only costs the resume.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** The reader's local transcription jobs, by entry. */
|
|
210
|
+
export class LocalTranscriber {
|
|
211
|
+
deps;
|
|
212
|
+
jobs = new Map();
|
|
213
|
+
/** Jobs a previous process left mid-way, waiting for the engine. */
|
|
214
|
+
resumable = [];
|
|
215
|
+
resumeTimer;
|
|
216
|
+
disposing = false;
|
|
217
|
+
constructor(deps) {
|
|
218
|
+
this.deps = deps;
|
|
219
|
+
try {
|
|
220
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
221
|
+
for (const name of readdirSync(deps.tmpRoot)) {
|
|
222
|
+
if (!name.startsWith('audio-'))
|
|
223
|
+
continue;
|
|
224
|
+
const dir = join(deps.tmpRoot, name);
|
|
225
|
+
// Audio a killed process left behind (an hour of it is tens of MB): gone after a day.
|
|
226
|
+
if (statSync(dir).mtimeMs < cutoff) {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const record = readJson(join(dir, 'job.json'));
|
|
231
|
+
if (record?.entryId !== undefined && record.media !== undefined)
|
|
232
|
+
this.resumable.push({ dir, record });
|
|
233
|
+
else
|
|
234
|
+
rmSync(dir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// No tmp directory yet.
|
|
239
|
+
}
|
|
240
|
+
if (this.resumable.length > 0) {
|
|
241
|
+
// The engine is another plugin: it may be provided after this one boots.
|
|
242
|
+
const since = Date.now();
|
|
243
|
+
this.resumeTimer = setInterval(() => {
|
|
244
|
+
const engine = this.deps.engine();
|
|
245
|
+
if (engine === undefined && Date.now() - since < RESUME_WAIT_MS)
|
|
246
|
+
return;
|
|
247
|
+
clearInterval(this.resumeTimer);
|
|
248
|
+
this.resumeTimer = undefined;
|
|
249
|
+
for (const { dir, record } of this.resumable.splice(0)) {
|
|
250
|
+
if (engine === undefined || this.jobs.has(record.entryId)) {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const job = { state: 'queued', progress: FETCH_SHARE, segments: [], rendered: 0, abort: new AbortController() };
|
|
255
|
+
this.jobs.set(record.entryId, job);
|
|
256
|
+
void this.execute(record, engine, 'auto', job, dir);
|
|
257
|
+
}
|
|
258
|
+
}, this.deps.resumePollMs ?? 2000);
|
|
259
|
+
this.resumeTimer.unref?.();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** An entry's job as the browser sees it. */
|
|
263
|
+
status(entryId) {
|
|
264
|
+
const job = this.jobs.get(entryId);
|
|
265
|
+
if (job === undefined)
|
|
266
|
+
return { entryId, state: 'idle' };
|
|
267
|
+
if (job.rendered !== job.segments.length && job.state !== 'done') {
|
|
268
|
+
job.html = renderLocalTranscript(job.segments);
|
|
269
|
+
job.rendered = job.segments.length;
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
entryId,
|
|
273
|
+
state: job.state,
|
|
274
|
+
progress: job.progress,
|
|
275
|
+
...(job.ahead === undefined ? {} : { ahead: job.ahead }),
|
|
276
|
+
...(job.html === undefined ? {} : { html: job.html }),
|
|
277
|
+
...(job.language === undefined ? {} : { language: job.language }),
|
|
278
|
+
...(job.error === undefined ? {} : { error: job.error }),
|
|
279
|
+
...(job.speakers === undefined ? {} : { speakers: job.speakers }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Start (or rejoin) an entry's transcription.
|
|
284
|
+
*
|
|
285
|
+
* @param request - the entry, its media, and whether the model download was confirmed.
|
|
286
|
+
*/
|
|
287
|
+
async start(request) {
|
|
288
|
+
const current = this.jobs.get(request.entryId);
|
|
289
|
+
if (current !== undefined && current.state !== 'failed' && current.state !== 'done')
|
|
290
|
+
return this.status(request.entryId);
|
|
291
|
+
const engine = this.deps.engine();
|
|
292
|
+
if (engine === undefined)
|
|
293
|
+
return { entryId: request.entryId, state: 'unavailable' };
|
|
294
|
+
const language = languageFor(request.media);
|
|
295
|
+
const bytes = await engine.missingBytes(engine.modelsFor({ language, diarize: false }));
|
|
296
|
+
if (bytes > CONFIRM_BYTES && request.confirmModels !== true)
|
|
297
|
+
return { entryId: request.entryId, state: 'needs-models', bytes };
|
|
298
|
+
const job = { state: 'fetching', progress: 0, segments: [], rendered: 0, abort: new AbortController() };
|
|
299
|
+
this.jobs.set(request.entryId, job);
|
|
300
|
+
const record = { entryId: request.entryId, url: request.url, media: request.media, ...(request.title === undefined ? {} : { title: request.title }), phase: 'transcribe' };
|
|
301
|
+
void this.execute(record, engine, language, job);
|
|
302
|
+
return this.status(request.entryId);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Leave or stop an entry's job: a queued (or still fetching) job is
|
|
306
|
+
* withdrawn either way; a running one only with `force`.
|
|
307
|
+
*/
|
|
308
|
+
cancel(entryId, force) {
|
|
309
|
+
const job = this.jobs.get(entryId);
|
|
310
|
+
if (job === undefined)
|
|
311
|
+
return { entryId, state: 'idle' };
|
|
312
|
+
// Leaving during the speakers pass lets it finish too (the transcript is stored already).
|
|
313
|
+
if (force || job.state === 'queued' || job.state === 'fetching') {
|
|
314
|
+
job.abort.abort();
|
|
315
|
+
this.jobs.delete(entryId);
|
|
316
|
+
return { entryId, state: 'idle' };
|
|
317
|
+
}
|
|
318
|
+
return this.status(entryId);
|
|
319
|
+
}
|
|
320
|
+
/** Whether the engine is installed right now. */
|
|
321
|
+
engineAvailable() {
|
|
322
|
+
return this.deps.engine() !== undefined;
|
|
323
|
+
}
|
|
324
|
+
/** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
|
|
325
|
+
dispose() {
|
|
326
|
+
this.disposing = true;
|
|
327
|
+
if (this.resumeTimer !== undefined)
|
|
328
|
+
clearInterval(this.resumeTimer);
|
|
329
|
+
for (const job of this.jobs.values())
|
|
330
|
+
job.abort.abort();
|
|
331
|
+
this.jobs.clear();
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Run (or resume) one job: fetch the audio unless the record has it,
|
|
335
|
+
* transcribe from where the saved segments end, store, then the speakers.
|
|
336
|
+
*
|
|
337
|
+
* @param resumeDir - a previous process's directory for this job.
|
|
338
|
+
*/
|
|
339
|
+
async execute(record, engine, language, job, resumeDir) {
|
|
340
|
+
const signal = job.abort.signal;
|
|
341
|
+
mkdirSync(this.deps.tmpRoot, { recursive: true });
|
|
342
|
+
const dir = resumeDir ?? mkdtempSync(join(this.deps.tmpRoot, 'audio-'));
|
|
343
|
+
const recordPath = join(dir, 'job.json');
|
|
344
|
+
const segmentsPath = join(dir, 'segments.json');
|
|
345
|
+
writeJson(recordPath, record);
|
|
346
|
+
let lastSave = 0;
|
|
347
|
+
const saveSegments = (force) => {
|
|
348
|
+
if (!force && Date.now() - lastSave < SAVE_EVERY_MS)
|
|
349
|
+
return;
|
|
350
|
+
lastSave = Date.now();
|
|
351
|
+
writeJson(segmentsPath, job.segments);
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
let file = record.file !== undefined && existsSync(record.file) ? record.file : undefined;
|
|
355
|
+
if (file === undefined) {
|
|
356
|
+
// No audio yet (or it went): whatever was transcribed from it is not trusted either.
|
|
357
|
+
job.segments = [];
|
|
358
|
+
if (record.media.kind === 'video') {
|
|
359
|
+
file = await this.deps.videoAudio(record.url, dir, { signal, onProgress: (fraction) => { job.progress = FETCH_SHARE * fraction; } });
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
file = join(dir, 'audio');
|
|
363
|
+
await curlTo(record.media.ref, file, signal);
|
|
364
|
+
}
|
|
365
|
+
record.file = file;
|
|
366
|
+
record.phase = 'transcribe';
|
|
367
|
+
writeJson(recordPath, record);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
job.segments = readJson(segmentsPath) ?? [];
|
|
371
|
+
}
|
|
372
|
+
job.progress = FETCH_SHARE;
|
|
373
|
+
let segments;
|
|
374
|
+
let detected = record.language;
|
|
375
|
+
if (record.phase === 'transcribe') {
|
|
376
|
+
job.state = 'queued';
|
|
377
|
+
// A resumed job picks up where its saved segments end.
|
|
378
|
+
const earlier = [...job.segments];
|
|
379
|
+
const fromSec = earlier[earlier.length - 1]?.end ?? 0;
|
|
380
|
+
const result = await engine.run({
|
|
381
|
+
input: { files: [file] },
|
|
382
|
+
...(fromSec > 0 ? { fromSec } : {}),
|
|
383
|
+
language,
|
|
384
|
+
diarize: false,
|
|
385
|
+
owner: '@khorsheed/dsh-reader',
|
|
386
|
+
...(record.title === undefined ? {} : { label: record.title }),
|
|
387
|
+
}, {
|
|
388
|
+
signal,
|
|
389
|
+
onProgress: (progress) => {
|
|
390
|
+
if (progress.stage === 'queued') {
|
|
391
|
+
job.state = 'queued';
|
|
392
|
+
if (progress.ahead === undefined)
|
|
393
|
+
delete job.ahead;
|
|
394
|
+
else
|
|
395
|
+
job.ahead = progress.ahead;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
job.state = 'transcribing';
|
|
399
|
+
delete job.ahead;
|
|
400
|
+
job.progress = FETCH_SHARE + (1 - FETCH_SHARE) * progress.progress;
|
|
401
|
+
},
|
|
402
|
+
onSegments: (more) => {
|
|
403
|
+
job.segments.push(...more);
|
|
404
|
+
saveSegments(false);
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
segments = [...earlier, ...result.segments];
|
|
408
|
+
detected = result.language ?? detected;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
segments = job.segments;
|
|
412
|
+
}
|
|
413
|
+
const html = renderLocalTranscript(segments);
|
|
414
|
+
if (html === '')
|
|
415
|
+
throw new Error('没有识别出语音');
|
|
416
|
+
if (detected !== undefined)
|
|
417
|
+
job.language = detected;
|
|
418
|
+
job.segments = segments;
|
|
419
|
+
saveSegments(true);
|
|
420
|
+
if (record.phase === 'transcribe') {
|
|
421
|
+
await this.deps.store(record.entryId, record.url, html, detected);
|
|
422
|
+
record.phase = 'speakers';
|
|
423
|
+
if (detected !== undefined)
|
|
424
|
+
record.language = detected;
|
|
425
|
+
writeJson(recordPath, record);
|
|
426
|
+
}
|
|
427
|
+
job.html = html;
|
|
428
|
+
job.rendered = segments.length;
|
|
429
|
+
job.progress = 1;
|
|
430
|
+
// Then who spoke when, on the audio still at hand. The transcript is
|
|
431
|
+
// already stored and readable; this pass only re-cuts its paragraphs.
|
|
432
|
+
if (engine.speakers !== undefined) {
|
|
433
|
+
job.state = 'speakers';
|
|
434
|
+
try {
|
|
435
|
+
const voices = await engine.speakers({
|
|
436
|
+
input: { files: [file] },
|
|
437
|
+
speech: segments.map(segment => ({ start: segment.start, end: segment.end })),
|
|
438
|
+
owner: '@khorsheed/dsh-reader',
|
|
439
|
+
...(record.title === undefined ? {} : { label: record.title }),
|
|
440
|
+
}, { signal });
|
|
441
|
+
const count = voices.single ? 1 : new Set(voices.turns.map(turn => turn.cluster)).size;
|
|
442
|
+
if (count >= 2) {
|
|
443
|
+
const split = renderSpeakerTranscript(segments, voices.turns);
|
|
444
|
+
await this.deps.store(record.entryId, record.url, split, detected, count);
|
|
445
|
+
job.html = split;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
await this.deps.store(record.entryId, record.url, html, detected, 1);
|
|
449
|
+
}
|
|
450
|
+
job.speakers = count;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (signal.aborted)
|
|
454
|
+
return;
|
|
455
|
+
// The transcript stands; only the speakers are missing.
|
|
456
|
+
job.speakersError = error instanceof Error ? error.message : String(error);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
job.state = 'done';
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (signal.aborted)
|
|
463
|
+
return;
|
|
464
|
+
job.state = 'failed';
|
|
465
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
// A shutdown keeps the record for the next process; anything else ends the job.
|
|
469
|
+
if (!this.disposing)
|
|
470
|
+
rmSync(dir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|