Skip to content

Commit b19eb0f

Browse files
committed
faet: clean up segments, file naming
1 parent b2b546c commit b19eb0f

File tree

4 files changed

+80
-41
lines changed

4 files changed

+80
-41
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@
1313
- [x] Add logic to avoid duplicates
1414
- [x] Try ElevenLabs TTS
1515
- [x] Add arg parser
16+
- [x] Get timestamps for each segment
17+
- [x] Create "chapters" for each segment
1618
- [ ] Upload the audio file to a podcast hosting service or generate RSS feed
1719
- [ ] Output a run summary at end for AI usage

src/index.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from 'path'
33

44
import { generatePodcastIntro, summarize } from './lib/ai'
55
import { generateAudioFromText, joinAudioFiles } from './lib/audio'
6-
import { OUTPUT_DIR, podcastOutro as outro } from './lib/constants'
6+
import { OUTPUT_DIR } from './lib/constants'
77
import { fetchTopStories } from './lib/hn'
88
import { getTtsService } from './lib/services'
99
import { generateShowNotes } from './lib/show-notes'
@@ -49,15 +49,11 @@ async function main() {
4949
if (audio === false) {
5050
log.info('SKIPPING audio generation')
5151
} else {
52-
const audioFilenames = await generateAudioFromText(
53-
[
54-
{ summary: intro.text, storyId: intro.cacheKey },
55-
...summaries,
56-
{ summary: outro, storyId: 'outro' },
57-
],
52+
const segments = await generateAudioFromText(
53+
[{ summary: intro.text, storyId: intro.cacheKey, title: intro.title }, ...summaries],
5854
ttsService,
5955
)
60-
await joinAudioFiles(audioFilenames, path.resolve(OUTPUT_DIR, 'output.mp3'))
56+
await joinAudioFiles(segments, path.resolve(OUTPUT_DIR, 'output.mp3'))
6157
}
6258

6359
log.info('Done!')

src/lib/ai.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export async function summarizeStory(story: StoryDataAggregate): Promise<StoryDa
9898

9999
export async function generatePodcastIntro(
100100
stories: StoryOutput[],
101-
): Promise<{ cacheKey: string; text: string }> {
101+
): Promise<{ cacheKey: string; text: string; title: string }> {
102102
logger.info('Generating podcast intro...')
103103
const hash = createHash('sha256')
104104
.update(stories.map(s => s.storyId).join())
@@ -109,7 +109,7 @@ export async function generatePodcastIntro(
109109
const cached = await readFromCache(cacheKey)
110110
if (cached) {
111111
logger.info(`Using cached intro: ${cacheKey}`)
112-
return { cacheKey, text: cached }
112+
return { cacheKey, text: cached, title: 'Intro' }
113113
}
114114

115115
const introTemplate = (summary: string) => `
@@ -144,7 +144,7 @@ ${stories.map(story => `Title: ${story.title}\nContent: ${story.content}\n\n`).j
144144

145145
const intro = introTemplate(text)
146146
await writeToCache(cacheKey, intro)
147-
return { cacheKey, text: intro }
147+
return { cacheKey, text: intro, title: 'Intro' }
148148
}
149149

150150
/**

src/lib/audio.ts

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,58 @@ import path from 'path'
33

44
import type { StoryDataAggregate, TtsService } from '../types'
55

6-
import { CACHE_DIR } from '../lib/constants'
6+
import { CACHE_DIR, podcastOutro } from '../lib/constants'
77
import { readFromCache, writeToCache } from '../utils/cache'
88
import { childLogger, log } from '../utils/log'
99

1010
const logger = childLogger('AUDIO')
11-
const silence = path.resolve(__dirname, 'silence-1s.mp3')
11+
const silenceAudioPath = path.resolve(__dirname, 'silence-1s.mp3')
12+
13+
const silenceAudioSegment: PodcastSegmentWithAudio = {
14+
audioFilename: silenceAudioPath,
15+
title: 'Silence',
16+
summary: 'Silence',
17+
isSilence: true,
18+
}
19+
20+
const outroSegment: PodcastSegment = {
21+
summary: podcastOutro,
22+
storyId: 'outro',
23+
title: 'Outro',
24+
}
1225

1326
type PodcastSegment = {
27+
title: string
1428
storyId: string
1529
summary: string
1630
}
1731

32+
type PodcastSegmentWithAudio = { audioFilename: string; isSilence?: boolean } & Omit<
33+
PodcastSegment,
34+
'storyId'
35+
>
36+
1837
type Chapter = { title: string; start: number; end: number }
1938

2039
export async function generateAudioFromText(
2140
storyData: (PodcastSegment | StoryDataAggregate)[],
2241
ttsService: TtsService,
23-
): Promise<string[]> {
24-
const audioFilenames: string[] = []
42+
): Promise<PodcastSegmentWithAudio[]> {
43+
const segments: PodcastSegmentWithAudio[] = []
44+
45+
storyData.push(outroSegment)
2546

2647
for (const [i, story] of storyData.entries()) {
2748
const filename = `segment-${story.storyId}.mp3`
2849

2950
const cached = await readFromCache(filename)
3051
if (cached) {
3152
logger.info(`[${i + 1}/${storyData.length}] Using cached audio for ${filename}`)
32-
audioFilenames.push(filename)
53+
segments.push({
54+
audioFilename: path.resolve(CACHE_DIR, filename),
55+
title: story.title,
56+
summary: story.summary as string,
57+
})
3358
continue
3459
}
3560

@@ -38,37 +63,46 @@ export async function generateAudioFromText(
3863
const buffer = await ttsService.convert(story.summary as string)
3964
logger.info(`Audio file generated: ${filename}`)
4065
await writeToCache(filename, buffer)
41-
audioFilenames.push(filename)
66+
segments.push({
67+
audioFilename: path.resolve(CACHE_DIR, filename),
68+
title: story.title,
69+
summary: story.summary as string,
70+
})
4271
} catch (error) {
4372
logger.error(`Error generating audio for story: ${story.storyId}\nsummary: ${story.summary}`)
4473
logger.error(error)
4574
}
4675
}
4776

48-
return audioFilenames
77+
return segments
4978
}
5079

51-
export async function joinAudioFiles(filenames: string[], outputFilename: string): Promise<void> {
52-
logger.info(`Merging ${filenames.length} audio files into ${outputFilename}...`)
80+
export async function joinAudioFiles(
81+
segments: PodcastSegmentWithAudio[],
82+
outputFilename: string,
83+
): Promise<void> {
84+
logger.info(`Merging ${segments.length} audio files into ${outputFilename}...`)
5385

5486
// insert silence between segments
55-
const filesWithSilence = insertBetween(filenames, silence)
87+
const segmentsWithSilence = insertBetween(segments, silenceAudioSegment)
5688
logger.debug({
57-
filesWithSilence,
89+
filesWithSilence: segmentsWithSilence,
5890
})
5991

60-
const durations = await calculateDurations(filesWithSilence)
61-
log.info({ msg: 'DURRATTIONNNSSSSSSSSSSSSSSSSSSSS', durations })
92+
const durations = await calculateChapters(segmentsWithSilence)
93+
log.info({ durations })
6294

6395
// Write chapter metadata to file
6496
const metadataContent = createMetadataContent(durations)
6597
await writeToCache('chapters.txt', metadataContent)
6698

99+
const noChapterOutputFilename = path.resolve(CACHE_DIR, 'output-no-chapters.mp3')
100+
67101
await new Promise((resolve, reject) => {
68102
const command = ffmpeg()
69103

70-
filesWithSilence
71-
.map(f => (path.isAbsolute(f) ? f : path.resolve(CACHE_DIR, f)))
104+
segmentsWithSilence
105+
.map(f => f.audioFilename)
72106
.forEach(file => {
73107
command.input(file)
74108
})
@@ -85,7 +119,7 @@ export async function joinAudioFiles(filenames: string[], outputFilename: string
85119
logger.error('Error occurred:', err)
86120
reject(err)
87121
})
88-
.mergeToFile(outputFilename, CACHE_DIR)
122+
.mergeToFile(noChapterOutputFilename, CACHE_DIR)
89123
})
90124

91125
log.info('Writing metadata to file...')
@@ -106,26 +140,30 @@ export async function joinAudioFiles(filenames: string[], outputFilename: string
106140
logger.error('Error occurred:', err)
107141
reject(err)
108142
})
109-
.input(outputFilename)
143+
.input(noChapterOutputFilename)
110144
.input(path.resolve(CACHE_DIR, 'chapters.txt'))
111145
.audioCodec('copy')
112146
.outputOptions('-map_metadata', '1')
113147
.outputOptions('-metadata', 'title=Hacker News Recap')
114-
.save(outputFilename + '.chapters.mp3')
148+
.save(outputFilename)
115149
})
116150

117151
log.info('Audio file created')
118152
}
119153

120-
function insertBetween(array: string[], itemToInsert: string): string[] {
154+
function insertBetween(
155+
array: PodcastSegmentWithAudio[],
156+
itemToInsert: PodcastSegmentWithAudio,
157+
): PodcastSegmentWithAudio[] {
121158
return array.reduce((acc, current, index) => {
122159
if (index > 0) {
123160
acc.push(itemToInsert)
124161
}
125162
acc.push(current)
126163
return acc
127-
}, [] as string[])
164+
}, [] as PodcastSegmentWithAudio[])
128165
}
166+
129167
function createMetadataContent(chapters: Chapter[]): string {
130168
return (
131169
`;FFMETADATA1\n` +
@@ -143,37 +181,40 @@ title=${chapter.title}
143181
)
144182
}
145183

146-
function calculateDurations(filenames: string[]): Promise<Chapter[]> {
184+
function calculateChapters(segments: PodcastSegmentWithAudio[]): Promise<Chapter[]> {
147185
return new Promise((resolve, reject) => {
148186
let currentTime = 0
149187
const chapters: Chapter[] = []
150188

151-
const processFile = (index: number) => {
152-
if (index >= filenames.length) {
189+
const processFile = (i: number) => {
190+
if (i >= segments.length) {
153191
resolve(chapters)
154192
return
155193
}
156194

157-
const file = path.isAbsolute(filenames[index])
158-
? filenames[index]
159-
: path.resolve(CACHE_DIR, filenames[index])
160-
ffmpeg.ffprobe(file, (err, metadata) => {
195+
ffmpeg.ffprobe(segments[i].audioFilename, (err, metadata) => {
161196
if (err) {
162197
reject(err as Error)
163198
return
164199
}
165200

166201
const duration = metadata.format.duration || 0
167-
const title = path.basename(file, path.extname(file))
202+
203+
// If it's silence, just skip to the next file
204+
if (segments[i].isSilence) {
205+
currentTime += duration
206+
processFile(i + 1)
207+
return
208+
}
168209

169210
chapters.push({
170-
title,
211+
title: segments[i].title,
171212
start: currentTime,
172213
end: currentTime + duration,
173214
})
174215

175216
currentTime += duration
176-
processFile(index + 1)
217+
processFile(i + 1)
177218
})
178219
}
179220

0 commit comments

Comments
 (0)