Skip to content

Commit eb0e4a8

Browse files
feat: add Deepgram file transcription support
Uses Deepgram's pre-recorded API (POST /v1/listen) with binary file upload. Supports nova-3 model, auto language detection, speaker diarization, smart formatting, and word-level timestamps. Maximum 2GB file size. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 517bf46 commit eb0e4a8

3 files changed

Lines changed: 217 additions & 1 deletion

File tree

frontend/src/hooks/useFileTranscription.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ import {
4040
transcribeFile as elevenlabsTranscribeFile,
4141
type ElevenLabsWord,
4242
} from '../utils/elevenlabsFileApi'
43+
import {
44+
transcribeFile as deepgramTranscribeFile,
45+
type DeepgramFileWord,
46+
type DeepgramFileUtterance,
47+
} from '../utils/deepgramFileApi'
4348

4449
/* ─── Soniox result conversion ──────────────────────────────── */
4550

@@ -662,6 +667,92 @@ async function executeCloudflare(
662667
}
663668
}
664669

670+
/* ─── Deepgram result conversion ───────────────────────────── */
671+
672+
function deepgramWordsToTokens(words: DeepgramFileWord[]): TranscriptTokenData[] {
673+
return words.map((w) => ({
674+
text: w.punctuated_word || w.word,
675+
isFinal: true,
676+
startMs: Math.round(w.start * 1000),
677+
endMs: Math.round(w.end * 1000),
678+
confidence: w.confidence,
679+
speaker: w.speaker != null ? String(w.speaker) : undefined,
680+
}))
681+
}
682+
683+
function deepgramUtterancesToSegments(utterances: DeepgramFileUtterance[]): TranscriptSegment[] {
684+
return utterances.map((u) => ({
685+
text: u.transcript.trim(),
686+
startMs: Math.round(u.start * 1000),
687+
endMs: Math.round(u.end * 1000),
688+
isFinal: true,
689+
speaker: u.speaker != null ? String(u.speaker) : undefined,
690+
}))
691+
}
692+
693+
function deepgramExtractSpeakers(utterances: DeepgramFileUtterance[]): TranscriptSpeaker[] {
694+
const ids = new Set<number>()
695+
for (const u of utterances) {
696+
if (u.speaker != null) ids.add(u.speaker)
697+
}
698+
return Array.from(ids)
699+
.sort((a, b) => a - b)
700+
.map((id) => ({ id: String(id), name: `Speaker ${id}` }))
701+
}
702+
703+
async function executeDeepgram(
704+
file: File,
705+
config: FileTranscriptionConfig,
706+
apiKey: string,
707+
jobId: string,
708+
updateJob: (id: string, u: Record<string, unknown>) => void,
709+
signal: AbortSignal,
710+
): Promise<TranscriptionResult> {
711+
updateJob(jobId, { status: 'uploading', progress: 20 })
712+
713+
updateJob(jobId, { status: 'transcribing', progress: 40 })
714+
const response = await deepgramTranscribeFile(
715+
apiKey,
716+
file,
717+
{
718+
model: config.model || 'nova-3',
719+
language: config.languageHints?.[0],
720+
diarize: config.enableSpeakerDiarization,
721+
punctuate: true,
722+
utterances: true,
723+
smartFormat: true,
724+
},
725+
signal,
726+
)
727+
728+
updateJob(jobId, { progress: 90 })
729+
730+
const channel = response.results.channels[0]
731+
const alt = channel?.alternatives[0]
732+
const transcript = alt?.transcript ?? ''
733+
const words = alt?.words ?? []
734+
const utterances = response.results.utterances ?? []
735+
736+
const tokens = words.length > 0
737+
? deepgramWordsToTokens(words)
738+
: [{ text: transcript, isFinal: true, startMs: 0, endMs: (response.metadata.duration ?? 0) * 1000 }]
739+
740+
const segments = utterances.length > 0
741+
? deepgramUtterancesToSegments(utterances)
742+
: [{ text: transcript.trim(), startMs: 0, endMs: (response.metadata.duration ?? 0) * 1000, isFinal: true }]
743+
744+
const speakers = deepgramExtractSpeakers(utterances)
745+
const durationMs = (response.metadata.duration ?? 0) * 1000
746+
747+
return {
748+
transcript,
749+
tokens,
750+
segments,
751+
speakers,
752+
durationMs,
753+
}
754+
}
755+
665756
/* ─── Main hook ─────────────────────────────────────────────── */
666757

