@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/README.md +74 -23
- package/extensions/talking-head/index.ts +142 -10
- package/package.json +1 -1
- package/prompts/edit-talking-head.md +1 -1
- package/skills/talking-head-edit/SKILL.md +13 -4
- package/skills/talking-head-edit/references/cut-craft.md +11 -0
- package/src/broll.ts +406 -0
- package/src/contracts.ts +119 -1
- package/src/project.ts +73 -6
- package/src/transcript.ts +198 -6
- package/src/workspace.ts +18 -4
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
|
};
|
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
|
}
|