Skip to content

Commit 8ad6c47

Browse files
committed
chore: update deps, fix util._extend deprecation, improve SRT/VTT testing
- Bump all deps to latest (react 19.2.8, tailwind 4.3.3, prettier 3.9.6, etc.); TypeScript held at 5.x since 7.x is a compiler major with no benefit here - http-proxy-middleware 3 -> 4 replaces unmaintained http-proxy with httpxy, removing the util._extend (DEP0060) deprecation warning in gatsby develop - Only send diarize/timestamp_granularities when they apply (timestamp_granularities is only valid with response_format=verbose_json), so requests test exactly what was selected - Displayed cURL now uses GATSBY_LITELLM_API_URL instead of a hardcoded host, and mirrors the conditional params - Add SRT/VTT structural validation (header, timestamps, sequential cue numbering) with a pass/fail badge and a download button on both result panels, covered by node:test - Update chat model option to claude-sonnet-4-6 - Ignore *.mp3 sample files
1 parent 3db16a5 commit 8ad6c47

8 files changed

Lines changed: 1591 additions & 1329 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,6 @@ yarn-error.log
6767
.pnp.js
6868
# Yarn Integrity file
6969
.yarn-integrity
70+
71+
# Local audio samples (too large for git)
72+
*.mp3

package.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"author": "amazeeio",
77
"dependencies": {
88
"gatsby": "^5.16.1",
9-
"react": "^19.2.7",
10-
"react-dom": "^19.2.7"
9+
"react": "^19.2.8",
10+
"react-dom": "^19.2.8"
1111
},
1212
"scripts": {
1313
"build": "gatsby build",
@@ -16,19 +16,19 @@
1616
"start": "gatsby develop",
1717
"serve": "gatsby serve",
1818
"clean": "gatsby clean",
19-
"test": "echo \"No tests written yet\" && exit 1"
19+
"test": "node --test src/services/*.test.ts"
2020
},
2121
"devDependencies": {
22-
"@tailwindcss/postcss": "^4.3.1",
23-
"@types/node": "^25.9.3",
24-
"@types/react": "^19.2.17",
25-
"@types/react-dom": "^19.2.3",
26-
"autoprefixer": "^10.5.0",
22+
"@tailwindcss/postcss": "^4.3.3",
23+
"@types/node": "^26.2.0",
24+
"@types/react": "^19.2.18",
25+
"@types/react-dom": "^19.2.4",
26+
"autoprefixer": "^10.5.4",
2727
"gatsby-plugin-postcss": "^6.16.0",
28-
"http-proxy-middleware": "^3.0.6",
29-
"postcss": "^8.5.15",
30-
"prettier": "^3.8.4",
31-
"tailwindcss": "^4.3.1",
28+
"http-proxy-middleware": "^4.2.0",
29+
"postcss": "^8.5.26",
30+
"prettier": "^3.9.6",
31+
"tailwindcss": "^4.3.3",
3232
"typescript": "^5.9.3"
3333
}
3434
}

pnpm-lock.yaml

Lines changed: 1417 additions & 1302 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/TranslationResult.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,64 @@
11
import React from 'react';
22

3+
import { validateSubtitle, SubtitleFormat } from '../services/subtitle';
4+
35
interface TranslationResultProps {
46
transcription: string;
57
translation: string;
68
isTranscribing: boolean;
79
isTranslating: boolean;
810
transcribeModel: string;
911
translateModel: string;
12+
responseFormat: string;
1013
}
1114