667758
export function useFileTranscription() {
@@ -712,6 +803,8 @@ export function useFileTranscription() {
712803
result = await executeGladia(file, config, apiKey!, jobId, updateJob, controller.signal)
713804
} else if (providerId === 'elevenlabs') {
714805
result = await executeElevenLabs(file, config, apiKey!, jobId, updateJob, controller.signal)
806+
} else if (providerId === 'deepgram') {
807+
result = await executeDeepgram(file, config, apiKey!, jobId, updateJob, controller.signal)
715808
} else {
716809
result = await executeSoniox(file, config, apiKey!, jobId, updateJob, controller.signal)
717810
}

frontend/src/providers/implementations/DeepgramProvider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export class DeepgramProvider extends BaseASRProvider {
5050
acceptedFileKinds: ['audio'],
5151
},
5252
fileTranscription: {
53-
availability: 'unsupported',
53+
availability: 'compatible',
54+
executionMode: 'single-request',
55+
inputSources: ['file'],
56+
acceptedFileKinds: ['audio', 'video'],
5457
},
5558
},
5659
supportsConfigTest: true,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { DEEPGRAM_DEFAULT_MODEL } from '../types/asr/vendors/deepgram'
2+
3+
const DEEPGRAM_API_BASE = 'https://api.deepgram.com'
4+
5+
export interface DeepgramFileWord {
6+
word: string
7+
start: number
8+
end: number
9+
confidence: number
10+
speaker?: number
11+
punctuated_word?: string
12+
}
13+
14+
export interface DeepgramFileChannel {
15+
alternatives: Array<{
16+
transcript: string
17+
confidence: number
18+
words?: DeepgramFileWord[]
19+
}>
20+
}
21+
22+
export interface DeepgramFileUtterance {
23+
start: number
24+
end: number
25+
confidence: number
26+
channel: number
27+
transcript: string
28+
words: DeepgramFileWord[]
29+
speaker?: number
30+
id: string
31+
}
32+
33+
export interface DeepgramFileTranscriptionResponse {
34+
metadata: {
35+
request_id: string
36+
created: string
37+
duration: number
38+
channels: number
39+
models: string[]
40+
sha256: string
41+
}
42+
results: {
43+
channels: DeepgramFileChannel[]
44+
utterances?: DeepgramFileUtterance[]
45+
}
46+
}
47+
48+
export interface DeepgramFileTranscribeParams {
49+
model?: string
50+
language?: string
51+
diarize?: boolean
52+
punctuate?: boolean
53+
utterances?: boolean
54+
smartFormat?: boolean
55+
}
56+
57+
function getMimeType(fileName: string): string {
58+
const ext = fileName.split('.').pop()?.toLowerCase()
59+
const mimeMap: Record<string, string> = {
60+
wav: 'audio/wav',
61+
mp3: 'audio/mpeg',
62+
mp4: 'video/mp4',
63+
m4a: 'audio/mp4',
64+
ogg: 'audio/ogg',
65+
flac: 'audio/flac',
66+
webm: 'audio/webm',
67+
aac: 'audio/aac',
68+
wma: 'audio/x-ms-wma',
69+
avi: 'video/x-msvideo',
70+
mov: 'video/quicktime',
71+
mkv: 'video/x-matroska',
72+
}
73+
return mimeMap[ext ?? ''] ?? 'audio/wav'
74+
}
75+
76+
export async function transcribeFile(
77+
apiKey: string,
78+
file: File,
79+
params: DeepgramFileTranscribeParams = {},
80+
signal?: AbortSignal,
81+
): Promise<DeepgramFileTranscriptionResponse> {
82+
const queryParams = new URLSearchParams()
83+
queryParams.set('model', params.model || DEEPGRAM_DEFAULT_MODEL)
84+
queryParams.set('punctuate', String(params.punctuate ?? true))
85+
queryParams.set('utterances', String(params.utterances ?? true))
86+
queryParams.set('smart_format', String(params.smartFormat ?? true))
87+
88+
if (params.language) {
89+
queryParams.set('language', params.language)
90+
} else {
91+
queryParams.set('detect_language', 'true')
92+
}
93+
94+
if (params.diarize) {
95+
queryParams.set('diarize', 'true')
96+
}
97+
98+
const arrayBuffer = await file.arrayBuffer()
99+
100+
const res = await fetch(`${DEEPGRAM_API_BASE}/v1/listen?${queryParams.toString()}`, {
101+
method: 'POST',
102+
headers: {
103+
Authorization: `Token ${apiKey}`,
104+
'Content-Type': getMimeType(file.name),
105+
},
106+
body: arrayBuffer,
107+
signal,
108+
})
109+
110+
if (!res.ok) {
111+
let errorMsg = `Deepgram API error ${res.status}`
112+
try {
113+
const body = await res.json()
114+
errorMsg = body.err_msg || body.error || body.message || errorMsg
115+
} catch { /* ignore */ }
116+
throw new Error(errorMsg)
117+
}
118+
119+
return res.json()
120+
}

0 commit comments

Comments
 (0)