Skip to content

Commit 108a110

Browse files
committed
Add audio conversion endpoint and client integration
1 parent 53a5be4 commit 108a110

File tree

4 files changed

+122
-48
lines changed

4 files changed

+122
-48
lines changed

apps/media-server/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ app.get("/", (c) => {
2121
"/audio/status",
2222
"/audio/check",
2323
"/audio/extract",
24+
"/audio/convert",
2425
"/video/status",
2526
"/video/probe",
2627
"/video/thumbnail",

apps/media-server/src/routes/audio.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ const extractSchema = z.object({
1919
stream: z.boolean().optional().default(true),
2020
});
2121

22+
const convertSchema = z.object({
23+
audioUrl: z.string().url(),
24+
outputFormat: z.literal("mp3").optional().default("mp3"),
25+
bitrate: z.string().optional().default("128k"),
26+
});
27+
2228
function isBusyError(err: unknown): boolean {
2329
return err instanceof Error && err.message.includes("Server is busy");
2430
}
@@ -173,4 +179,73 @@ audio.post("/extract", async (c) => {
173179
}
174180
});
175181

182+
audio.post("/convert", async (c) => {
183+
const body = await c.req.json();
184+
const result = convertSchema.safeParse(body);
185+
186+
if (!result.success) {
187+
return c.json(
188+
{
189+
error: "Invalid request",
190+
code: "INVALID_REQUEST",
191+
details: result.error.message,
192+
},
193+
400,
194+
);
195+
}
196+
197+
const { audioUrl, bitrate } = result.data;
198+
199+
try {
200+
const { stream, cleanup } = extractAudioStream(audioUrl, {
201+
bitrate,
202+
timeoutMs: 15 * 60 * 1000,
203+
});
204+
205+
c.req.raw.signal.addEventListener("abort", () => {
206+
cleanup();
207+
});
208+
209+
return new Response(stream, {
210+
headers: {
211+
"Content-Type": "audio/mpeg",
212+
"Transfer-Encoding": "chunked",
213+
},
214+
});
215+
} catch (err) {
216+
console.error("[audio/convert] Error:", err);
217+
218+
if (isBusyError(err)) {
219+
return c.json(
220+
{
221+
error: "Server is busy",
222+
code: "SERVER_BUSY",
223+
details: "Too many concurrent requests, please retry later",
224+
},
225+
503,
226+
);
227+
}
228+
229+
if (isTimeoutError(err)) {
230+
return c.json(
231+
{
232+
error: "Request timed out",
233+
code: "TIMEOUT",
234+
details: err instanceof Error ? err.message : String(err),
235+
},
236+
504,
237+
);
238+
}
239+
240+
return c.json(
241+
{
242+
error: "Failed to convert audio",
243+
code: "FFMPEG_ERROR",
244+
details: err instanceof Error ? err.message : String(err),
245+
},
246+
500,
247+
);
248+
}
249+
});
250+
176251
export default audio;

apps/web/lib/audio-enhance.ts

Lines changed: 10 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { spawn } from "node:child_process";
21
import { serverEnv } from "@cap/env";
32
import Replicate from "replicate";
4-
import { getFfmpegPath } from "./audio-extract";
3+
import {
4+
convertAudioToMp3ViaMediaServer,
5+
isMediaServerConfigured,
6+
} from "./media-client";
57

68
const MAX_POLL_ATTEMPTS = 120;
79
const POLL_INTERVAL_MS = 5000;
@@ -10,51 +12,7 @@ export const ENHANCED_AUDIO_EXTENSION = "mp3";
1012
export const ENHANCED_AUDIO_CONTENT_TYPE = "audio/mpeg";
1113

1214
export function isAudioEnhancementConfigured(): boolean {
13-
return !!serverEnv().REPLICATE_API_TOKEN;
14-
}
15-
16-
async function streamConvertToMp3(wavUrl: string): Promise<Buffer> {
17-
const ffmpeg = getFfmpegPath();
18-
const ffmpegArgs = [
19-
"-i",
20-
wavUrl,
21-
"-acodec",
22-
"libmp3lame",
23-
"-b:a",
24-
"128k",
25-
"-f",
26-
"mp3",
27-
"-pipe:1",
28-
];
29-
30-
return new Promise((resolve, reject) => {
31-
const proc = spawn(ffmpeg, ffmpegArgs, { stdio: ["pipe", "pipe", "pipe"] });
32-
33-
const chunks: Buffer[] = [];
34-
let stderr = "";
35-
36-
proc.stdout?.on("data", (chunk: Buffer) => {
37-
chunks.push(chunk);
38-
});
39-
40-
proc.stderr?.on("data", (data: Buffer) => {
41-
stderr += data.toString();
42-
});
43-
44-
proc.on("error", (err: Error) => {
45-
reject(new Error(`Audio conversion failed: ${err.message}`));
46-
});
47-
48-
proc.on("close", (code: number | null) => {
49-
if (code === 0) {
50-
resolve(Buffer.concat(chunks));
51-
} else {
52-
reject(
53-
new Error(`Audio conversion failed with code ${code}: ${stderr}`),
54-
);
55-
}
56-
});
57-
});
15+
return !!serverEnv().REPLICATE_API_TOKEN && isMediaServerConfigured();
5816
}
5917

6018
export async function enhanceAudioFromUrl(audioUrl: string): Promise<Buffer> {
@@ -63,6 +21,10 @@ export async function enhanceAudioFromUrl(audioUrl: string): Promise<Buffer> {
6321
throw new Error("REPLICATE_API_TOKEN is not configured");
6422
}
6523

24+
if (!isMediaServerConfigured()) {
25+
throw new Error("MEDIA_SERVER_URL is not configured");
26+
}
27+
6628
const replicate = new Replicate({
6729
auth: apiToken,
6830
});
@@ -106,7 +68,7 @@ export async function enhanceAudioFromUrl(audioUrl: string): Promise<Buffer> {
10668
throw new Error("No output received from Replicate");
10769
}
10870

109-
const mp3Buffer = await streamConvertToMp3(enhancedAudioUrl);
71+
const mp3Buffer = await convertAudioToMp3ViaMediaServer(enhancedAudioUrl);
11072

11173
return mp3Buffer;
11274
}

apps/web/lib/media-client.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,39 @@ export async function extractAudioViaMediaServer(
142142
const arrayBuffer = await response.arrayBuffer();
143143
return Buffer.from(arrayBuffer);
144144
}
145+
146+
export async function convertAudioToMp3ViaMediaServer(
147+
audioUrl: string,
148+
): Promise<Buffer> {
149+
const mediaServerUrl = serverEnv().MEDIA_SERVER_URL;
150+
if (!mediaServerUrl) {
151+
throw new Error("MEDIA_SERVER_URL is not configured");
152+
}
153+
154+
const response = await fetchWithRetry(`${mediaServerUrl}/audio/convert`, {
155+
method: "POST",
156+
headers: { "Content-Type": "application/json" },
157+
body: JSON.stringify({
158+
audioUrl,
159+
outputFormat: "mp3",
160+
bitrate: "128k",
161+
}),
162+
});
163+
164+
if (!response.ok) {
165+
let errorData: MediaServerError;
166+
try {
167+
errorData = (await response.json()) as MediaServerError;
168+
} catch {
169+
throw new Error(
170+
`Audio conversion failed: ${response.status} ${response.statusText}`,
171+
);
172+
}
173+
throw new Error(
174+
errorData.details || errorData.error || "Audio conversion failed",
175+
);
176+
}
177+
178+
const arrayBuffer = await response.arrayBuffer();
179+
return Buffer.from(arrayBuffer);
180+
}

0 commit comments

Comments
 (0)