@hustle-together/api-dev-tools 2.0.3 → 2.0.5
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/demo/audio/audio-sync.js +295 -0
- package/demo/audio/generate-narration.js +422 -0
- package/demo/audio/narration-timing.json +3614 -0
- package/demo/audio/narration-timing.sample.json +48 -0
- package/demo/audio/narration.mp3 +0 -0
- package/demo/hustle-together/index.html +4 -4
- package/demo/workflow-demo.html +324 -1
- package/package.json +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Sync Controller for Workflow Demo
|
|
3
|
+
*
|
|
4
|
+
* This module syncs audio narration with GSAP animations.
|
|
5
|
+
* It uses word-level timestamps to trigger highlights at the right moment.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const sync = new AudioSyncController('audio/narration.mp3', 'audio/narration-timing.json');
|
|
9
|
+
* await sync.init();
|
|
10
|
+
* sync.play();
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class AudioSyncController {
|
|
14
|
+
constructor(audioUrl, timingUrl) {
|
|
15
|
+
this.audioUrl = audioUrl;
|
|
16
|
+
this.timingUrl = timingUrl;
|
|
17
|
+
this.audio = null;
|
|
18
|
+
this.timing = null;
|
|
19
|
+
this.currentSection = null;
|
|
20
|
+
this.highlightedElements = new Set();
|
|
21
|
+
this.isPlaying = false;
|
|
22
|
+
this.onSectionChange = null;
|
|
23
|
+
this.onHighlight = null;
|
|
24
|
+
this.onWordSpoken = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Initialize the controller by loading audio and timing data
|
|
29
|
+
*/
|
|
30
|
+
async init() {
|
|
31
|
+
// Load timing data
|
|
32
|
+
const response = await fetch(this.timingUrl);
|
|
33
|
+
this.timing = await response.json();
|
|
34
|
+
|
|
35
|
+
// Create audio element
|
|
36
|
+
this.audio = new Audio(this.audioUrl);
|
|
37
|
+
this.audio.preload = 'auto';
|
|
38
|
+
|
|
39
|
+
// Set up event listeners
|
|
40
|
+
this.audio.addEventListener('timeupdate', () => this.onTimeUpdate());
|
|
41
|
+
this.audio.addEventListener('ended', () => this.onEnded());
|
|
42
|
+
this.audio.addEventListener('play', () => { this.isPlaying = true; });
|
|
43
|
+
this.audio.addEventListener('pause', () => { this.isPlaying = false; });
|
|
44
|
+
|
|
45
|
+
// Wait for audio to be ready
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
this.audio.addEventListener('canplaythrough', () => resolve(), { once: true });
|
|
48
|
+
this.audio.addEventListener('error', (e) => reject(e), { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the current playback time
|
|
54
|
+
*/
|
|
55
|
+
get currentTime() {
|
|
56
|
+
return this.audio ? this.audio.currentTime : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the total duration
|
|
61
|
+
*/
|
|
62
|
+
get duration() {
|
|
63
|
+
return this.audio ? this.audio.duration : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Play the audio
|
|
68
|
+
*/
|
|
69
|
+
play() {
|
|
70
|
+
if (this.audio) {
|
|
71
|
+
this.audio.play();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pause the audio
|
|
77
|
+
*/
|
|
78
|
+
pause() {
|
|
79
|
+
if (this.audio) {
|
|
80
|
+
this.audio.pause();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Toggle play/pause
|
|
86
|
+
*/
|
|
87
|
+
toggle() {
|
|
88
|
+
if (this.isPlaying) {
|
|
89
|
+
this.pause();
|
|
90
|
+
} else {
|
|
91
|
+
this.play();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Seek to a specific time
|
|
97
|
+
*/
|
|
98
|
+
seek(time) {
|
|
99
|
+
if (this.audio) {
|
|
100
|
+
this.audio.currentTime = time;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Seek to a specific section
|
|
106
|
+
*/
|
|
107
|
+
seekToSection(sectionId) {
|
|
108
|
+
const section = this.timing.sections.find(s => s.id === sectionId);
|
|
109
|
+
if (section) {
|
|
110
|
+
this.seek(section.timestamp);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle time updates from the audio element
|
|
116
|
+
*/
|
|
117
|
+
onTimeUpdate() {
|
|
118
|
+
const currentTime = this.audio.currentTime;
|
|
119
|
+
|
|
120
|
+
// Check for section changes
|
|
121
|
+
const newSection = this.getCurrentSection(currentTime);
|
|
122
|
+
if (newSection !== this.currentSection) {
|
|
123
|
+
this.currentSection = newSection;
|
|
124
|
+
this.clearHighlights();
|
|
125
|
+
if (this.onSectionChange) {
|
|
126
|
+
this.onSectionChange(newSection);
|
|
127
|
+
}
|
|
128
|
+
// Scroll to section
|
|
129
|
+
this.scrollToSection(newSection);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for highlights that should be active
|
|
133
|
+
this.updateHighlights(currentTime);
|
|
134
|
+
|
|
135
|
+
// Check for words being spoken
|
|
136
|
+
if (this.onWordSpoken) {
|
|
137
|
+
const currentWord = this.getCurrentWord(currentTime);
|
|
138
|
+
if (currentWord) {
|
|
139
|
+
this.onWordSpoken(currentWord);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get the current section based on time
|
|
146
|
+
*/
|
|
147
|
+
getCurrentSection(time) {
|
|
148
|
+
let current = null;
|
|
149
|
+
for (const section of this.timing.sections) {
|
|
150
|
+
if (section.timestamp <= time) {
|
|
151
|
+
current = section.id;
|
|
152
|
+
} else {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return current;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the current word being spoken
|
|
161
|
+
*/
|
|
162
|
+
getCurrentWord(time) {
|
|
163
|
+
for (const word of this.timing.words) {
|
|
164
|
+
if (time >= word.start && time <= word.end) {
|
|
165
|
+
return word;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update highlights based on current time
|
|
173
|
+
*/
|
|
174
|
+
updateHighlights(time) {
|
|
175
|
+
// Get highlights that should be active (within 3 seconds of their timestamp)
|
|
176
|
+
const HIGHLIGHT_DURATION = 3; // seconds
|
|
177
|
+
|
|
178
|
+
for (const highlight of this.timing.highlights) {
|
|
179
|
+
const isActive = time >= highlight.timestamp &&
|
|
180
|
+
time < highlight.timestamp + HIGHLIGHT_DURATION;
|
|
181
|
+
|
|
182
|
+
const wasHighlighted = this.highlightedElements.has(highlight.selector);
|
|
183
|
+
|
|
184
|
+
if (isActive && !wasHighlighted) {
|
|
185
|
+
// Add highlight
|
|
186
|
+
this.applyHighlight(highlight.selector);
|
|
187
|
+
this.highlightedElements.add(highlight.selector);
|
|
188
|
+
if (this.onHighlight) {
|
|
189
|
+
this.onHighlight(highlight.selector, true);
|
|
190
|
+
}
|
|
191
|
+
} else if (!isActive && wasHighlighted) {
|
|
192
|
+
// Remove highlight
|
|
193
|
+
this.removeHighlight(highlight.selector);
|
|
194
|
+
this.highlightedElements.delete(highlight.selector);
|
|
195
|
+
if (this.onHighlight) {
|
|
196
|
+
this.onHighlight(highlight.selector, false);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Apply highlight animation to an element
|
|
204
|
+
*/
|
|
205
|
+
applyHighlight(selector) {
|
|
206
|
+
const elements = document.querySelectorAll(selector);
|
|
207
|
+
elements.forEach(el => {
|
|
208
|
+
// Add highlight class
|
|
209
|
+
el.classList.add('audio-highlighted');
|
|
210
|
+
|
|
211
|
+
// Use GSAP for smooth animation if available
|
|
212
|
+
if (typeof gsap !== 'undefined') {
|
|
213
|
+
gsap.to(el, {
|
|
214
|
+
boxShadow: '0 0 30px var(--accent-red-glow), 0 0 60px var(--accent-red-glow)',
|
|
215
|
+
borderColor: 'var(--accent-red)',
|
|
216
|
+
scale: 1.02,
|
|
217
|
+
duration: 0.3,
|
|
218
|
+
ease: 'power2.out'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Remove highlight from an element
|
|
226
|
+
*/
|
|
227
|
+
removeHighlight(selector) {
|
|
228
|
+
const elements = document.querySelectorAll(selector);
|
|
229
|
+
elements.forEach(el => {
|
|
230
|
+
el.classList.remove('audio-highlighted');
|
|
231
|
+
|
|
232
|
+
if (typeof gsap !== 'undefined') {
|
|
233
|
+
gsap.to(el, {
|
|
234
|
+
boxShadow: 'none',
|
|
235
|
+
borderColor: 'var(--grey)',
|
|
236
|
+
scale: 1,
|
|
237
|
+
duration: 0.3,
|
|
238
|
+
ease: 'power2.out'
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clear all highlights
|
|
246
|
+
*/
|
|
247
|
+
clearHighlights() {
|
|
248
|
+
for (const selector of this.highlightedElements) {
|
|
249
|
+
this.removeHighlight(selector);
|
|
250
|
+
}
|
|
251
|
+
this.highlightedElements.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Scroll to a section
|
|
256
|
+
*/
|
|
257
|
+
scrollToSection(sectionId) {
|
|
258
|
+
const element = document.getElementById(sectionId);
|
|
259
|
+
if (element) {
|
|
260
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Handle audio ended
|
|
266
|
+
*/
|
|
267
|
+
onEnded() {
|
|
268
|
+
this.isPlaying = false;
|
|
269
|
+
this.clearHighlights();
|
|
270
|
+
if (this.onSectionChange) {
|
|
271
|
+
this.onSectionChange(null);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get all section timestamps for building a progress bar
|
|
277
|
+
*/
|
|
278
|
+
getSectionMarkers() {
|
|
279
|
+
return this.timing.sections.map(s => ({
|
|
280
|
+
id: s.id,
|
|
281
|
+
timestamp: s.timestamp,
|
|
282
|
+
percentage: (s.timestamp / this.duration) * 100
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Export for use in browser
|
|
288
|
+
if (typeof window !== 'undefined') {
|
|
289
|
+
window.AudioSyncController = AudioSyncController;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Export for Node.js (if needed for testing)
|
|
293
|
+
if (typeof module !== 'undefined') {
|
|
294
|
+
module.exports = AudioSyncController;
|
|
295
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate narration audio with word-level timestamps using ElevenLabs API
|
|
4
|
+
*
|
|
5
|
+
* Usage: ELEVENLABS_API_KEY=your_key node generate-narration.js
|
|
6
|
+
*
|
|
7
|
+
* Output:
|
|
8
|
+
* - narration.mp3 - The audio file
|
|
9
|
+
* - narration-timing.json - Word timestamps with highlight triggers
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// ElevenLabs API configuration
|
|
16
|
+
const API_BASE = 'https://api.elevenlabs.io/v1';
|
|
17
|
+
const VOICE_ID = 'pNInz6obpgDQGcFmaJgB'; // Adam - deep, professional voice
|
|
18
|
+
const MODEL_ID = 'eleven_turbo_v2_5'; // Fast, high-quality model
|
|
19
|
+
|
|
20
|
+
// The narration script with section markers and highlight triggers
|
|
21
|
+
// Format: [SECTION:id] marks a new section, [HIGHLIGHT:element-selector] marks what to highlight
|
|
22
|
+
const NARRATION_SCRIPT = `
|
|
23
|
+
[SECTION:intro]
|
|
24
|
+
Welcome to Hustle API Dev Tools.
|
|
25
|
+
|
|
26
|
+
[HIGHLIGHT:#hustleBrand]
|
|
27
|
+
This package enforces a structured workflow for AI-assisted API development.
|
|
28
|
+
|
|
29
|
+
[HIGHLIGHT:[data-phase="research"]]
|
|
30
|
+
First, you research. No assumptions. No training data. Real documentation.
|
|
31
|
+
|
|
32
|
+
[HIGHLIGHT:[data-phase="interview"]]
|
|
33
|
+
Then you interview. The AI asks YOU questions with structured options based on what it learned.
|
|
34
|
+
|
|
35
|
+
[HIGHLIGHT:[data-phase="test"]]
|
|
36
|
+
Next, you write tests first. Red, green, refactor. No implementation without a failing test.
|
|
37
|
+
|
|
38
|
+
[HIGHLIGHT:[data-phase="code"]]
|
|
39
|
+
Only then do you write code. Minimal. Just enough to pass the tests.
|
|
40
|
+
|
|
41
|
+
[HIGHLIGHT:[data-phase="docs"]]
|
|
42
|
+
Finally, documentation. Every endpoint documented with real examples and schemas.
|
|
43
|
+
|
|
44
|
+
The philosophy is simple: Hustle together. Share resources. Build stronger.
|
|
45
|
+
|
|
46
|
+
[SECTION:problems]
|
|
47
|
+
[HIGHLIGHT:#problems h2]
|
|
48
|
+
Let's talk about the problem. What goes wrong when AI builds APIs without structure?
|
|
49
|
+
|
|
50
|
+
[HIGHLIGHT:.gap-item:nth-child(1)]
|
|
51
|
+
Gap one: AI doesn't use your exact words. You say Vercel AI Gateway but it searches for Vercel AI SDK. Wrong library. Wrong documentation. Wrong code.
|
|
52
|
+
|
|
53
|
+
[HIGHLIGHT:.gap-item:nth-child(2)]
|
|
54
|
+
Gap two: AI claims files are updated without proof. It says I've updated the file but there's no git diff. No verification. You're trusting on faith.
|
|
55
|
+
|
|
56
|
+
[HIGHLIGHT:.gap-item:nth-child(3)]
|
|
57
|
+
Gap three: Skipped tests are accepted. The AI runs tests, some fail, and it moves on. We can fix those later. Those later fixes never come.
|
|
58
|
+
|
|
59
|
+
[HIGHLIGHT:.gap-item:nth-child(4)]
|
|
60
|
+
Gap four: Tasks marked complete without verification. The AI says Done but the feature doesn't work. No one actually checked.
|
|
61
|
+
|
|
62
|
+
[HIGHLIGHT:.gap-item:nth-child(5)]
|
|
63
|
+
Gap five: Environment variable mismatch. Tests pass locally but fail in production. The AI used different values than what's actually deployed.
|
|
64
|
+
|
|
65
|
+
These gaps compound. One wrong assumption leads to another. By the time you notice, you've built on a broken foundation.
|
|
66
|
+
|
|
67
|
+
[SECTION:solution]
|
|
68
|
+
[HIGHLIGHT:#solution h2]
|
|
69
|
+
The solution is enforcement. Python hooks that intercept every tool call.
|
|
70
|
+
|
|
71
|
+
[HIGHLIGHT:.hook-box:nth-child(1)]
|
|
72
|
+
PreToolUse hooks run before Claude can write or edit any file. They check: Did you research first? Did you interview the user? Did you write a failing test?
|
|
73
|
+
|
|
74
|
+
[HIGHLIGHT:.hook-box:nth-child(2)]
|
|
75
|
+
PostToolUse hooks run after research and interviews. They track what was learned. They log every query. They build a paper trail.
|
|
76
|
+
|
|
77
|
+
[HIGHLIGHT:.hook-box:nth-child(3)]
|
|
78
|
+
The Stop hook runs when Claude tries to mark a task complete. It checks: Are all phases done? Did tests pass? Is documentation updated? If not, blocked.
|
|
79
|
+
|
|
80
|
+
This isn't about limiting AI. It's about holding it to the same standards we hold ourselves.
|
|
81
|
+
|
|
82
|
+
[SECTION:workflow]
|
|
83
|
+
[HIGHLIGHT:#workflow h2]
|
|
84
|
+
The workflow has ten phases. Let's walk through each one.
|
|
85
|
+
|
|
86
|
+
[HIGHLIGHT:.workflow-phase:nth-child(1)]
|
|
87
|
+
Phase one: Scope. Define what you're building. What's the endpoint? What does it do?
|
|
88
|
+
|
|
89
|
+
[HIGHLIGHT:.workflow-phase:nth-child(2)]
|
|
90
|
+
Phase two: Initial research. Use Context7 or web search. Find the real documentation. No guessing.
|
|
91
|
+
|
|
92
|
+
[HIGHLIGHT:.workflow-phase:nth-child(3)]
|
|
93
|
+
Phase three: Interview. Ask the user questions with multiple choice options. What provider? What format? What error handling?
|
|
94
|
+
|
|
95
|
+
[HIGHLIGHT:.workflow-phase:nth-child(4)]
|
|
96
|
+
Phase four: Deep research. Based on interview answers, research specific APIs and SDKs.
|
|
97
|
+
|
|
98
|
+
[HIGHLIGHT:.workflow-phase:nth-child(5)]
|
|
99
|
+
Phase five: Schema design. Define request and response schemas with Zod. Types before code.
|
|
100
|
+
|
|
101
|
+
[HIGHLIGHT:.workflow-phase:nth-child(6)]
|
|
102
|
+
Phase six: Environment setup. Check API keys. Verify environment variables. Test connectivity.
|
|
103
|
+
|
|
104
|
+
[HIGHLIGHT:.workflow-phase:nth-child(7)]
|
|
105
|
+
Phase seven: Red. Write a failing test. Define what success looks like before writing any implementation.
|
|
106
|
+
|
|
107
|
+
[HIGHLIGHT:.workflow-phase:nth-child(8)]
|
|
108
|
+
Phase eight: Green. Write minimal code to pass the test. No extra features. No premature optimization.
|
|
109
|
+
|
|
110
|
+
[HIGHLIGHT:.workflow-phase:nth-child(9)]
|
|
111
|
+
Phase nine: Refactor. Clean up the code. Extract utilities. Improve readability. Tests stay green.
|
|
112
|
+
|
|
113
|
+
[HIGHLIGHT:.workflow-phase:nth-child(10)]
|
|
114
|
+
Phase ten: Documentation. Update OpenAPI spec. Add to test manifest. Include real examples.
|
|
115
|
+
|
|
116
|
+
Only when all ten phases are complete can the workflow finish.
|
|
117
|
+
|
|
118
|
+
[SECTION:installation]
|
|
119
|
+
[HIGHLIGHT:#installation h2]
|
|
120
|
+
Installation takes one command.
|
|
121
|
+
|
|
122
|
+
[HIGHLIGHT:.install-command]
|
|
123
|
+
Run npx @hustle-together/api-dev-tools. That's it.
|
|
124
|
+
|
|
125
|
+
The CLI copies slash commands to your .claude/commands folder. Red, green, refactor, cycle, and the API development commands.
|
|
126
|
+
|
|
127
|
+
It copies Python hooks to .claude/hooks. These are the enforcers. The gatekeepers.
|
|
128
|
+
|
|
129
|
+
It merges settings into your settings.json. Hooks are registered. Permissions are configured.
|
|
130
|
+
|
|
131
|
+
And it offers to add the Context7 MCP server for live documentation lookup.
|
|
132
|
+
|
|
133
|
+
Your project is now enforced. Every API you build follows the workflow.
|
|
134
|
+
|
|
135
|
+
[SECTION:credits]
|
|
136
|
+
[HIGHLIGHT:#credits h2]
|
|
137
|
+
This project builds on the work of others.
|
|
138
|
+
|
|
139
|
+
The TDD workflow commands are based on @wbern/claude-instructions by William Bernmalm. The red, green, refactor pattern that makes AI development rigorous.
|
|
140
|
+
|
|
141
|
+
The interview methodology is inspired by Anthropic's Interviewer approach. Structured discovery before implementation.
|
|
142
|
+
|
|
143
|
+
And Context7 provides live documentation lookup. Current docs, not stale training data.
|
|
144
|
+
|
|
145
|
+
Thank you to the Claude Code community. Together, we're making AI development better.
|
|
146
|
+
|
|
147
|
+
[SECTION:outro]
|
|
148
|
+
[HIGHLIGHT:#intro]
|
|
149
|
+
Hustle API Dev Tools. Research first. Interview second. Test before code. Document everything.
|
|
150
|
+
|
|
151
|
+
Build together. Share resources. Grow stronger.
|
|
152
|
+
|
|
153
|
+
Install it now with npx @hustle-together/api-dev-tools.
|
|
154
|
+
`.trim();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract plain text from the narration script (remove markers)
|
|
158
|
+
*/
|
|
159
|
+
function extractPlainText(script) {
|
|
160
|
+
return script
|
|
161
|
+
.replace(/\[SECTION:[^\]]+\]/g, '')
|
|
162
|
+
.replace(/\[HIGHLIGHT:[^\]]+\]/g, '')
|
|
163
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse the script to extract section and highlight markers with their positions
|
|
169
|
+
*/
|
|
170
|
+
function parseMarkers(script) {
|
|
171
|
+
const markers = [];
|
|
172
|
+
const lines = script.split('\n');
|
|
173
|
+
let charPosition = 0;
|
|
174
|
+
let currentSection = 'intro';
|
|
175
|
+
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
// Check for section marker
|
|
178
|
+
const sectionMatch = line.match(/\[SECTION:([^\]]+)\]/);
|
|
179
|
+
if (sectionMatch) {
|
|
180
|
+
currentSection = sectionMatch[1];
|
|
181
|
+
markers.push({
|
|
182
|
+
type: 'section',
|
|
183
|
+
id: currentSection,
|
|
184
|
+
charPosition
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for highlight marker
|
|
189
|
+
const highlightMatch = line.match(/\[HIGHLIGHT:([^\]]+)\]/);
|
|
190
|
+
if (highlightMatch) {
|
|
191
|
+
markers.push({
|
|
192
|
+
type: 'highlight',
|
|
193
|
+
selector: highlightMatch[1],
|
|
194
|
+
section: currentSection,
|
|
195
|
+
charPosition
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Update char position (for plain text, not including markers)
|
|
200
|
+
const plainLine = line
|
|
201
|
+
.replace(/\[SECTION:[^\]]+\]/g, '')
|
|
202
|
+
.replace(/\[HIGHLIGHT:[^\]]+\]/g, '');
|
|
203
|
+
if (plainLine.trim()) {
|
|
204
|
+
charPosition += plainLine.length + 1; // +1 for newline
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return markers;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Convert character-level timestamps to word-level timestamps
|
|
213
|
+
*/
|
|
214
|
+
function characterToWordTimestamps(alignment) {
|
|
215
|
+
const words = [];
|
|
216
|
+
let currentWord = '';
|
|
217
|
+
let wordStart = null;
|
|
218
|
+
let wordEnd = null;
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < alignment.characters.length; i++) {
|
|
221
|
+
const char = alignment.characters[i];
|
|
222
|
+
const startTime = alignment.character_start_times_seconds[i];
|
|
223
|
+
const endTime = alignment.character_end_times_seconds[i];
|
|
224
|
+
|
|
225
|
+
if (char === ' ' || char === '\n') {
|
|
226
|
+
if (currentWord) {
|
|
227
|
+
words.push({
|
|
228
|
+
word: currentWord,
|
|
229
|
+
start: wordStart,
|
|
230
|
+
end: wordEnd,
|
|
231
|
+
charIndex: i - currentWord.length
|
|
232
|
+
});
|
|
233
|
+
currentWord = '';
|
|
234
|
+
wordStart = null;
|
|
235
|
+
wordEnd = null;
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
if (wordStart === null) {
|
|
239
|
+
wordStart = startTime;
|
|
240
|
+
}
|
|
241
|
+
wordEnd = endTime;
|
|
242
|
+
currentWord += char;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Don't forget the last word
|
|
247
|
+
if (currentWord) {
|
|
248
|
+
words.push({
|
|
249
|
+
word: currentWord,
|
|
250
|
+
start: wordStart,
|
|
251
|
+
end: wordEnd,
|
|
252
|
+
charIndex: alignment.characters.length - currentWord.length
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return words;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Match markers to timestamps based on text position
|
|
261
|
+
*/
|
|
262
|
+
function matchMarkersToTimestamps(markers, wordTimestamps, plainText) {
|
|
263
|
+
const timedMarkers = [];
|
|
264
|
+
|
|
265
|
+
for (const marker of markers) {
|
|
266
|
+
// Find the word closest to this marker's position
|
|
267
|
+
let closestWord = wordTimestamps[0];
|
|
268
|
+
let minDiff = Infinity;
|
|
269
|
+
|
|
270
|
+
for (const word of wordTimestamps) {
|
|
271
|
+
const diff = Math.abs(word.charIndex - marker.charPosition);
|
|
272
|
+
if (diff < minDiff) {
|
|
273
|
+
minDiff = diff;
|
|
274
|
+
closestWord = word;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
timedMarkers.push({
|
|
279
|
+
...marker,
|
|
280
|
+
timestamp: closestWord ? closestWord.start : 0
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return timedMarkers;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Call ElevenLabs API to generate speech with timestamps
|
|
289
|
+
*/
|
|
290
|
+
async function generateSpeech(text, apiKey) {
|
|
291
|
+
const url = `${API_BASE}/text-to-speech/${VOICE_ID}/with-timestamps`;
|
|
292
|
+
|
|
293
|
+
console.log('Calling ElevenLabs API...');
|
|
294
|
+
console.log(`Text length: ${text.length} characters`);
|
|
295
|
+
|
|
296
|
+
const response = await fetch(url, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: {
|
|
299
|
+
'xi-api-key': apiKey,
|
|
300
|
+
'Content-Type': 'application/json'
|
|
301
|
+
},
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
text,
|
|
304
|
+
model_id: MODEL_ID,
|
|
305
|
+
voice_settings: {
|
|
306
|
+
stability: 0.5,
|
|
307
|
+
similarity_boost: 0.75,
|
|
308
|
+
style: 0.3,
|
|
309
|
+
use_speaker_boost: true
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
const error = await response.text();
|
|
316
|
+
throw new Error(`ElevenLabs API error: ${response.status} - ${error}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return response.json();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Main function
|
|
324
|
+
*/
|
|
325
|
+
async function main() {
|
|
326
|
+
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
327
|
+
|
|
328
|
+
if (!apiKey) {
|
|
329
|
+
console.error('Error: ELEVENLABS_API_KEY environment variable is required');
|
|
330
|
+
console.error('Usage: ELEVENLABS_API_KEY=your_key node generate-narration.js');
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const outputDir = __dirname;
|
|
335
|
+
const audioPath = path.join(outputDir, 'narration.mp3');
|
|
336
|
+
const timingPath = path.join(outputDir, 'narration-timing.json');
|
|
337
|
+
|
|
338
|
+
// Extract plain text for TTS
|
|
339
|
+
const plainText = extractPlainText(NARRATION_SCRIPT);
|
|
340
|
+
console.log('Plain text extracted:', plainText.substring(0, 200) + '...');
|
|
341
|
+
|
|
342
|
+
// Parse markers from script
|
|
343
|
+
const markers = parseMarkers(NARRATION_SCRIPT);
|
|
344
|
+
console.log(`Found ${markers.length} markers`);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Generate speech with timestamps
|
|
348
|
+
const result = await generateSpeech(plainText, apiKey);
|
|
349
|
+
|
|
350
|
+
console.log('Audio generated successfully!');
|
|
351
|
+
|
|
352
|
+
// Decode and save audio
|
|
353
|
+
const audioBuffer = Buffer.from(result.audio_base64, 'base64');
|
|
354
|
+
fs.writeFileSync(audioPath, audioBuffer);
|
|
355
|
+
console.log(`Audio saved to: ${audioPath}`);
|
|
356
|
+
console.log(`Audio size: ${(audioBuffer.length / 1024 / 1024).toFixed(2)} MB`);
|
|
357
|
+
|
|
358
|
+
// Convert character timestamps to word timestamps
|
|
359
|
+
const wordTimestamps = characterToWordTimestamps(result.alignment);
|
|
360
|
+
console.log(`Extracted ${wordTimestamps.length} word timestamps`);
|
|
361
|
+
|
|
362
|
+
// Match markers to timestamps
|
|
363
|
+
const timedMarkers = matchMarkersToTimestamps(markers, wordTimestamps, plainText);
|
|
364
|
+
|
|
365
|
+
// Calculate duration
|
|
366
|
+
const lastWord = wordTimestamps[wordTimestamps.length - 1];
|
|
367
|
+
const duration = lastWord ? lastWord.end : 0;
|
|
368
|
+
|
|
369
|
+
// Build timing data
|
|
370
|
+
const timingData = {
|
|
371
|
+
generated: new Date().toISOString(),
|
|
372
|
+
duration,
|
|
373
|
+
wordCount: wordTimestamps.length,
|
|
374
|
+
sections: [],
|
|
375
|
+
highlights: [],
|
|
376
|
+
words: wordTimestamps.map(w => ({
|
|
377
|
+
word: w.word,
|
|
378
|
+
start: w.start,
|
|
379
|
+
end: w.end
|
|
380
|
+
}))
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Separate sections and highlights
|
|
384
|
+
for (const marker of timedMarkers) {
|
|
385
|
+
if (marker.type === 'section') {
|
|
386
|
+
timingData.sections.push({
|
|
387
|
+
id: marker.id,
|
|
388
|
+
timestamp: marker.timestamp
|
|
389
|
+
});
|
|
390
|
+
} else if (marker.type === 'highlight') {
|
|
391
|
+
timingData.highlights.push({
|
|
392
|
+
selector: marker.selector,
|
|
393
|
+
section: marker.section,
|
|
394
|
+
timestamp: marker.timestamp
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Save timing data
|
|
400
|
+
fs.writeFileSync(timingPath, JSON.stringify(timingData, null, 2));
|
|
401
|
+
console.log(`Timing data saved to: ${timingPath}`);
|
|
402
|
+
|
|
403
|
+
console.log('\n=== Summary ===');
|
|
404
|
+
console.log(`Duration: ${duration.toFixed(1)} seconds`);
|
|
405
|
+
console.log(`Sections: ${timingData.sections.length}`);
|
|
406
|
+
console.log(`Highlights: ${timingData.highlights.length}`);
|
|
407
|
+
console.log(`Words: ${timingData.words.length}`);
|
|
408
|
+
|
|
409
|
+
console.log('\n=== Section Timestamps ===');
|
|
410
|
+
for (const section of timingData.sections) {
|
|
411
|
+
const mins = Math.floor(section.timestamp / 60);
|
|
412
|
+
const secs = (section.timestamp % 60).toFixed(1);
|
|
413
|
+
console.log(` ${section.id}: ${mins}:${secs.padStart(4, '0')}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
} catch (error) {
|
|
417
|
+
console.error('Error:', error.message);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
main();
|