15+
const downloadText = (text: string, filename: string) => {
16+
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
17+
const a = document.createElement('a');
18+
a.href = url;
19+
a.download = filename;
20+
a.click();
21+
URL.revokeObjectURL(url);
22+
};
23+
24+
const SubtitleTools: React.FC<{ content: string; format: SubtitleFormat; filename: string }> = ({
25+
content,
26+
format,
27+
filename,
28+
}) => {
29+
const error = validateSubtitle(content, format);
30+
return (
31+
<div className="mt-2 flex items-center gap-3 text-xs">
32+
{error ? (
33+
<span className="px-2 py-0.5 rounded bg-red-100 text-red-800 border border-red-200" title={error}>
34+
✗ Invalid {format.toUpperCase()}: {error}
35+
</span>
36+
) : (
37+
<span className="px-2 py-0.5 rounded bg-green-100 text-green-800 border border-green-200">
38+
✓ Valid {format.toUpperCase()}
39+
</span>
40+
)}
41+
<button
42+
onClick={() => downloadText(content, filename)}
43+
className="underline text-indigo-600 hover:text-indigo-800 cursor-pointer"
44+
>
45+
Download .{format}
46+
</button>
47+
</div>
48+
);
49+
};
50+
1251
export const TranslationResult: React.FC<TranslationResultProps> = ({
1352
transcription,
1453
translation,
1554
isTranscribing,
1655
isTranslating,
1756
transcribeModel,
1857
translateModel,
58+
responseFormat,
1959
}) => {
60+
const subtitleFormat =
61+
responseFormat === 'srt' || responseFormat === 'vtt' ? (responseFormat as SubtitleFormat) : null;
2062

2163
return (
2264
<div className="w-full space-y-6 grid grid-cols-2 gap-6">
@@ -33,6 +75,13 @@ export const TranslationResult: React.FC<TranslationResultProps> = ({
3375
)}
3476
</h3>
3577
<p className="mt-1 max-w-2xl text-sm text-gray-500">Transcribed via "{transcribeModel}" model.</p>
78+
{subtitleFormat && transcription && !isTranscribing && (
79+
<SubtitleTools
80+
content={transcription}
81+
format={subtitleFormat}
82+
filename={`transcription.${subtitleFormat}`}
83+
/>
84+
)}
3685
</div>
3786
<div className="px-4 py-5 sm:p-6 text-gray-800 whitespace-pre-wrap min-h-[100px]">
3887
{isTranscribing ? (
@@ -59,6 +108,13 @@ export const TranslationResult: React.FC<TranslationResultProps> = ({
59108
)}
60109
</h3>
61110
<p className="mt-1 max-w-2xl text-sm text-indigo-500">Target language text result via "{translateModel}" model.</p>
111+
{subtitleFormat && translation && !isTranslating && (
112+
<SubtitleTools
113+
content={translation}
114+
format={subtitleFormat}
115+
filename={`translation.${subtitleFormat}`}
116+
/>
117+
)}
62118
</div>
63119
<div className="px-4 py-5 sm:p-6 text-gray-800 whitespace-pre-wrap min-h-[100px]">
64120
{isTranslating ? (

src/pages/index.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,21 @@ const IndexPage = () => {
5252
setTranscription('');
5353
setTranslation('');
5454

55-
// Construct a representation of the curl request for transparency
56-
const curlCommand = `curl -X POST "https://llm.us104.amazee.ai/v1/audio/transcriptions" \\
57-
-H "Authorization: Bearer [GATSBY_LITELLM_API_KEY]" \\
58-
-F "file=@${file.name}" \\
59-
-F "model=${transcribeModel}" \\
60-
-F "response_format=${responseFormat}" \\
61-
-F "diarize=${diarize}" \\
62-
${timestampGranularity.map(g => `-F "timestamp_granularities[]=${g}"`).join(' \\\n ')} \\
63-
${sourceLang ? `-F "language=${sourceLang}"` : ''}`;
55+
// Construct a representation of the curl request for transparency.
56+
// Mirrors llmService.ts: optional params only included when they apply.
57+
const apiBase = process.env.GATSBY_LITELLM_API_URL || 'https://llm.us104.amazee.ai';
58+
const curlCommand = [
59+
`curl -X POST "${apiBase}/v1/audio/transcriptions"`,
60+
`-H "Authorization: Bearer [GATSBY_LITELLM_API_KEY]"`,
61+
`-F "file=@${file.name}"`,
62+
`-F "model=${transcribeModel}"`,
63+
...(diarize ? [`-F "diarize=true"`] : []),
64+
...(responseFormat === 'verbose_json'
65+
? timestampGranularity.map(g => `-F "timestamp_granularities[]=${g}"`)
66+
: []),
67+
`-F "response_format=${responseFormat}"`,
68+
...(sourceLang ? [`-F "language=${sourceLang}"`] : []),
69+
].join(' \\\n ');
6470

6571
setLastCurlRequest(curlCommand);
6672
setIsCurlExpanded(true);
@@ -76,7 +82,7 @@ const IndexPage = () => {
7682
setIsTranslating(true);
7783

7884
// Construct a representation of the curl request for translation
79-
const translateCurl = `curl -X POST "https://llm.us104.amazee.ai/v1/chat/completions" \\
85+
const translateCurl = `curl -X POST "${apiBase}/v1/chat/completions" \\
8086
-H "Content-Type: application/json" \\
8187
-H "Authorization: Bearer [GATSBY_LITELLM_API_KEY]" \\
8288
-d '{
@@ -248,7 +254,7 @@ const IndexPage = () => {
248254
onChange={(e) => setChatModel(e.target.value)}
249255
>
250256
<option value="chat">chat</option>
251-
<option value="claude-4-5-sonnet">claude-4-5-sonnet</option>
257+
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
252258
</select>
253259
</div>
254260

@@ -360,6 +366,7 @@ const IndexPage = () => {
360366
isTranslating={isTranslating}
361367
transcribeModel={transcribeModel}
362368
translateModel={chatModel}
369+
responseFormat={responseFormat}
363370
/>
364371
</div>
365372
</div>

src/services/llmService.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ export const transcribeAudio = async (
2222
formData.append('file', file);
2323
formData.append('model', model);
2424

25-
// diarize and timestamp_granularities support
26-
formData.append('diarize', diarize.toString());
27-
timestampGranularities.forEach(g => formData.append('timestamp_granularities[]', g));
28-
25+
// Only send optional params when they apply, so the request tests exactly
26+
// what was selected. timestamp_granularities is only valid with
27+
// response_format=verbose_json per the transcription API.
28+
if (diarize) {
29+
formData.append('diarize', 'true');
30+
}
31+
if (responseFormat === 'verbose_json') {
32+
timestampGranularities.forEach(g => formData.append('timestamp_granularities[]', g));
33+
}
34+
2935
formData.append('response_format', responseFormat);
3036

3137
if (sourceLanguage) {

src/services/subtitle.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { validateSubtitle } from './subtitle.ts';
4+
5+
const srt = `1
6+
00:00:01,000 --> 00:00:03,500
7+
Hello world
8+
9+
2
10+
00:00:04,000 --> 00:00:06,000
11+
Second line`;
12+
13+
const vtt = `WEBVTT
14+
15+
00:00:01.000 --> 00:00:03.500
16+
Hello world
17+
18+
00:00:04.000 --> 00:00:06.000
19+
Second line`;
20+
21+
test('valid srt passes', () => assert.equal(validateSubtitle(srt, 'srt'), null));
22+
test('valid vtt passes', () => assert.equal(validateSubtitle(vtt, 'vtt'), null));
23+
test('empty output fails', () => assert.match(validateSubtitle(' ', 'srt')!, /empty/));
24+
test('vtt without header fails', () =>
25+
assert.match(validateSubtitle(vtt.replace('WEBVTT\n\n', ''), 'vtt')!, /WEBVTT/));
26+
test('no cues fails', () => assert.match(validateSubtitle('just some text', 'srt')!, /cues/));
27+
test('wrong timestamp separator fails', () =>
28+
assert.match(validateSubtitle(srt.replaceAll(',', '.'), 'srt')!, /timestamp/i));
29+
test('broken numbering fails', () =>
30+
assert.match(validateSubtitle(srt.replace('\n2\n', '\n3\n'), 'srt')!, /numbering/));

src/services/subtitle.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type SubtitleFormat = 'srt' | 'vtt';
2+
3+
const TIMESTAMP = {
4+
srt: /^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}/,
5+
vtt: /^(\d{2,}:)?\d{2}:\d{2}\.\d{3} --> (\d{2,}:)?\d{2}:\d{2}\.\d{3}/,
6+
};
7+
8+
/**
9+
* Validates SRT/VTT structure: header, timestamp lines, and (for SRT)
10+
* sequential cue numbering. Returns null if valid, otherwise a message
11+
* describing the first problem found.
12+
*/
13+
export const validateSubtitle = (text: string, format: SubtitleFormat): string | null => {
14+
const trimmed = text.trim();
15+
if (!trimmed) return 'Output is empty';
16+
if (format === 'vtt' && !trimmed.startsWith('WEBVTT')) {
17+
return 'Missing "WEBVTT" header on first line';
18+
}
19+
20+
const lines = trimmed.split(/\r?\n/);
21+
const cueIndexes = lines.reduce<number[]>((acc, line, i) => {
22+
if (line.includes('-->')) acc.push(i);
23+
return acc;
24+
}, []);
25+
if (cueIndexes.length === 0) return 'No timestamp cues ("-->") found';
26+
27+
for (const i of cueIndexes) {
28+
if (!TIMESTAMP[format].test(lines[i].trim())) {
29+
return `Malformed ${format.toUpperCase()} timestamp: "${lines[i].trim().slice(0, 50)}"`;
30+
}
31+
}
32+
33+
if (format === 'srt') {
34+
let expected = 1;
35+
for (const i of cueIndexes) {
36+
const num = Number(lines[i - 1]?.trim());
37+
if (num !== expected) {
38+
return `Cue numbering broken: expected ${expected}, got "${(lines[i - 1] ?? '').trim()}"`;
39+
}
40+
expected++;
41+
}
42+
}
43+
44+
return null;
45+
};

0 commit comments

Comments
 (0)