@speclip/pi-talking-head 0.1.0 → 0.1.2
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/README.md +18 -9
- package/extensions/talking-head/index.ts +41 -6
- package/package.json +1 -1
- package/prompts/edit-talking-head.md +1 -1
- package/skills/talking-head-edit/SKILL.md +5 -4
- package/skills/talking-head-edit/references/cut-craft.md +7 -0
- package/src/contracts.ts +56 -0
- package/src/project.ts +64 -1
- package/src/transcript.ts +198 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @speclip/pi-talking-head
|
|
2
2
|
|
|
3
|
-
给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech`
|
|
3
|
+
给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳,结合整句上下文识别词间停顿、语气词和相邻重复,生成保守的 A-roll 剪辑方案,并可加入 B-roll 规划。
|
|
4
4
|
|
|
5
5
|
它不直接调用 FFmpeg。最终输出是 `pi-media@0.3.1` 的通用 timeline EDL,由 `pi-media` 负责素材校验、不可变 revision、渲染和验收。
|
|
6
6
|
|
|
@@ -42,25 +42,34 @@ talking_head_create {
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
默认只自动移除至少 500ms
|
|
45
|
+
默认只自动移除至少 500ms、且没有语气词或表达边界保护信号的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
|
|
46
46
|
|
|
47
47
|
- `safe`:至少 400ms,通常可以切。
|
|
48
48
|
- `review`:150–399ms,必须结合语义和画面判断。
|
|
49
49
|
- `unsafe`:少于 150ms,默认不切。
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
若停顿紧邻候选语气词,或位于问号、感叹号等表达边界之后,即使超过阈值也会降级为 `review`,不会进入默认自动剪辑。工具返回 revision 1、内容与停顿摘要,以及可直接交给 `pi-media.edit_apply` 的 `mediaOperation`。
|
|
52
52
|
|
|
53
|
-
### 2.
|
|
53
|
+
### 2. 分页检查整句和编辑候选
|
|
54
54
|
|
|
55
55
|
```js
|
|
56
56
|
talking_head_get {
|
|
57
57
|
projectId: "launch-video",
|
|
58
|
+
sentenceOffset: 0,
|
|
59
|
+
sentenceLimit: 20,
|
|
58
60
|
pauseOffset: 0,
|
|
59
|
-
pauseLimit: 50
|
|
61
|
+
pauseLimit: 50,
|
|
62
|
+
fillerOffset: 0,
|
|
63
|
+
fillerLimit: 50,
|
|
64
|
+
repetitionOffset: 0,
|
|
65
|
+
repetitionLimit: 50,
|
|
66
|
+
includeTranscriptText: true
|
|
60
67
|
}
|
|
61
68
|
```
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
工具会分页返回整句上下文、停顿、语气词和相邻重复;需要判断整段结构时可显式取得完整转录文本。`啊`、`额`、`嗯` 等只会成为 `review` 候选,Agent 必须判断它是口癖、语义成分还是刻意表达,不能自动删除。文本标点只能提供低置信度且可以并存的表达线索,不等同于声学情绪识别。
|
|
71
|
+
|
|
72
|
+
只有调用这个工具时才会把这些证据放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
|
|
64
73
|
|
|
65
74
|
### 3. 写入人工确认后的时间线
|
|
66
75
|
|
|
@@ -114,10 +123,10 @@ review { path: "out/launch-final.mp4" }
|
|
|
114
123
|
|
|
115
124
|
| 工具 | 作用 |
|
|
116
125
|
| --- | --- |
|
|
117
|
-
| `talking_head_create` | 从视频和词级转录建立 revision 1
|
|
118
|
-
| `talking_head_get` | 读取指定 revision
|
|
126
|
+
| `talking_head_create` | 从视频和词级转录建立 revision 1,分析句子、语气词、重复和停顿并生成默认 EDL |
|
|
127
|
+
| `talking_head_get` | 读取指定 revision,分页返回整句与编辑候选,可选导出 pi-media EDL |
|
|
119
128
|
| `talking_head_apply` | 写入新的不可变口播 revision,并返回 pi-media EDL |
|
|
120
129
|
|
|
121
130
|
## 当前边界
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
当前版本不负责语音转录、声学情绪识别、素材搜索、字幕、画面理解或渲染。它提供整句文本与时间轴层面的编辑证据;需要依赖真实语气、呼吸或表演意图的决定,必须由 Agent 结合原音频和画面复核。B-roll 的搜索与视觉匹配最终仍写入同一份 `broll` 数据并由 `pi-media` 渲染。
|
|
@@ -75,29 +75,64 @@ export default function talkingHead(pi: ExtensionAPI): void {
|
|
|
75
75
|
pi.registerTool({
|
|
76
76
|
name: "talking_head_get",
|
|
77
77
|
label: "Inspect talking-head edit",
|
|
78
|
-
description: "Read one immutable talking-head revision and
|
|
78
|
+
description: "Read one immutable talking-head revision plus bounded sentence, pause, filler-word, and repetition evidence for Agent editorial judgment. Optionally include the generic pi-media timeline operation.",
|
|
79
79
|
parameters: Type.Object({
|
|
80
80
|
projectId: stableId,
|
|
81
81
|
revision: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
82
|
+
sentenceOffset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
83
|
+
sentenceLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
82
84
|
pauseOffset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
83
85
|
pauseLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
86
|
+
fillerOffset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
87
|
+
fillerLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
88
|
+
repetitionOffset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
89
|
+
repetitionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
90
|
+
includeTranscriptText: Type.Optional(Type.Boolean({ description: "Include the complete transcript text for passage-level Agent review." })),
|
|
84
91
|
includeMediaOperation: Type.Optional(Type.Boolean()),
|
|
85
92
|
}, { additionalProperties: false }),
|
|
86
93
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
87
94
|
const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision);
|
|
88
95
|
const analysis = await getAnalysis(ctx.cwd, project);
|
|
89
|
-
const
|
|
90
|
-
const
|
|
96
|
+
const sentenceOffset = params.sentenceOffset ?? 0;
|
|
97
|
+
const sentenceLimit = params.sentenceLimit ?? 20;
|
|
98
|
+
const pauseOffset = params.pauseOffset ?? 0;
|
|
99
|
+
const pauseLimit = params.pauseLimit ?? 50;
|
|
100
|
+
const fillerOffset = params.fillerOffset ?? 0;
|
|
101
|
+
const fillerLimit = params.fillerLimit ?? 50;
|
|
102
|
+
const repetitionOffset = params.repetitionOffset ?? 0;
|
|
103
|
+
const repetitionLimit = params.repetitionLimit ?? 50;
|
|
104
|
+
const sentences = analysis.sentences ?? [];
|
|
105
|
+
const fillers = analysis.fillers ?? [];
|
|
106
|
+
const repetitions = analysis.repetitions ?? [];
|
|
91
107
|
const details: Record<string, unknown> = {
|
|
92
108
|
project,
|
|
93
109
|
snapshot,
|
|
110
|
+
sentences: {
|
|
111
|
+
offset: sentenceOffset,
|
|
112
|
+
limit: sentenceLimit,
|
|
113
|
+
total: sentences.length,
|
|
114
|
+
items: sentences.slice(sentenceOffset, sentenceOffset + sentenceLimit),
|
|
115
|
+
},
|
|
94
116
|
pauses: {
|
|
95
|
-
offset,
|
|
96
|
-
limit,
|
|
117
|
+
offset: pauseOffset,
|
|
118
|
+
limit: pauseLimit,
|
|
97
119
|
total: analysis.candidates.length,
|
|
98
|
-
items: analysis.candidates.slice(
|
|
120
|
+
items: analysis.candidates.slice(pauseOffset, pauseOffset + pauseLimit),
|
|
121
|
+
},
|
|
122
|
+
fillers: {
|
|
123
|
+
offset: fillerOffset,
|
|
124
|
+
limit: fillerLimit,
|
|
125
|
+
total: fillers.length,
|
|
126
|
+
items: fillers.slice(fillerOffset, fillerOffset + fillerLimit),
|
|
127
|
+
},
|
|
128
|
+
repetitions: {
|
|
129
|
+
offset: repetitionOffset,
|
|
130
|
+
limit: repetitionLimit,
|
|
131
|
+
total: repetitions.length,
|
|
132
|
+
items: repetitions.slice(repetitionOffset, repetitionOffset + repetitionLimit),
|
|
99
133
|
},
|
|
100
134
|
};
|
|
135
|
+
if (params.includeTranscriptText) details.transcriptText = analysis.text;
|
|
101
136
|
if (params.includeMediaOperation) {
|
|
102
137
|
await assertProjectSourcesUnchanged(ctx.cwd, project);
|
|
103
138
|
await assertSnapshotAssetsUnchanged(ctx.cwd, snapshot);
|
package/package.json
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
description: Tighten a talking-head video without destroying natural speech rhythm
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Use the `talking-head-edit` skill to
|
|
5
|
+
Use the `talking-head-edit` skill to review full-sentence context, word-level pauses, filler words, adjacent repetitions, and low-confidence delivery cues; propose a conservative cut strategy, optionally place justified B-roll, and export a generic pi-media EDL for this request: $@
|
|
6
6
|
|
|
7
7
|
Show the proposed rhythm before writing a new revision. Never overwrite the source or an existing render.
|
|
@@ -9,10 +9,11 @@ Use `pi-speech` for word evidence, this package for editorial decisions, and `pi
|
|
|
9
9
|
|
|
10
10
|
1. Obtain a `pi-speech`-compatible transcript JSON containing word-level `beginMs` and `endMs`. Do not infer frame-accurate cuts from sentence text alone.
|
|
11
11
|
2. Call `talking_head_create` with the source video and transcript. Keep the default 500ms cut threshold, 50ms head padding, and 80ms tail padding unless the user requests a different rhythm.
|
|
12
|
-
3. Inspect
|
|
13
|
-
4.
|
|
14
|
-
5.
|
|
15
|
-
6.
|
|
12
|
+
3. Inspect bounded `talking_head_get` pages. Review the full sentence context, filler candidates, and repetition candidates together with pauses. Treat `safe` and every recommendation as evidence, not an instruction. Preserve pauses that carry emphasis, emotion, topic boundaries, or a deliberate breath.
|
|
13
|
+
4. Never delete a filler token automatically. Decide whether `啊`, `额`, `嗯`, or a repeated token is a false start, a discourse marker, or intentional emphasis from its sentence context. A text-only delivery cue is low-confidence evidence; review audio and picture before relying on emotion or performance intent.
|
|
14
|
+
5. Before changing the revision, summarize the proposed rhythm: what words and gaps will be removed, which pauses or fillers will remain, and why. Get the user's approval unless they explicitly delegated editorial judgment.
|
|
15
|
+
6. Call `talking_head_apply` using exact word-boundary A-roll ranges. Every B-roll window must have a concrete visual purpose in `reason`; keep `audio: keep-primary`.
|
|
16
|
+
7. Create/read a `pi-media` project for the same source. Pass the returned `mediaOperation` unchanged to `edit_apply`, then `render` the exact new revision and call `review`.
|
|
16
17
|
|
|
17
18
|
Never overwrite source media or outputs. If a revision conflict occurs, re-read both projects and reconcile intent. If the source, transcript, or B-roll hash changed, stop and ask whether to create a new project rather than silently adopting new bytes.
|
|
18
19
|
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
- Prefer one clean removal over many micro-cuts. Dense sub-150ms cuts create robotic cadence and visible jump cuts.
|
|
9
9
|
- `pi-media` adds a 30ms audio fade at every primary-segment edge to suppress clicks. This does not repair a semantically bad cut.
|
|
10
10
|
|
|
11
|
+
## Editing spoken content
|
|
12
|
+
|
|
13
|
+
- Read the complete sentence before removing a filler or repeated word. `啊`, `嗯`, `那个`, and `就是` can carry hesitation, emphasis, transition, or actual meaning.
|
|
14
|
+
- Treat transcript punctuation as a low-confidence delivery hint, not acoustic emotion detection. Confirm expressive decisions against the source audio and picture.
|
|
15
|
+
- Prefer removing a complete false start at word boundaries over deleting an isolated sound that leaves an unnatural join.
|
|
16
|
+
- If a necessary content cut creates a hard join, preserve room tone and use a short transition or justified B-roll rather than compressing every gap.
|
|
17
|
+
|
|
11
18
|
## Using B-roll
|
|
12
19
|
|
|
13
20
|
- Place B-roll on the output timeline after A-roll cuts have stabilized.
|
package/src/contracts.ts
CHANGED
|
@@ -28,6 +28,57 @@ export interface PauseCandidate {
|
|
|
28
28
|
classification: PauseClassification;
|
|
29
29
|
beforeText: string;
|
|
30
30
|
afterText: string;
|
|
31
|
+
boundary: "within-sentence" | "between-sentences";
|
|
32
|
+
context: {
|
|
33
|
+
before: string;
|
|
34
|
+
after: string;
|
|
35
|
+
};
|
|
36
|
+
adjacentFillerIds: string[];
|
|
37
|
+
recommendation: EditorialRecommendation;
|
|
38
|
+
reasons: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type EditorialRecommendation = "cut" | "review" | "keep";
|
|
42
|
+
export type DeliveryCue = "neutral" | "hesitation" | "emphasis" | "question";
|
|
43
|
+
|
|
44
|
+
export interface SentenceAnalysis {
|
|
45
|
+
sentenceIndex: number;
|
|
46
|
+
sentenceId: number;
|
|
47
|
+
beginMs: number;
|
|
48
|
+
endMs: number;
|
|
49
|
+
text: string;
|
|
50
|
+
wordStartIndex: number;
|
|
51
|
+
wordEndIndex: number;
|
|
52
|
+
deliveryCues: DeliveryCue[];
|
|
53
|
+
confidence: "low";
|
|
54
|
+
evidence: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FillerCandidate {
|
|
58
|
+
id: string;
|
|
59
|
+
wordIndex: number;
|
|
60
|
+
sentenceIndex: number;
|
|
61
|
+
text: string;
|
|
62
|
+
startMs: number;
|
|
63
|
+
endMs: number;
|
|
64
|
+
kind: "hesitation" | "discourse";
|
|
65
|
+
matchConfidence: "exact" | "contextual";
|
|
66
|
+
recommendation: "review";
|
|
67
|
+
contextText: string;
|
|
68
|
+
reasons: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RepetitionCandidate {
|
|
72
|
+
id: string;
|
|
73
|
+
sentenceIndex: number;
|
|
74
|
+
text: string;
|
|
75
|
+
firstWordIndex: number;
|
|
76
|
+
secondWordIndex: number;
|
|
77
|
+
startMs: number;
|
|
78
|
+
endMs: number;
|
|
79
|
+
recommendation: "review";
|
|
80
|
+
contextText: string;
|
|
81
|
+
reasons: string[];
|
|
31
82
|
}
|
|
32
83
|
|
|
33
84
|
export interface ArollSegment {
|
|
@@ -53,7 +104,12 @@ export interface BrollPlacement {
|
|
|
53
104
|
export type BrollPlacementInput = Omit<BrollPlacement, "assetBytes" | "assetSha256">;
|
|
54
105
|
|
|
55
106
|
export interface TranscriptAnalysis {
|
|
107
|
+
schemaVersion: 2;
|
|
108
|
+
text: string;
|
|
56
109
|
words: TranscriptWord[];
|
|
110
|
+
sentences: SentenceAnalysis[];
|
|
111
|
+
fillers: FillerCandidate[];
|
|
112
|
+
repetitions: RepetitionCandidate[];
|
|
57
113
|
candidates: PauseCandidate[];
|
|
58
114
|
segments: ArollSegment[];
|
|
59
115
|
outputDurationMs: number;
|
package/src/project.ts
CHANGED
|
@@ -5,10 +5,15 @@ import type {
|
|
|
5
5
|
ArollSegment,
|
|
6
6
|
BrollPlacement,
|
|
7
7
|
BrollPlacementInput,
|
|
8
|
+
FillerCandidate,
|
|
9
|
+
PauseCandidate,
|
|
10
|
+
RepetitionCandidate,
|
|
11
|
+
SentenceAnalysis,
|
|
8
12
|
TalkingHeadPolicy,
|
|
9
13
|
TalkingHeadProject,
|
|
10
14
|
TalkingHeadSnapshot,
|
|
11
15
|
TranscriptAnalysis,
|
|
16
|
+
TranscriptWord,
|
|
12
17
|
WordTranscript,
|
|
13
18
|
} from "./contracts.ts";
|
|
14
19
|
import { analyzeTranscript, DEFAULT_POLICY, timelineDuration } from "./transcript.ts";
|
|
@@ -111,10 +116,15 @@ async function validateTimeline(
|
|
|
111
116
|
function summary(analysis: TranscriptAnalysis) {
|
|
112
117
|
return {
|
|
113
118
|
wordCount: analysis.words.length,
|
|
119
|
+
sentenceCount: analysis.sentences.length,
|
|
120
|
+
fillerCount: analysis.fillers.length,
|
|
121
|
+
repetitionCount: analysis.repetitions.length,
|
|
114
122
|
pauseCount: analysis.candidates.length,
|
|
115
123
|
safePauses: analysis.candidates.filter((candidate) => candidate.classification === "safe").length,
|
|
116
124
|
reviewPauses: analysis.candidates.filter((candidate) => candidate.classification === "review").length,
|
|
117
125
|
unsafePauses: analysis.candidates.filter((candidate) => candidate.classification === "unsafe").length,
|
|
126
|
+
automaticCutPauses: analysis.candidates.filter((candidate) => candidate.recommendation === "cut").length,
|
|
127
|
+
editorialReviewPauses: analysis.candidates.filter((candidate) => candidate.recommendation === "review").length,
|
|
118
128
|
defaultSegmentCount: analysis.segments.length,
|
|
119
129
|
defaultOutputDurationMs: analysis.outputDurationMs,
|
|
120
130
|
};
|
|
@@ -261,7 +271,60 @@ export async function applyTimeline(cwd: string, input: ApplyTimelineInput): Pro
|
|
|
261
271
|
|
|
262
272
|
export async function getAnalysis(cwd: string, project: TalkingHeadProject): Promise<TranscriptAnalysis> {
|
|
263
273
|
const absolute = await resolveExistingWorkspaceFile(cwd, project.analysisPath);
|
|
264
|
-
|
|
274
|
+
const persisted = await readJson<{
|
|
275
|
+
schemaVersion?: number;
|
|
276
|
+
text?: string;
|
|
277
|
+
words: TranscriptWord[];
|
|
278
|
+
sentences?: SentenceAnalysis[];
|
|
279
|
+
fillers?: FillerCandidate[];
|
|
280
|
+
repetitions?: RepetitionCandidate[];
|
|
281
|
+
candidates: Array<Partial<PauseCandidate> & Pick<PauseCandidate, "id" | "startMs" | "endMs" | "durationMs" | "classification" | "beforeText" | "afterText">>;
|
|
282
|
+
segments: ArollSegment[];
|
|
283
|
+
outputDurationMs: number;
|
|
284
|
+
}>(absolute, `talking-head analysis ${project.projectId}`);
|
|
285
|
+
if (!Array.isArray(persisted.words) || !Array.isArray(persisted.candidates)
|
|
286
|
+
|| !Array.isArray(persisted.segments) || !Number.isFinite(persisted.outputDurationMs)) {
|
|
287
|
+
throw new Error(`Invalid talking-head analysis: ${project.projectId}`);
|
|
288
|
+
}
|
|
289
|
+
const completeV2 = persisted.schemaVersion === 2
|
|
290
|
+
&& typeof persisted.text === "string"
|
|
291
|
+
&& Array.isArray(persisted.sentences)
|
|
292
|
+
&& Array.isArray(persisted.fillers)
|
|
293
|
+
&& Array.isArray(persisted.repetitions);
|
|
294
|
+
if (!completeV2) {
|
|
295
|
+
const currentTranscript = await snapshotFile(cwd, project.transcript.path);
|
|
296
|
+
if (currentTranscript.sha256 !== project.transcript.sha256 || currentTranscript.bytes !== project.transcript.bytes) {
|
|
297
|
+
throw new Error(`Transcript changed before legacy analysis migration: ${project.transcript.path}`);
|
|
298
|
+
}
|
|
299
|
+
const transcriptAbsolute = await resolveExistingWorkspaceFile(cwd, project.transcript.path);
|
|
300
|
+
const transcriptPayload = await readJson<WordTranscript>(transcriptAbsolute, "word transcript");
|
|
301
|
+
const { snapshot } = await getTalkingHeadProject(cwd, project.projectId);
|
|
302
|
+
return analyzeTranscript(transcriptPayload, snapshot.policy);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
schemaVersion: 2,
|
|
306
|
+
text: persisted.text ?? "",
|
|
307
|
+
words: persisted.words,
|
|
308
|
+
sentences: persisted.sentences ?? [],
|
|
309
|
+
fillers: persisted.fillers ?? [],
|
|
310
|
+
repetitions: persisted.repetitions ?? [],
|
|
311
|
+
candidates: persisted.candidates.map((candidate) => ({
|
|
312
|
+
id: candidate.id,
|
|
313
|
+
startMs: candidate.startMs,
|
|
314
|
+
endMs: candidate.endMs,
|
|
315
|
+
durationMs: candidate.durationMs,
|
|
316
|
+
classification: candidate.classification,
|
|
317
|
+
beforeText: candidate.beforeText,
|
|
318
|
+
afterText: candidate.afterText,
|
|
319
|
+
boundary: candidate.boundary ?? "within-sentence",
|
|
320
|
+
context: candidate.context ?? { before: candidate.beforeText, after: candidate.afterText },
|
|
321
|
+
adjacentFillerIds: candidate.adjacentFillerIds ?? [],
|
|
322
|
+
recommendation: candidate.recommendation ?? "review",
|
|
323
|
+
reasons: candidate.reasons ?? ["Legacy pause analysis requires editorial review before cutting."],
|
|
324
|
+
})),
|
|
325
|
+
segments: persisted.segments,
|
|
326
|
+
outputDurationMs: persisted.outputDurationMs,
|
|
327
|
+
};
|
|
265
328
|
}
|
|
266
329
|
|
|
267
330
|
export async function assertProjectSourcesUnchanged(cwd: string, project: TalkingHeadProject): Promise<void> {
|
package/src/transcript.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ArollSegment,
|
|
3
|
+
FillerCandidate,
|
|
3
4
|
PauseCandidate,
|
|
4
5
|
PauseClassification,
|
|
6
|
+
RepetitionCandidate,
|
|
7
|
+
SentenceAnalysis,
|
|
5
8
|
TalkingHeadPolicy,
|
|
6
9
|
TranscriptAnalysis,
|
|
7
10
|
TranscriptWord,
|
|
8
11
|
WordTranscript,
|
|
9
12
|
} from "./contracts.ts";
|
|
10
13
|
|
|
14
|
+
const HESITATION_FILLERS = new Set(["嗯", "呃", "额", "唔", "呣", "em", "um", "uh"]);
|
|
15
|
+
const CONTEXTUAL_FILLERS = new Set(["啊", "呀", "那个", "这个", "就是", "然后"]);
|
|
16
|
+
|
|
11
17
|
export const DEFAULT_POLICY: TalkingHeadPolicy = {
|
|
12
18
|
cutThresholdMs: 500,
|
|
13
19
|
headPaddingMs: 50,
|
|
@@ -40,7 +46,17 @@ function flattenWords(transcript: WordTranscript): TranscriptWord[] {
|
|
|
40
46
|
throw new Error("Transcript must contain text and sentences");
|
|
41
47
|
}
|
|
42
48
|
const words = transcript.sentences.flatMap((sentence) => {
|
|
43
|
-
if (!sentence || !
|
|
49
|
+
if (!sentence || !Number.isInteger(sentence.id) || typeof sentence.text !== "string"
|
|
50
|
+
|| !finiteNonNegative(sentence.beginMs) || !finiteNonNegative(sentence.endMs)
|
|
51
|
+
|| sentence.endMs <= sentence.beginMs || !Array.isArray(sentence.words) || sentence.words.length === 0) {
|
|
52
|
+
throw new Error("Transcript contains an invalid sentence timestamp or metadata");
|
|
53
|
+
}
|
|
54
|
+
const first = sentence.words[0]!;
|
|
55
|
+
const last = sentence.words.at(-1)!;
|
|
56
|
+
if (!finiteNonNegative(first.beginMs) || !finiteNonNegative(last.endMs)
|
|
57
|
+
|| first.beginMs < sentence.beginMs || last.endMs > sentence.endMs) {
|
|
58
|
+
throw new Error("Transcript sentence timestamp does not contain its word timestamps");
|
|
59
|
+
}
|
|
44
60
|
return sentence.words;
|
|
45
61
|
});
|
|
46
62
|
if (words.length === 0) throw new Error("Transcript contains no word timestamps");
|
|
@@ -57,14 +73,172 @@ function flattenWords(transcript: WordTranscript): TranscriptWord[] {
|
|
|
57
73
|
return words.map((word) => ({ ...word, punctuation: word.punctuation ?? "" }));
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
function
|
|
76
|
+
function normalizedSpokenText(text: string): string {
|
|
77
|
+
return text.trim().toLowerCase().replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fillerCandidates(transcript: WordTranscript, words: TranscriptWord[]): FillerCandidate[] {
|
|
81
|
+
const candidates: FillerCandidate[] = [];
|
|
82
|
+
let wordIndex = 0;
|
|
83
|
+
for (let sentenceIndex = 0; sentenceIndex < transcript.sentences.length; sentenceIndex += 1) {
|
|
84
|
+
const sentence = transcript.sentences[sentenceIndex]!;
|
|
85
|
+
for (const _word of sentence.words) {
|
|
86
|
+
const word = words[wordIndex]!;
|
|
87
|
+
const normalized = normalizedSpokenText(word.text);
|
|
88
|
+
const hesitation = HESITATION_FILLERS.has(normalized);
|
|
89
|
+
const contextual = CONTEXTUAL_FILLERS.has(normalized);
|
|
90
|
+
if (hesitation || contextual) {
|
|
91
|
+
candidates.push({
|
|
92
|
+
id: `filler-${String(candidates.length + 1).padStart(3, "0")}`,
|
|
93
|
+
wordIndex,
|
|
94
|
+
sentenceIndex,
|
|
95
|
+
text: word.text,
|
|
96
|
+
startMs: word.beginMs,
|
|
97
|
+
endMs: word.endMs,
|
|
98
|
+
kind: hesitation ? "hesitation" : "discourse",
|
|
99
|
+
matchConfidence: hesitation ? "exact" : "contextual",
|
|
100
|
+
recommendation: "review",
|
|
101
|
+
contextText: sentence.text,
|
|
102
|
+
reasons: hesitation
|
|
103
|
+
? ["The token commonly marks hesitation, but may still carry delivery intent."]
|
|
104
|
+
: ["The token can be either a filler or meaningful discourse, so context is required."],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
wordIndex += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return candidates;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sentenceAnalyses(transcript: WordTranscript, fillers: FillerCandidate[]): SentenceAnalysis[] {
|
|
114
|
+
let wordStartIndex = 0;
|
|
115
|
+
const fillersBySentence = new Map<number, FillerCandidate[]>();
|
|
116
|
+
for (const filler of fillers) {
|
|
117
|
+
const current = fillersBySentence.get(filler.sentenceIndex) ?? [];
|
|
118
|
+
current.push(filler);
|
|
119
|
+
fillersBySentence.set(filler.sentenceIndex, current);
|
|
120
|
+
}
|
|
121
|
+
return transcript.sentences.map((sentence, sentenceIndex) => {
|
|
122
|
+
const wordEndIndex = wordStartIndex + sentence.words.length - 1;
|
|
123
|
+
const sentenceFillers = fillersBySentence.get(sentenceIndex) ?? [];
|
|
124
|
+
const deliveryCues: SentenceAnalysis["deliveryCues"] = [];
|
|
125
|
+
const evidence: string[] = [];
|
|
126
|
+
const hesitationCount = sentenceFillers.filter((candidate) => candidate.kind === "hesitation").length;
|
|
127
|
+
if (hesitationCount > 0) {
|
|
128
|
+
deliveryCues.push("hesitation");
|
|
129
|
+
evidence.push(`Contains ${hesitationCount} hesitation-lexicon token(s).`);
|
|
130
|
+
}
|
|
131
|
+
if (/[??]/u.test(sentence.text) || sentence.words.some((word) => /[??]/u.test(word.punctuation ?? ""))) {
|
|
132
|
+
deliveryCues.push("question");
|
|
133
|
+
evidence.push("Question punctuation is present in the transcript.");
|
|
134
|
+
}
|
|
135
|
+
if (/[!!]/u.test(sentence.text) || sentence.words.some((word) => /[!!]/u.test(word.punctuation ?? ""))) {
|
|
136
|
+
deliveryCues.push("emphasis");
|
|
137
|
+
evidence.push("Emphasis punctuation is present in the transcript.");
|
|
138
|
+
}
|
|
139
|
+
if (deliveryCues.length === 0) {
|
|
140
|
+
deliveryCues.push("neutral");
|
|
141
|
+
evidence.push("No explicit delivery cue was found in transcript text.");
|
|
142
|
+
}
|
|
143
|
+
const analysis: SentenceAnalysis = {
|
|
144
|
+
sentenceIndex,
|
|
145
|
+
sentenceId: sentence.id,
|
|
146
|
+
beginMs: sentence.beginMs,
|
|
147
|
+
endMs: sentence.endMs,
|
|
148
|
+
text: sentence.text,
|
|
149
|
+
wordStartIndex,
|
|
150
|
+
wordEndIndex,
|
|
151
|
+
deliveryCues,
|
|
152
|
+
confidence: "low",
|
|
153
|
+
evidence,
|
|
154
|
+
};
|
|
155
|
+
wordStartIndex = wordEndIndex + 1;
|
|
156
|
+
return analysis;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function repetitionCandidates(transcript: WordTranscript, words: TranscriptWord[]): RepetitionCandidate[] {
|
|
161
|
+
const candidates: RepetitionCandidate[] = [];
|
|
162
|
+
let sentenceWordStart = 0;
|
|
163
|
+
for (let sentenceIndex = 0; sentenceIndex < transcript.sentences.length; sentenceIndex += 1) {
|
|
164
|
+
const sentence = transcript.sentences[sentenceIndex]!;
|
|
165
|
+
for (let offset = 1; offset < sentence.words.length; offset += 1) {
|
|
166
|
+
const firstWordIndex = sentenceWordStart + offset - 1;
|
|
167
|
+
const secondWordIndex = sentenceWordStart + offset;
|
|
168
|
+
const first = words[firstWordIndex]!;
|
|
169
|
+
const second = words[secondWordIndex]!;
|
|
170
|
+
const normalized = normalizedSpokenText(first.text);
|
|
171
|
+
if (!normalized || normalized !== normalizedSpokenText(second.text)) continue;
|
|
172
|
+
candidates.push({
|
|
173
|
+
id: `repetition-${String(candidates.length + 1).padStart(3, "0")}`,
|
|
174
|
+
sentenceIndex,
|
|
175
|
+
text: first.text,
|
|
176
|
+
firstWordIndex,
|
|
177
|
+
secondWordIndex,
|
|
178
|
+
startMs: first.beginMs,
|
|
179
|
+
endMs: second.endMs,
|
|
180
|
+
recommendation: "review",
|
|
181
|
+
contextText: sentence.text,
|
|
182
|
+
reasons: ["Two adjacent normalized tokens are identical; review whether this is a false start or intentional emphasis."],
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
sentenceWordStart += sentence.words.length;
|
|
186
|
+
}
|
|
187
|
+
return candidates;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function wordSentenceIndexes(transcript: WordTranscript): number[] {
|
|
191
|
+
return transcript.sentences.flatMap((sentence, sentenceIndex) => sentence.words.map(() => sentenceIndex));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function candidatesFrom(
|
|
195
|
+
words: TranscriptWord[],
|
|
196
|
+
transcript: WordTranscript,
|
|
197
|
+
fillers: FillerCandidate[],
|
|
198
|
+
policy: TalkingHeadPolicy,
|
|
199
|
+
): PauseCandidate[] {
|
|
61
200
|
const candidates: PauseCandidate[] = [];
|
|
201
|
+
const sentenceIndexes = wordSentenceIndexes(transcript);
|
|
202
|
+
const fillersByWordIndex = new Map<number, FillerCandidate[]>();
|
|
203
|
+
for (const filler of fillers) {
|
|
204
|
+
fillersByWordIndex.set(filler.wordIndex, [...(fillersByWordIndex.get(filler.wordIndex) ?? []), filler]);
|
|
205
|
+
}
|
|
62
206
|
for (let index = 1; index < words.length; index += 1) {
|
|
63
207
|
const before = words[index - 1];
|
|
64
208
|
const after = words[index];
|
|
65
209
|
if (!before || !after) continue;
|
|
66
210
|
const durationMs = after.beginMs - before.endMs;
|
|
67
211
|
if (durationMs <= 0) continue;
|
|
212
|
+
const beforeSentenceIndex = sentenceIndexes[index - 1]!;
|
|
213
|
+
const afterSentenceIndex = sentenceIndexes[index]!;
|
|
214
|
+
const adjacentFillers = [
|
|
215
|
+
...(fillersByWordIndex.get(index - 1) ?? []),
|
|
216
|
+
...(fillersByWordIndex.get(index) ?? []),
|
|
217
|
+
];
|
|
218
|
+
const sentenceBoundary = beforeSentenceIndex !== afterSentenceIndex;
|
|
219
|
+
const beforeSentence = transcript.sentences[beforeSentenceIndex]!;
|
|
220
|
+
const expressiveBoundary = /[!!??…]/u.test(before.punctuation) || /[!!??…]\s*$/u.test(beforeSentence.text);
|
|
221
|
+
const reasons: string[] = [];
|
|
222
|
+
let recommendation: PauseCandidate["recommendation"];
|
|
223
|
+
if (durationMs < 150) {
|
|
224
|
+
recommendation = "keep";
|
|
225
|
+
reasons.push("The gap is shorter than 150ms and removing it risks robotic cadence.");
|
|
226
|
+
} else if (adjacentFillers.length > 0) {
|
|
227
|
+
recommendation = "review";
|
|
228
|
+
reasons.push("The gap touches a possible filler whose meaning must be judged in sentence context.");
|
|
229
|
+
} else if (expressiveBoundary) {
|
|
230
|
+
recommendation = "review";
|
|
231
|
+
reasons.push("The pause follows expressive punctuation and may carry emphasis, emotion, or a question beat.");
|
|
232
|
+
} else if (sentenceBoundary) {
|
|
233
|
+
recommendation = "review";
|
|
234
|
+
reasons.push("The gap is between sentences and may mark a paragraph, topic, or deliberate delivery boundary.");
|
|
235
|
+
} else if (durationMs >= policy.cutThresholdMs) {
|
|
236
|
+
recommendation = "cut";
|
|
237
|
+
reasons.push(`The unprotected word gap meets the ${policy.cutThresholdMs}ms automatic cut threshold.`);
|
|
238
|
+
} else {
|
|
239
|
+
recommendation = "review";
|
|
240
|
+
reasons.push("The gap is noticeable but does not meet the automatic cut threshold.");
|
|
241
|
+
}
|
|
68
242
|
candidates.push({
|
|
69
243
|
id: `pause-${String(candidates.length + 1).padStart(3, "0")}`,
|
|
70
244
|
startMs: before.endMs,
|
|
@@ -73,18 +247,28 @@ function candidatesFrom(words: TranscriptWord[]): PauseCandidate[] {
|
|
|
73
247
|
classification: classifyPause(durationMs),
|
|
74
248
|
beforeText: `${before.text}${before.punctuation}`,
|
|
75
249
|
afterText: after.text,
|
|
250
|
+
boundary: beforeSentenceIndex === afterSentenceIndex ? "within-sentence" : "between-sentences",
|
|
251
|
+
context: {
|
|
252
|
+
before: transcript.sentences[beforeSentenceIndex]!.text,
|
|
253
|
+
after: transcript.sentences[afterSentenceIndex]!.text,
|
|
254
|
+
},
|
|
255
|
+
adjacentFillerIds: adjacentFillers.map((candidate) => candidate.id),
|
|
256
|
+
recommendation,
|
|
257
|
+
reasons,
|
|
76
258
|
});
|
|
77
259
|
}
|
|
78
260
|
return candidates;
|
|
79
261
|
}
|
|
80
262
|
|
|
81
|
-
function defaultSegments(words: TranscriptWord[], policy: TalkingHeadPolicy): ArollSegment[] {
|
|
263
|
+
function defaultSegments(words: TranscriptWord[], policy: TalkingHeadPolicy, candidates: PauseCandidate[]): ArollSegment[] {
|
|
82
264
|
const segments: ArollSegment[] = [];
|
|
265
|
+
const candidateByGap = new Map(candidates.map((candidate) => [`${candidate.startMs}:${candidate.endMs}`, candidate]));
|
|
83
266
|
let segmentStart = Math.max(0, words[0]!.beginMs - policy.headPaddingMs);
|
|
84
267
|
for (let index = 1; index < words.length; index += 1) {
|
|
85
268
|
const before = words[index - 1]!;
|
|
86
269
|
const after = words[index]!;
|
|
87
|
-
|
|
270
|
+
const candidate = candidateByGap.get(`${before.endMs}:${after.beginMs}`);
|
|
271
|
+
if (candidate?.recommendation !== "cut") continue;
|
|
88
272
|
segments.push({
|
|
89
273
|
id: `a-${String(segments.length + 1).padStart(3, "0")}`,
|
|
90
274
|
sourceStartMs: segmentStart,
|
|
@@ -111,10 +295,18 @@ export function analyzeTranscript(
|
|
|
111
295
|
const policy = { ...DEFAULT_POLICY, ...overrides };
|
|
112
296
|
validatePolicy(policy);
|
|
113
297
|
const words = flattenWords(transcript);
|
|
114
|
-
const
|
|
298
|
+
const fillers = fillerCandidates(transcript, words);
|
|
299
|
+
const repetitions = repetitionCandidates(transcript, words);
|
|
300
|
+
const candidates = candidatesFrom(words, transcript, fillers, policy);
|
|
301
|
+
const segments = defaultSegments(words, policy, candidates);
|
|
115
302
|
return {
|
|
303
|
+
schemaVersion: 2,
|
|
304
|
+
text: transcript.text,
|
|
116
305
|
words,
|
|
117
|
-
|
|
306
|
+
sentences: sentenceAnalyses(transcript, fillers),
|
|
307
|
+
fillers,
|
|
308
|
+
repetitions,
|
|
309
|
+
candidates,
|
|
118
310
|
segments,
|
|
119
311
|
outputDurationMs: timelineDuration(segments),
|
|
120
312
|
};
|