@speclip/pi-talking-head 0.1.1 → 0.1.3

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/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
  };
package/src/workspace.ts CHANGED
@@ -39,6 +39,17 @@ export async function resolveExistingWorkspaceFile(cwd: string, inputPath: strin
39
39
  return canonical;
40
40
  }
41
41
 
42
+ export async function resolveExistingWorkspaceDirectory(cwd: string, inputPath: string): Promise<string> {
43
+ const root = await workspaceRoot(cwd);
44
+ const lexical = resolve(root, inputPath);
45
+ if (!isWithin(root, lexical)) throw new Error(`Path is outside the workspace: ${inputPath}`);
46
+ if ((await lstat(lexical)).isSymbolicLink()) throw new Error(`Unsafe workspace-directory symlink: ${inputPath}`);
47
+ const canonical = await realpath(lexical);
48
+ if (!isWithin(root, canonical)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
49
+ if (!(await stat(canonical)).isDirectory()) throw new Error(`Path is not a directory: ${inputPath}`);
50
+ return canonical;
51
+ }
52
+
42
53
  export async function resolveWorkspacePath(cwd: string, inputPath: string): Promise<string> {
43
54
  const root = await workspaceRoot(cwd);
44
55
  const lexical = resolve(root, inputPath);
@@ -55,17 +66,20 @@ export async function workspaceRelativePath(cwd: string, absolutePath: string):
55
66
  return relative(root, absolutePath).split(sep).join("/");
56
67
  }
57
68
 
58
- async function sha256File(path: string): Promise<string> {
69
+ async function sha256File(path: string, signal?: AbortSignal): Promise<string> {
70
+ signal?.throwIfAborted();
59
71
  const hash = createHash("sha256");
60
- for await (const chunk of createReadStream(path)) hash.update(chunk);
72
+ for await (const chunk of createReadStream(path, signal === undefined ? {} : { signal })) hash.update(chunk);
73
+ signal?.throwIfAborted();
61
74
  return hash.digest("hex");
62
75
  }
63
76
 
64
- export async function snapshotFile(cwd: string, inputPath: string): Promise<FileRef> {
77
+ export async function snapshotFile(cwd: string, inputPath: string, signal?: AbortSignal): Promise<FileRef> {
78
+ signal?.throwIfAborted();
65
79
  const absolute = await resolveExistingWorkspaceFile(cwd, inputPath);
66
80
  return {
67
81
  path: await workspaceRelativePath(cwd, absolute),
68
82
  bytes: (await stat(absolute)).size,
69
- sha256: await sha256File(absolute),
83
+ sha256: await sha256File(absolute, signal),
70
84
  };
71
85
  }