@speclip/pi-talking-head 0.1.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @speclip/pi-talking-head
2
2
 
3
- 给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳,识别词间停顿,生成保守的 A-roll 剪辑方案,并可加入 B-roll 规划。
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 的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
45
+ 默认只自动移除至少 500ms、且没有语气词或表达边界保护信号的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
46
46
 
47
47
  - `safe`:至少 400ms,通常可以切。
48
48
  - `review`:150–399ms,必须结合语义和画面判断。
49
49
  - `unsafe`:少于 150ms,默认不切。
50
50
 
51
- 工具返回 revision 1、停顿摘要和可直接交给 `pi-media.edit_apply` 的 `mediaOperation`。
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
- 只有调用这个工具时才会把候选停顿放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
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,分析停顿并生成默认 EDL |
118
- | `talking_head_get` | 读取指定 revision,分页返回停顿候选,可选导出 pi-media EDL |
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
- 0.1.0 不负责语音转录、素材搜索、字幕、画面理解或渲染。它只提供稳定的口播时间线决策层。B-roll 的搜索与视觉匹配可以后续新增为独立 Skill 或 Extension,最终仍写入同一份 `broll` 数据并由 `pi-media` 渲染。
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 a bounded page of pause candidates. Optionally include the generic pi-media timeline operation for that revision.",
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 offset = params.pauseOffset ?? 0;
90
- const limit = params.pauseLimit ?? 50;
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(offset, offset + limit),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speclip/pi-talking-head",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pause-aware talking-head editing and B-roll planning for Pi, exported as generic pi-media EDLs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 analyze word-level pauses, propose a conservative cut strategy, optionally place justified B-roll, and export a generic pi-media EDL for this request: $@
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 pause candidates with bounded `talking_head_get` pages. Treat `safe` as a candidate, not an instruction. Preserve pauses that carry emphasis, emotion, topic boundaries, or a deliberate breath.
13
- 4. Before changing the revision, summarize the proposed rhythm: what will be removed, which short pauses will remain, and why. Get the user's approval unless they explicitly delegated editorial judgment.
14
- 5. 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`.
15
- 6. 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`.
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
- return await readJson<TranscriptAnalysis>(absolute, `talking-head analysis ${project.projectId}`);
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 || !Array.isArray(sentence.words)) throw new Error("Transcript sentence is missing words");
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 candidatesFrom(words: TranscriptWord[]): PauseCandidate[] {
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
- if (after.beginMs - before.endMs < policy.cutThresholdMs) continue;
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 segments = defaultSegments(words, policy);
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
- candidates: candidatesFrom(words),
306
+ sentences: sentenceAnalyses(transcript, fillers),
307
+ fillers,
308
+ repetitions,
309
+ candidates,
118
310
  segments,
119
311
  outputDurationMs: timelineDuration(segments),
120
312
  };