f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Transcription of a take's mic audio with Deepgram's pre-recorded API.
3import fs from 'node:fs/promises';
4import path from 'node:path';
5import type { Transcript, Word } from '../shared/types.ts';
6import { extractAudio } from './media.ts';
7import { HttpError, getConfig, getVideo, takeDir, updateVideo, writeJsonAtomic } from './store.ts';
9const inFlight = new Set<string>();
11export async function transcribeTake(videoId: string, takeId: string) {
12 const key = `${videoId}/${takeId}`;
13 if (inFlight.has(key)) return;
14 const cfg = await getConfig();
15 if (!cfg.deepgramApiKey) throw new HttpError(400, 'No Deepgram API key configured (see Settings)');
16 const v = await getVideo(videoId);
17 const take = v.takes.find((t) => t.id === takeId);
18 if (!take) throw new HttpError(404, 'Take not found');
19 if (take.status !== 'ready') throw new HttpError(409, 'Take is not ready');
20 if (!take.audio) throw new HttpError(400, 'Take has no audio');
22 inFlight.add(key);
23 const setStatus = (status: 'pending' | 'done' | 'failed', error?: string) =>
24 updateVideo(videoId, (v) => {
25 const t = v.takes.find((t) => t.id === takeId);
26 if (t) t.transcript = { status, updatedAt: new Date().toISOString(), ...(error ? { error } : {}) };
27 });
29 try {
30 await setStatus('pending');
31 } catch (e) {
32 inFlight.delete(key);
33 throw e;
34 }
35 // Run in the background; callers poll the take's transcript status.
36 (async () => {
37 const dir = takeDir(videoId, takeId);
38 const audio = path.join(dir, 'transcribe-audio.ogg');
39 try {
40 await extractAudio(path.join(dir, `${take.audio}.webm`), audio);
41 const body = await fs.readFile(audio);
42 const url = new URL('https://api.deepgram.com/v1/listen');
43 url.searchParams.set('model', cfg.deepgramModel || 'nova-3');
44 url.searchParams.set('smart_format', 'true');
45 url.searchParams.set('punctuate', 'true');
46 const res = await fetch(url, {
47 method: 'POST',
48 headers: { Authorization: `Token ${cfg.deepgramApiKey}`, 'Content-Type': 'audio/ogg' },
49 body,
50 });
51 if (!res.ok) throw new Error(`Deepgram ${res.status}: ${(await res.text()).slice(0, 300)}`);
52 const json = (await res.json()) as any;
53 const alt = json?.results?.channels?.[0]?.alternatives?.[0] ?? {};
54 const words: Word[] = (alt.words ?? []).map((w: { word: string; punctuated_word?: string; start: number; end: number }) => ({
55 word: w.word,
56 text: w.punctuated_word ?? w.word,
57 start: w.start,
58 end: w.end,
59 }));
60 const transcript: Transcript = {
61 takeId,
62 model: cfg.deepgramModel,
63 createdAt: new Date().toISOString(),
64 text: alt.transcript ?? '',
65 words,
66 };
67 await writeJsonAtomic(path.join(dir, 'transcript.json'), transcript);
68 await setStatus('done');
69 } catch (e) {
70 console.warn('transcription failed:', (e as Error).message);
71 await setStatus('failed', (e as Error).message).catch(() => {});
72 } finally {
73 await fs.rm(audio, { force: true });
74 inFlight.delete(key);
75 }
76 })();
77}