From 33da67d3d8ca98beb4acf78ae45acd8df75ef446 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:53:57 -0400 Subject: [PATCH 1/2] feat: add ElevenLabs Music API integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six endpoints proxying ElevenLabs Music API: - POST /api/music/compose — generate song from prompt or composition plan - POST /api/music/compose/detailed — generate with metadata + timestamps - POST /api/music/stream — streaming audio generation - POST /api/music/plan — create composition plan (free, no credits) - POST /api/music/video-to-music — background music from video files - POST /api/music/stem-separation — separate audio into stems Includes: - lib/elevenlabs/ module (2 HTTP clients, shared schemas, 6 validators, 6 handlers) - 6 MCP tools (compose_music, compose_music_detailed, stream_music, create_composition_plan, video_to_music, separate_stems) - 9 test files, 37 test cases — all passing - ELEVENLABS_BASE_URL constant in lib/const.ts Made-with: Cursor --- app/api/music/compose/detailed/route.ts | 25 ++++ app/api/music/compose/route.ts | 25 ++++ app/api/music/plan/route.ts | 25 ++++ app/api/music/stem-separation/route.ts | 26 ++++ app/api/music/stream/route.ts | 24 ++++ app/api/music/video-to-music/route.ts | 25 ++++ lib/const.ts | 3 + .../__tests__/callElevenLabsMusic.test.ts | 61 +++++++++ .../callElevenLabsMusicMultipart.test.ts | 64 ++++++++++ .../__tests__/composeHandler.test.ts | 106 ++++++++++++++++ .../__tests__/createPlanHandler.test.ts | 90 +++++++++++++ .../__tests__/validateComposeBody.test.ts | 63 ++++++++++ .../__tests__/validateCreatePlanBody.test.ts | 38 ++++++ .../validateStemSeparationBody.test.ts | 27 ++++ .../__tests__/validateStreamBody.test.ts | 32 +++++ .../validateVideoToMusicBody.test.ts | 35 ++++++ lib/elevenlabs/callElevenLabsMusic.ts | 36 ++++++ .../callElevenLabsMusicMultipart.ts | 35 ++++++ lib/elevenlabs/composeDetailedHandler.ts | 70 +++++++++++ lib/elevenlabs/composeHandler.ts | 64 ++++++++++ lib/elevenlabs/compositionPlanSchema.ts | 19 +++ lib/elevenlabs/createPlanHandler.ts | 58 +++++++++ lib/elevenlabs/outputFormats.ts | 28 +++++ lib/elevenlabs/stemSeparationHandler.ts | 95 ++++++++++++++ lib/elevenlabs/streamHandler.ts | 65 ++++++++++ lib/elevenlabs/validateComposeBody.ts | 45 +++++++ lib/elevenlabs/validateComposeDetailedBody.ts | 46 +++++++ lib/elevenlabs/validateCreatePlanBody.ts | 31 +++++ lib/elevenlabs/validateStemSeparationBody.ts | 34 +++++ lib/elevenlabs/validateStreamBody.ts | 43 +++++++ lib/elevenlabs/validateVideoToMusicBody.ts | 32 +++++ lib/elevenlabs/videoToMusicHandler.ts | 118 ++++++++++++++++++ lib/mcp/tools/index.ts | 2 + lib/mcp/tools/music/index.ts | 21 ++++ .../music/registerComposeDetailedMusicTool.ts | 69 ++++++++++ .../tools/music/registerComposeMusicTool.ts | 67 ++++++++++ .../registerCreateCompositionPlanTool.ts | 56 +++++++++ .../tools/music/registerStemSeparationTool.ts | 85 +++++++++++++ .../tools/music/registerStreamMusicTool.ts | 67 ++++++++++ .../tools/music/registerVideoToMusicTool.ts | 95 ++++++++++++++ 40 files changed, 1950 insertions(+) create mode 100644 app/api/music/compose/detailed/route.ts create mode 100644 app/api/music/compose/route.ts create mode 100644 app/api/music/plan/route.ts create mode 100644 app/api/music/stem-separation/route.ts create mode 100644 app/api/music/stream/route.ts create mode 100644 app/api/music/video-to-music/route.ts create mode 100644 lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts create mode 100644 lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts create mode 100644 lib/elevenlabs/__tests__/composeHandler.test.ts create mode 100644 lib/elevenlabs/__tests__/createPlanHandler.test.ts create mode 100644 lib/elevenlabs/__tests__/validateComposeBody.test.ts create mode 100644 lib/elevenlabs/__tests__/validateCreatePlanBody.test.ts create mode 100644 lib/elevenlabs/__tests__/validateStemSeparationBody.test.ts create mode 100644 lib/elevenlabs/__tests__/validateStreamBody.test.ts create mode 100644 lib/elevenlabs/__tests__/validateVideoToMusicBody.test.ts create mode 100644 lib/elevenlabs/callElevenLabsMusic.ts create mode 100644 lib/elevenlabs/callElevenLabsMusicMultipart.ts create mode 100644 lib/elevenlabs/composeDetailedHandler.ts create mode 100644 lib/elevenlabs/composeHandler.ts create mode 100644 lib/elevenlabs/compositionPlanSchema.ts create mode 100644 lib/elevenlabs/createPlanHandler.ts create mode 100644 lib/elevenlabs/outputFormats.ts create mode 100644 lib/elevenlabs/stemSeparationHandler.ts create mode 100644 lib/elevenlabs/streamHandler.ts create mode 100644 lib/elevenlabs/validateComposeBody.ts create mode 100644 lib/elevenlabs/validateComposeDetailedBody.ts create mode 100644 lib/elevenlabs/validateCreatePlanBody.ts create mode 100644 lib/elevenlabs/validateStemSeparationBody.ts create mode 100644 lib/elevenlabs/validateStreamBody.ts create mode 100644 lib/elevenlabs/validateVideoToMusicBody.ts create mode 100644 lib/elevenlabs/videoToMusicHandler.ts create mode 100644 lib/mcp/tools/music/index.ts create mode 100644 lib/mcp/tools/music/registerComposeDetailedMusicTool.ts create mode 100644 lib/mcp/tools/music/registerComposeMusicTool.ts create mode 100644 lib/mcp/tools/music/registerCreateCompositionPlanTool.ts create mode 100644 lib/mcp/tools/music/registerStemSeparationTool.ts create mode 100644 lib/mcp/tools/music/registerStreamMusicTool.ts create mode 100644 lib/mcp/tools/music/registerVideoToMusicTool.ts diff --git a/app/api/music/compose/detailed/route.ts b/app/api/music/compose/detailed/route.ts new file mode 100644 index 00000000..a567fe13 --- /dev/null +++ b/app/api/music/compose/detailed/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { composeDetailedHandler } from "@/lib/elevenlabs/composeDetailedHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/compose/detailed + * + * Generate a song with metadata and optional word timestamps. + * Returns multipart/mixed (JSON metadata + binary audio). + */ +export { composeDetailedHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; +export const maxDuration = 120; diff --git a/app/api/music/compose/route.ts b/app/api/music/compose/route.ts new file mode 100644 index 00000000..84ae7fb5 --- /dev/null +++ b/app/api/music/compose/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { composeHandler } from "@/lib/elevenlabs/composeHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/compose + * + * Generate a song from a text prompt or composition plan using ElevenLabs Music AI. + * Returns binary audio. The song-id is returned in the response headers. + */ +export { composeHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; +export const maxDuration = 120; diff --git a/app/api/music/plan/route.ts b/app/api/music/plan/route.ts new file mode 100644 index 00000000..b1990a2d --- /dev/null +++ b/app/api/music/plan/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createPlanHandler } from "@/lib/elevenlabs/createPlanHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/plan + * + * Create a composition plan from a text prompt. + * Free — does not consume ElevenLabs credits. + * Use this before compose to preview and tweak the plan. + */ +export { createPlanHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/music/stem-separation/route.ts b/app/api/music/stem-separation/route.ts new file mode 100644 index 00000000..daa9702c --- /dev/null +++ b/app/api/music/stem-separation/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { stemSeparationHandler } from "@/lib/elevenlabs/stemSeparationHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/stem-separation + * + * Separate an audio file into individual stems (vocals, drums, bass, etc.). + * Accepts multipart/form-data with one audio file. + * Returns a ZIP archive containing the stem files. + */ +export { stemSeparationHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; +export const maxDuration = 120; diff --git a/app/api/music/stream/route.ts b/app/api/music/stream/route.ts new file mode 100644 index 00000000..f9fecb3e --- /dev/null +++ b/app/api/music/stream/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { streamHandler } from "@/lib/elevenlabs/streamHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/stream + * + * Generate a song and stream audio chunks to the client in real time. + */ +export { streamHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; +export const maxDuration = 120; diff --git a/app/api/music/video-to-music/route.ts b/app/api/music/video-to-music/route.ts new file mode 100644 index 00000000..6689f5fc --- /dev/null +++ b/app/api/music/video-to-music/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { videoToMusicHandler } from "@/lib/elevenlabs/videoToMusicHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/music/video-to-music + * + * Generate background music from video files. + * Accepts multipart/form-data with 1-10 video files (total ≤200MB). + */ +export { videoToMusicHandler as POST }; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; +export const maxDuration = 120; diff --git a/lib/const.ts b/lib/const.ts index a5cccfac..118635a4 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -32,6 +32,9 @@ export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b"; export const RECOUP_API_KEY = process.env.RECOUP_API_KEY || ""; +/** ElevenLabs Music API base URL */ +export const ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"; + /** Music Flamingo model inference endpoint (Modal) */ export const FLAMINGO_GENERATE_URL = "https://sidney-78147--music-flamingo-musicflamingo-generate.modal.run"; diff --git a/lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts b/lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts new file mode 100644 index 00000000..8cb5487a --- /dev/null +++ b/lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/const", () => ({ + ELEVENLABS_BASE_URL: "https://api.elevenlabs.io", +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { callElevenLabsMusic } from "../callElevenLabsMusic"; + +describe("callElevenLabsMusic", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("ELEVENLABS_API_KEY", "test-xi-key"); + }); + + it("sends JSON body with xi-api-key header", async () => { + mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 })); + + await callElevenLabsMusic("/v1/music", { prompt: "upbeat pop" }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.elevenlabs.io/v1/music", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "xi-api-key": "test-xi-key", + }, + body: JSON.stringify({ prompt: "upbeat pop" }), + }), + ); + }); + + it("appends output_format query param when provided", async () => { + mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 })); + + await callElevenLabsMusic("/v1/music", { prompt: "test" }, "mp3_44100_128"); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("output_format=mp3_44100_128"); + }); + + it("does not append output_format when not provided", async () => { + mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 })); + + await callElevenLabsMusic("/v1/music", { prompt: "test" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).not.toContain("output_format"); + }); + + it("throws when ELEVENLABS_API_KEY is missing", async () => { + vi.stubEnv("ELEVENLABS_API_KEY", ""); + + await expect(callElevenLabsMusic("/v1/music", { prompt: "test" })).rejects.toThrow( + "ELEVENLABS_API_KEY is not configured", + ); + }); +}); diff --git a/lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts b/lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts new file mode 100644 index 00000000..7157e67d --- /dev/null +++ b/lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/const", () => ({ + ELEVENLABS_BASE_URL: "https://api.elevenlabs.io", +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { callElevenLabsMusicMultipart } from "../callElevenLabsMusicMultipart"; + +describe("callElevenLabsMusicMultipart", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("ELEVENLABS_API_KEY", "test-xi-key"); + }); + + it("sends FormData with xi-api-key header", async () => { + mockFetch.mockResolvedValue(new Response("zip-data", { status: 200 })); + + const formData = new FormData(); + formData.append("file", new Blob(["audio"]), "test.mp3"); + + await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.elevenlabs.io/v1/music/stem-separation", + expect.objectContaining({ + method: "POST", + headers: { "xi-api-key": "test-xi-key" }, + body: formData, + }), + ); + }); + + it("does not set Content-Type header (lets fetch auto-set multipart boundary)", async () => { + mockFetch.mockResolvedValue(new Response("data", { status: 200 })); + + const formData = new FormData(); + await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers).not.toHaveProperty("Content-Type"); + }); + + it("appends output_format query param when provided", async () => { + mockFetch.mockResolvedValue(new Response("data", { status: 200 })); + + const formData = new FormData(); + await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData, "mp3_44100_128"); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("output_format=mp3_44100_128"); + }); + + it("throws when ELEVENLABS_API_KEY is missing", async () => { + vi.stubEnv("ELEVENLABS_API_KEY", ""); + + const formData = new FormData(); + await expect( + callElevenLabsMusicMultipart("/v1/music/stem-separation", formData), + ).rejects.toThrow("ELEVENLABS_API_KEY is not configured"); + }); +}); diff --git a/lib/elevenlabs/__tests__/composeHandler.test.ts b/lib/elevenlabs/__tests__/composeHandler.test.ts new file mode 100644 index 00000000..65466735 --- /dev/null +++ b/lib/elevenlabs/__tests__/composeHandler.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../callElevenLabsMusic", () => ({ + callElevenLabsMusic: vi.fn(), +})); + +import { composeHandler } from "../composeHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { callElevenLabsMusic } from "../callElevenLabsMusic"; + +describe("composeHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns audio with correct content-type on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account", + orgId: null, + authToken: "test-token", + }); + + vi.mocked(callElevenLabsMusic).mockResolvedValue( + new Response("audio-bytes", { + status: 200, + headers: { + "content-type": "audio/mpeg", + "song-id": "song-123", + }, + }), + ); + + const request = new NextRequest("http://localhost/api/music/compose", { + method: "POST", + body: JSON.stringify({ prompt: "upbeat pop song" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await composeHandler(request); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("audio/mpeg"); + expect(response.headers.get("song-id")).toBe("song-123"); + }); + + it("returns 400 on invalid body", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/music/compose", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + const response = await composeHandler(request); + expect(response.status).toBe(400); + }); + + it("returns 401 on missing auth", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/music/compose", { + method: "POST", + body: JSON.stringify({ prompt: "test" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await composeHandler(request); + expect(response.status).toBe(401); + }); + + it("returns 502 when ElevenLabs returns 500", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account", + orgId: null, + authToken: "test-token", + }); + + vi.mocked(callElevenLabsMusic).mockResolvedValue( + new Response("Internal error", { status: 500 }), + ); + + const request = new NextRequest("http://localhost/api/music/compose", { + method: "POST", + body: JSON.stringify({ prompt: "test" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await composeHandler(request); + expect(response.status).toBe(502); + }); +}); diff --git a/lib/elevenlabs/__tests__/createPlanHandler.test.ts b/lib/elevenlabs/__tests__/createPlanHandler.test.ts new file mode 100644 index 00000000..c0e9fd0a --- /dev/null +++ b/lib/elevenlabs/__tests__/createPlanHandler.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../callElevenLabsMusic", () => ({ + callElevenLabsMusic: vi.fn(), +})); + +import { createPlanHandler } from "../createPlanHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { callElevenLabsMusic } from "../callElevenLabsMusic"; + +describe("createPlanHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns composition plan JSON on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account", + orgId: null, + authToken: "test-token", + }); + + const planData = { + positive_global_styles: ["pop", "upbeat"], + negative_global_styles: ["metal"], + sections: [{ title: "Verse 1", lyrics: "Hello world" }], + }; + + vi.mocked(callElevenLabsMusic).mockResolvedValue( + new Response(JSON.stringify(planData), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const request = new NextRequest("http://localhost/api/music/plan", { + method: "POST", + body: JSON.stringify({ prompt: "cinematic piece" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createPlanHandler(request); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.status).toBe("success"); + expect(body.positive_global_styles).toEqual(["pop", "upbeat"]); + }); + + it("returns 400 when prompt is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-account", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/music/plan", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createPlanHandler(request); + expect(response.status).toBe(400); + }); + + it("returns 401 on missing auth", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/music/plan", { + method: "POST", + body: JSON.stringify({ prompt: "test" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createPlanHandler(request); + expect(response.status).toBe(401); + }); +}); diff --git a/lib/elevenlabs/__tests__/validateComposeBody.test.ts b/lib/elevenlabs/__tests__/validateComposeBody.test.ts new file mode 100644 index 00000000..b570018f --- /dev/null +++ b/lib/elevenlabs/__tests__/validateComposeBody.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +import { validateComposeBody } from "../validateComposeBody"; + +describe("validateComposeBody", () => { + it("accepts valid prompt-only body", () => { + const result = validateComposeBody({ prompt: "upbeat pop song about summer" }); + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as Record).prompt).toBe("upbeat pop song about summer"); + }); + + it("accepts valid composition_plan-only body", () => { + const plan = { + positive_global_styles: ["pop"], + negative_global_styles: ["metal"], + sections: [{ title: "Verse 1" }], + }; + const result = validateComposeBody({ composition_plan: plan }); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("rejects when both prompt and composition_plan are provided", () => { + const result = validateComposeBody({ + prompt: "test", + composition_plan: { + positive_global_styles: [], + negative_global_styles: [], + sections: [{ title: "V1" }], + }, + }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects when neither prompt nor composition_plan is provided", () => { + const result = validateComposeBody({}); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects prompt exceeding 4100 characters", () => { + const result = validateComposeBody({ prompt: "x".repeat(4101) }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects music_length_ms out of range", () => { + const result = validateComposeBody({ prompt: "test", music_length_ms: 1000 }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts valid output_format", () => { + const result = validateComposeBody({ prompt: "test", output_format: "mp3_44100_128" }); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("rejects invalid output_format", () => { + const result = validateComposeBody({ prompt: "test", output_format: "invalid_format" }); + expect(result).toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/elevenlabs/__tests__/validateCreatePlanBody.test.ts b/lib/elevenlabs/__tests__/validateCreatePlanBody.test.ts new file mode 100644 index 00000000..915c7539 --- /dev/null +++ b/lib/elevenlabs/__tests__/validateCreatePlanBody.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +import { validateCreatePlanBody } from "../validateCreatePlanBody"; + +describe("validateCreatePlanBody", () => { + it("accepts valid body with prompt", () => { + const result = validateCreatePlanBody({ prompt: "cinematic orchestral piece, 3 minutes" }); + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as Record).prompt).toBe("cinematic orchestral piece, 3 minutes"); + }); + + it("rejects missing prompt", () => { + const result = validateCreatePlanBody({}); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects music_length_ms out of range", () => { + const result = validateCreatePlanBody({ prompt: "test", music_length_ms: 999999 }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts optional source_composition_plan", () => { + const result = validateCreatePlanBody({ + prompt: "make it more upbeat", + source_composition_plan: { + positive_global_styles: ["pop"], + negative_global_styles: [], + sections: [{ title: "Intro" }], + }, + }); + expect(result).not.toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/elevenlabs/__tests__/validateStemSeparationBody.test.ts b/lib/elevenlabs/__tests__/validateStemSeparationBody.test.ts new file mode 100644 index 00000000..688714fc --- /dev/null +++ b/lib/elevenlabs/__tests__/validateStemSeparationBody.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +import { validateStemSeparationBody } from "../validateStemSeparationBody"; + +describe("validateStemSeparationBody", () => { + it("accepts valid body with stem_variation_id", () => { + const result = validateStemSeparationBody({ stem_variation_id: "two_stems_v1" }); + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as Record).stem_variation_id).toBe("two_stems_v1"); + }); + + it("defaults stem_variation_id to six_stems_v1", () => { + const result = validateStemSeparationBody({}); + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as Record).stem_variation_id).toBe("six_stems_v1"); + }); + + it("rejects invalid stem_variation_id", () => { + const result = validateStemSeparationBody({ stem_variation_id: "invalid" }); + expect(result).toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/elevenlabs/__tests__/validateStreamBody.test.ts b/lib/elevenlabs/__tests__/validateStreamBody.test.ts new file mode 100644 index 00000000..f22d31d6 --- /dev/null +++ b/lib/elevenlabs/__tests__/validateStreamBody.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +import { validateStreamBody } from "../validateStreamBody"; + +describe("validateStreamBody", () => { + it("accepts valid prompt-only body", () => { + const result = validateStreamBody({ prompt: "lo-fi hip hop beats" }); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("rejects when both prompt and composition_plan are provided", () => { + const result = validateStreamBody({ + prompt: "test", + composition_plan: { + positive_global_styles: [], + negative_global_styles: [], + sections: [{ title: "V1" }], + }, + }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects when neither prompt nor composition_plan is provided", () => { + const result = validateStreamBody({}); + expect(result).toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/elevenlabs/__tests__/validateVideoToMusicBody.test.ts b/lib/elevenlabs/__tests__/validateVideoToMusicBody.test.ts new file mode 100644 index 00000000..dfd35ad3 --- /dev/null +++ b/lib/elevenlabs/__tests__/validateVideoToMusicBody.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +import { validateVideoToMusicBody } from "../validateVideoToMusicBody"; + +describe("validateVideoToMusicBody", () => { + it("accepts valid body with description and tags", () => { + const result = validateVideoToMusicBody({ + description: "energetic background music", + tags: ["upbeat", "electronic"], + }); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("accepts empty body (all fields are optional)", () => { + const result = validateVideoToMusicBody({}); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("rejects tags exceeding 10 items", () => { + const result = validateVideoToMusicBody({ + tags: Array.from({ length: 11 }, (_, i) => `tag${i}`), + }); + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects description exceeding 1000 chars", () => { + const result = validateVideoToMusicBody({ description: "x".repeat(1001) }); + expect(result).toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/elevenlabs/callElevenLabsMusic.ts b/lib/elevenlabs/callElevenLabsMusic.ts new file mode 100644 index 00000000..375c93f5 --- /dev/null +++ b/lib/elevenlabs/callElevenLabsMusic.ts @@ -0,0 +1,36 @@ +import { ELEVENLABS_BASE_URL } from "@/lib/const"; + +/** + * Calls an ElevenLabs Music API JSON endpoint. + * Shared by all music handlers that send JSON bodies. + * + * @param path - The API path (e.g. "/v1/music" or "/v1/music/plan"). + * @param body - The JSON request body. + * @param outputFormat - Optional audio output format query param. + * @returns The raw Response from ElevenLabs. + * @throws Error if ELEVENLABS_API_KEY is not configured. + */ +export async function callElevenLabsMusic( + path: string, + body: Record, + outputFormat?: string, +): Promise { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { + throw new Error("ELEVENLABS_API_KEY is not configured"); + } + + const url = new URL(path, ELEVENLABS_BASE_URL); + if (outputFormat) { + url.searchParams.set("output_format", outputFormat); + } + + return fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "xi-api-key": apiKey, + }, + body: JSON.stringify(body), + }); +} diff --git a/lib/elevenlabs/callElevenLabsMusicMultipart.ts b/lib/elevenlabs/callElevenLabsMusicMultipart.ts new file mode 100644 index 00000000..a40cfa00 --- /dev/null +++ b/lib/elevenlabs/callElevenLabsMusicMultipart.ts @@ -0,0 +1,35 @@ +import { ELEVENLABS_BASE_URL } from "@/lib/const"; + +/** + * Calls an ElevenLabs Music API multipart/form-data endpoint. + * Used by video-to-music and stem-separation which accept file uploads. + * + * @param path - The API path (e.g. "/v1/music/video-to-music"). + * @param formData - The FormData containing files and fields. + * @param outputFormat - Optional audio output format query param. + * @returns The raw Response from ElevenLabs. + * @throws Error if ELEVENLABS_API_KEY is not configured. + */ +export async function callElevenLabsMusicMultipart( + path: string, + formData: FormData, + outputFormat?: string, +): Promise { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { + throw new Error("ELEVENLABS_API_KEY is not configured"); + } + + const url = new URL(path, ELEVENLABS_BASE_URL); + if (outputFormat) { + url.searchParams.set("output_format", outputFormat); + } + + return fetch(url.toString(), { + method: "POST", + headers: { + "xi-api-key": apiKey, + }, + body: formData, + }); +} diff --git a/lib/elevenlabs/composeDetailedHandler.ts b/lib/elevenlabs/composeDetailedHandler.ts new file mode 100644 index 00000000..1f96bb0c --- /dev/null +++ b/lib/elevenlabs/composeDetailedHandler.ts @@ -0,0 +1,70 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateComposeDetailedBody } from "./validateComposeDetailedBody"; +import { callElevenLabsMusic } from "./callElevenLabsMusic"; + +/** + * Handler for POST /api/music/compose/detailed. + * Generates a song with metadata and optional word timestamps. + * Proxies the multipart/mixed response from ElevenLabs directly. + * + * @param request - The incoming request with a JSON body. + * @returns The upstream multipart/mixed response or error JSON. + */ +export async function composeDetailedHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Request body must be valid JSON" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateComposeDetailedBody(body); + if (validated instanceof NextResponse) return validated; + + const { output_format, ...elevenLabsBody } = validated; + + try { + const upstream = await callElevenLabsMusic( + "/v1/music/detailed", + elevenLabsBody, + output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs compose/detailed returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Music generation failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const songId = upstream.headers.get("song-id"); + const contentType = upstream.headers.get("content-type") ?? "multipart/mixed"; + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + }; + if (songId) headers["song-id"] = songId; + + return new Response(upstream.body, { status: 200, headers }); + } catch (error) { + console.error("ElevenLabs compose/detailed error:", error); + return NextResponse.json( + { status: "error", error: "Music generation failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/elevenlabs/composeHandler.ts b/lib/elevenlabs/composeHandler.ts new file mode 100644 index 00000000..2ee8390a --- /dev/null +++ b/lib/elevenlabs/composeHandler.ts @@ -0,0 +1,64 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateComposeBody } from "./validateComposeBody"; +import { callElevenLabsMusic } from "./callElevenLabsMusic"; + +/** + * Handler for POST /api/music/compose. + * Generates a song from a text prompt or composition plan. + * Returns binary audio with the song-id in response headers. + * + * @param request - The incoming request with a JSON body. + * @returns Binary audio response or error JSON. + */ +export async function composeHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Request body must be valid JSON" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateComposeBody(body); + if (validated instanceof NextResponse) return validated; + + const { output_format, ...elevenLabsBody } = validated; + + try { + const upstream = await callElevenLabsMusic("/v1/music", elevenLabsBody, output_format); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs compose returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Music generation failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const songId = upstream.headers.get("song-id"); + const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + }; + if (songId) headers["song-id"] = songId; + + return new Response(upstream.body, { status: 200, headers }); + } catch (error) { + console.error("ElevenLabs compose error:", error); + return NextResponse.json( + { status: "error", error: "Music generation failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/elevenlabs/compositionPlanSchema.ts b/lib/elevenlabs/compositionPlanSchema.ts new file mode 100644 index 00000000..2660e550 --- /dev/null +++ b/lib/elevenlabs/compositionPlanSchema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** Zod schema for an ElevenLabs composition plan, shared across compose/stream/detailed/plan endpoints. */ +export const compositionPlanSchema = z.object({ + positive_global_styles: z.array(z.string()), + negative_global_styles: z.array(z.string()), + sections: z.array( + z.object({ + title: z.string(), + lyrics: z.string().nullable().optional(), + duration_ms: z.number().int().min(3000).max(600000).optional(), + positive_styles: z.array(z.string()).optional(), + negative_styles: z.array(z.string()).optional(), + audio_url: z.string().url().nullable().optional(), + }), + ), +}); + +export type CompositionPlan = z.infer; diff --git a/lib/elevenlabs/createPlanHandler.ts b/lib/elevenlabs/createPlanHandler.ts new file mode 100644 index 00000000..1c47e208 --- /dev/null +++ b/lib/elevenlabs/createPlanHandler.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateCreatePlanBody } from "./validateCreatePlanBody"; +import { callElevenLabsMusic } from "./callElevenLabsMusic"; + +/** + * Handler for POST /api/music/plan. + * Creates a composition plan from a text prompt. + * Free endpoint — does not consume ElevenLabs credits. + * + * @param request - The incoming request with a JSON body. + * @returns The composition plan JSON or error JSON. + */ +export async function createPlanHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Request body must be valid JSON" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateCreatePlanBody(body); + if (validated instanceof NextResponse) return validated; + + try { + const upstream = await callElevenLabsMusic("/v1/music/plan", validated); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs plan returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Plan creation failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const plan = await upstream.json(); + + return NextResponse.json( + { status: "success", ...plan }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("ElevenLabs plan error:", error); + return NextResponse.json( + { status: "error", error: "Plan creation failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/elevenlabs/outputFormats.ts b/lib/elevenlabs/outputFormats.ts new file mode 100644 index 00000000..5c8a343b --- /dev/null +++ b/lib/elevenlabs/outputFormats.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +/** Valid ElevenLabs output audio formats. */ +export const elevenLabsOutputFormatSchema = z.enum([ + "mp3_22050_32", + "mp3_24000_48", + "mp3_44100_32", + "mp3_44100_64", + "mp3_44100_96", + "mp3_44100_128", + "mp3_44100_192", + "pcm_8000", + "pcm_16000", + "pcm_22050", + "pcm_24000", + "pcm_32000", + "pcm_44100", + "pcm_48000", + "ulaw_8000", + "alaw_8000", + "opus_48000_32", + "opus_48000_64", + "opus_48000_96", + "opus_48000_128", + "opus_48000_192", +]); + +export type ElevenLabsOutputFormat = z.infer; diff --git a/lib/elevenlabs/stemSeparationHandler.ts b/lib/elevenlabs/stemSeparationHandler.ts new file mode 100644 index 00000000..8ea79573 --- /dev/null +++ b/lib/elevenlabs/stemSeparationHandler.ts @@ -0,0 +1,95 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateStemSeparationBody } from "./validateStemSeparationBody"; +import { callElevenLabsMusicMultipart } from "./callElevenLabsMusicMultipart"; + +/** + * Handler for POST /api/music/stem-separation. + * Accepts multipart/form-data with an audio file. + * Separates the audio into individual stems (vocals, drums, bass, etc.). + * Returns a ZIP archive containing the stem files. + * + * @param request - The incoming multipart/form-data request. + * @returns ZIP archive response or error JSON. + */ +export async function stemSeparationHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let incomingForm: FormData; + try { + incomingForm = await request.formData(); + } catch { + return NextResponse.json( + { status: "error", error: "Request must be multipart/form-data" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const file = incomingForm.get("file") as File | null; + if (!file) { + return NextResponse.json( + { status: "error", error: "An audio file is required in the 'file' field" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const textFields: Record = {}; + for (const [key, value] of incomingForm.entries()) { + if (key !== "file" && typeof value === "string") { + if (key === "sign_with_c2pa") { + textFields[key] = value === "true"; + } else { + textFields[key] = value; + } + } + } + + const validated = validateStemSeparationBody(textFields); + if (validated instanceof NextResponse) return validated; + + const upstreamForm = new FormData(); + upstreamForm.append("file", file); + upstreamForm.append("stem_variation_id", validated.stem_variation_id); + if (validated.sign_with_c2pa) { + upstreamForm.append("sign_with_c2pa", String(validated.sign_with_c2pa)); + } + + try { + const upstream = await callElevenLabsMusicMultipart( + "/v1/music/stem-separation", + upstreamForm, + validated.output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs stem-separation returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Stem separation failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const contentType = upstream.headers.get("content-type") ?? "application/zip"; + const contentDisposition = upstream.headers.get("content-disposition"); + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + }; + if (contentDisposition) headers["Content-Disposition"] = contentDisposition; + + return new Response(upstream.body, { status: 200, headers }); + } catch (error) { + console.error("ElevenLabs stem-separation error:", error); + return NextResponse.json( + { status: "error", error: "Stem separation failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/elevenlabs/streamHandler.ts b/lib/elevenlabs/streamHandler.ts new file mode 100644 index 00000000..7a72a7c4 --- /dev/null +++ b/lib/elevenlabs/streamHandler.ts @@ -0,0 +1,65 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateStreamBody } from "./validateStreamBody"; +import { callElevenLabsMusic } from "./callElevenLabsMusic"; + +/** + * Handler for POST /api/music/stream. + * Generates a song and streams audio chunks directly to the client. + * Does not buffer — pipes the upstream readable stream through. + * + * @param request - The incoming request with a JSON body. + * @returns A streaming audio response or error JSON. + */ +export async function streamHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Request body must be valid JSON" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateStreamBody(body); + if (validated instanceof NextResponse) return validated; + + const { output_format, ...elevenLabsBody } = validated; + + try { + const upstream = await callElevenLabsMusic("/v1/music/stream", elevenLabsBody, output_format); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs stream returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Music streaming failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const songId = upstream.headers.get("song-id"); + const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + "Transfer-Encoding": "chunked", + }; + if (songId) headers["song-id"] = songId; + + return new Response(upstream.body, { status: 200, headers }); + } catch (error) { + console.error("ElevenLabs stream error:", error); + return NextResponse.json( + { status: "error", error: "Music streaming failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/elevenlabs/validateComposeBody.ts b/lib/elevenlabs/validateComposeBody.ts new file mode 100644 index 00000000..ec849fd8 --- /dev/null +++ b/lib/elevenlabs/validateComposeBody.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { compositionPlanSchema } from "./compositionPlanSchema"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +export const composeBodySchema = z + .object({ + prompt: z.string().max(4100).nullable().optional(), + composition_plan: compositionPlanSchema.nullable().optional(), + music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), + model_id: z.enum(["music_v1"]).optional().default("music_v1"), + seed: z.number().int().min(0).max(2147483647).nullable().optional(), + force_instrumental: z.boolean().optional().default(false), + respect_sections_durations: z.boolean().optional().default(true), + store_for_inpainting: z.boolean().optional().default(false), + sign_with_c2pa: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), + }) + .refine((data) => !(data.prompt && data.composition_plan), { + message: "Cannot use both prompt and composition_plan", + }) + .refine((data) => data.prompt || data.composition_plan, { + message: "Must provide either prompt or composition_plan", + }); + +export type ComposeBody = z.infer; + +/** + * Validates the request body for POST /api/music/compose. + * + * @param body - The raw request body. + * @returns Validated data or a NextResponse error. + */ +export function validateComposeBody(body: unknown): NextResponse | ComposeBody { + const result = composeBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/validateComposeDetailedBody.ts b/lib/elevenlabs/validateComposeDetailedBody.ts new file mode 100644 index 00000000..ecaa4f24 --- /dev/null +++ b/lib/elevenlabs/validateComposeDetailedBody.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { compositionPlanSchema } from "./compositionPlanSchema"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +export const composeDetailedBodySchema = z + .object({ + prompt: z.string().max(4100).nullable().optional(), + composition_plan: compositionPlanSchema.nullable().optional(), + music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), + model_id: z.enum(["music_v1"]).optional().default("music_v1"), + seed: z.number().int().min(0).max(2147483647).nullable().optional(), + force_instrumental: z.boolean().optional().default(false), + respect_sections_durations: z.boolean().optional().default(true), + store_for_inpainting: z.boolean().optional().default(false), + sign_with_c2pa: z.boolean().optional().default(false), + with_timestamps: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), + }) + .refine((data) => !(data.prompt && data.composition_plan), { + message: "Cannot use both prompt and composition_plan", + }) + .refine((data) => data.prompt || data.composition_plan, { + message: "Must provide either prompt or composition_plan", + }); + +export type ComposeDetailedBody = z.infer; + +/** + * Validates the request body for POST /api/music/compose/detailed. + * + * @param body - The raw request body. + * @returns Validated data or a NextResponse error. + */ +export function validateComposeDetailedBody(body: unknown): NextResponse | ComposeDetailedBody { + const result = composeDetailedBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/validateCreatePlanBody.ts b/lib/elevenlabs/validateCreatePlanBody.ts new file mode 100644 index 00000000..62304a9d --- /dev/null +++ b/lib/elevenlabs/validateCreatePlanBody.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { compositionPlanSchema } from "./compositionPlanSchema"; + +export const createPlanBodySchema = z.object({ + prompt: z.string().max(4100, "prompt exceeds 4100 character limit"), + music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), + source_composition_plan: compositionPlanSchema.nullable().optional(), + model_id: z.enum(["music_v1"]).optional().default("music_v1"), +}); + +export type CreatePlanBody = z.infer; + +/** + * Validates the request body for POST /api/music/plan. + * + * @param body - The raw request body. + * @returns Validated data or a NextResponse error. + */ +export function validateCreatePlanBody(body: unknown): NextResponse | CreatePlanBody { + const result = createPlanBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/validateStemSeparationBody.ts b/lib/elevenlabs/validateStemSeparationBody.ts new file mode 100644 index 00000000..0c405346 --- /dev/null +++ b/lib/elevenlabs/validateStemSeparationBody.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +export const stemSeparationBodySchema = z.object({ + stem_variation_id: z + .enum(["two_stems_v1", "six_stems_v1"]) + .optional() + .default("six_stems_v1"), + sign_with_c2pa: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), +}); + +export type StemSeparationBody = z.infer; + +/** + * Validates the text fields for POST /api/music/stem-separation. + * The audio file is validated separately during form parsing. + * + * @param body - The raw text fields from multipart form data. + * @returns Validated data or a NextResponse error. + */ +export function validateStemSeparationBody(body: unknown): NextResponse | StemSeparationBody { + const result = stemSeparationBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/validateStreamBody.ts b/lib/elevenlabs/validateStreamBody.ts new file mode 100644 index 00000000..610d05d8 --- /dev/null +++ b/lib/elevenlabs/validateStreamBody.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { compositionPlanSchema } from "./compositionPlanSchema"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +export const streamBodySchema = z + .object({ + prompt: z.string().max(4100).nullable().optional(), + composition_plan: compositionPlanSchema.nullable().optional(), + music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), + model_id: z.enum(["music_v1"]).optional().default("music_v1"), + seed: z.number().int().min(0).max(2147483647).nullable().optional(), + force_instrumental: z.boolean().optional().default(false), + store_for_inpainting: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), + }) + .refine((data) => !(data.prompt && data.composition_plan), { + message: "Cannot use both prompt and composition_plan", + }) + .refine((data) => data.prompt || data.composition_plan, { + message: "Must provide either prompt or composition_plan", + }); + +export type StreamBody = z.infer; + +/** + * Validates the request body for POST /api/music/stream. + * + * @param body - The raw request body. + * @returns Validated data or a NextResponse error. + */ +export function validateStreamBody(body: unknown): NextResponse | StreamBody { + const result = streamBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/validateVideoToMusicBody.ts b/lib/elevenlabs/validateVideoToMusicBody.ts new file mode 100644 index 00000000..2fcd0142 --- /dev/null +++ b/lib/elevenlabs/validateVideoToMusicBody.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +export const videoToMusicBodySchema = z.object({ + description: z.string().min(1).max(1000).nullable().optional(), + tags: z.array(z.string()).max(10).optional(), + sign_with_c2pa: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), +}); + +export type VideoToMusicBody = z.infer; + +/** + * Validates the text fields for POST /api/music/video-to-music. + * The video file itself is validated separately during form parsing. + * + * @param body - The raw text fields from multipart form data. + * @returns Validated data or a NextResponse error. + */ +export function validateVideoToMusicBody(body: unknown): NextResponse | VideoToMusicBody { + const result = videoToMusicBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/elevenlabs/videoToMusicHandler.ts b/lib/elevenlabs/videoToMusicHandler.ts new file mode 100644 index 00000000..8722c2dc --- /dev/null +++ b/lib/elevenlabs/videoToMusicHandler.ts @@ -0,0 +1,118 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateVideoToMusicBody } from "./validateVideoToMusicBody"; +import { callElevenLabsMusicMultipart } from "./callElevenLabsMusicMultipart"; + +const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB +const MAX_FILES = 10; + +/** + * Handler for POST /api/music/video-to-music. + * Accepts multipart/form-data with video files and text fields. + * Generates background music from the video content. + * + * @param request - The incoming multipart/form-data request. + * @returns Binary audio response or error JSON. + */ +export async function videoToMusicHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + let incomingForm: FormData; + try { + incomingForm = await request.formData(); + } catch { + return NextResponse.json( + { status: "error", error: "Request must be multipart/form-data" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const videos = incomingForm.getAll("videos") as File[]; + if (videos.length === 0) { + return NextResponse.json( + { status: "error", error: "At least one video file is required in the 'videos' field" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + if (videos.length > MAX_FILES) { + return NextResponse.json( + { status: "error", error: `Maximum ${MAX_FILES} video files allowed` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const totalSize = videos.reduce((sum, v) => sum + v.size, 0); + if (totalSize > MAX_TOTAL_SIZE) { + return NextResponse.json( + { status: "error", error: "Total video size exceeds 200MB limit" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const textFields: Record = {}; + for (const [key, value] of incomingForm.entries()) { + if (key !== "videos" && typeof value === "string") { + if (key === "tags") { + textFields[key] = value.split(",").map((t) => t.trim()); + } else if (key === "sign_with_c2pa") { + textFields[key] = value === "true"; + } else { + textFields[key] = value; + } + } + } + + const validated = validateVideoToMusicBody(textFields); + if (validated instanceof NextResponse) return validated; + + const upstreamForm = new FormData(); + for (const video of videos) { + upstreamForm.append("videos", video); + } + if (validated.description) upstreamForm.append("description", validated.description); + if (validated.tags) { + for (const tag of validated.tags) { + upstreamForm.append("tags", tag); + } + } + if (validated.sign_with_c2pa) { + upstreamForm.append("sign_with_c2pa", String(validated.sign_with_c2pa)); + } + + try { + const upstream = await callElevenLabsMusicMultipart( + "/v1/music/video-to-music", + upstreamForm, + validated.output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs video-to-music returned ${upstream.status}: ${errorText}`); + return NextResponse.json( + { status: "error", error: `Video-to-music failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); + } + + const songId = upstream.headers.get("song-id"); + const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + }; + if (songId) headers["song-id"] = songId; + + return new Response(upstream.body, { status: 200, headers }); + } catch (error) { + console.error("ElevenLabs video-to-music error:", error); + return NextResponse.json( + { status: "error", error: "Video-to-music generation failed" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index e95da17f..6d3d95d6 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -13,6 +13,7 @@ import { registerWebDeepResearchTool } from "./registerWebDeepResearchTool"; import { registerArtistDeepResearchTool } from "./registerArtistDeepResearchTool"; import { registerAllFileTools } from "./files"; import { registerAllFlamingoTools } from "./flamingo"; +import { registerAllMusicTools } from "./music"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; @@ -39,6 +40,7 @@ export const registerAllTools = (server: McpServer): void => { registerAllFileTools(server); registerAllFlamingoTools(server); registerAllImageTools(server); + registerAllMusicTools(server); registerAllPulseTools(server); registerAllSandboxTools(server); registerAllSearchTools(server); diff --git a/lib/mcp/tools/music/index.ts b/lib/mcp/tools/music/index.ts new file mode 100644 index 00000000..d077c0c2 --- /dev/null +++ b/lib/mcp/tools/music/index.ts @@ -0,0 +1,21 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerComposeMusicTool } from "./registerComposeMusicTool"; +import { registerComposeDetailedMusicTool } from "./registerComposeDetailedMusicTool"; +import { registerStreamMusicTool } from "./registerStreamMusicTool"; +import { registerCreateCompositionPlanTool } from "./registerCreateCompositionPlanTool"; +import { registerVideoToMusicTool } from "./registerVideoToMusicTool"; +import { registerStemSeparationTool } from "./registerStemSeparationTool"; + +/** + * Registers all ElevenLabs music MCP tools. + * + * @param server - The MCP server instance. + */ +export function registerAllMusicTools(server: McpServer): void { + registerComposeMusicTool(server); + registerComposeDetailedMusicTool(server); + registerStreamMusicTool(server); + registerCreateCompositionPlanTool(server); + registerVideoToMusicTool(server); + registerStemSeparationTool(server); +} diff --git a/lib/mcp/tools/music/registerComposeDetailedMusicTool.ts b/lib/mcp/tools/music/registerComposeDetailedMusicTool.ts new file mode 100644 index 00000000..630ffd67 --- /dev/null +++ b/lib/mcp/tools/music/registerComposeDetailedMusicTool.ts @@ -0,0 +1,69 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { composeDetailedBodySchema } from "@/lib/elevenlabs/validateComposeDetailedBody"; +import { callElevenLabsMusic } from "@/lib/elevenlabs/callElevenLabsMusic"; + +/** + * Registers the compose_music_detailed MCP tool. + * Generates a song with detailed metadata and optional word timestamps. + * + * @param server - The MCP server instance. + */ +export function registerComposeDetailedMusicTool(server: McpServer): void { + server.registerTool( + "compose_music_detailed", + { + description: + "Generate a song with detailed metadata and optional word timestamps. " + + "Returns the song-id. Set with_timestamps to true for word-level timing data. " + + "Audio is available via the REST API.", + inputSchema: composeDetailedBodySchema, + }, + async ( + args: Record, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + const { output_format, ...body } = args as Record & { + output_format?: string; + }; + + try { + const upstream = await callElevenLabsMusic( + "/v1/music/detailed", + body, + output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError( + `Detailed music generation failed (${upstream.status}): ${errorText}`, + ); + } + + const songId = upstream.headers.get("song-id"); + return getToolResultSuccess({ + song_id: songId, + message: "Song generated with detailed metadata. Audio is available via POST /api/music/compose/detailed.", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Detailed music generation failed"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/mcp/tools/music/registerComposeMusicTool.ts b/lib/mcp/tools/music/registerComposeMusicTool.ts new file mode 100644 index 00000000..7e55473e --- /dev/null +++ b/lib/mcp/tools/music/registerComposeMusicTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { composeBodySchema } from "@/lib/elevenlabs/validateComposeBody"; +import { callElevenLabsMusic } from "@/lib/elevenlabs/callElevenLabsMusic"; + +/** + * Registers the compose_music MCP tool. + * Generates a song, saves audio to Supabase storage, and returns the URL. + * + * @param server - The MCP server instance. + */ +export function registerComposeMusicTool(server: McpServer): void { + server.registerTool( + "compose_music", + { + description: + "Generate a song from a text prompt or composition plan using ElevenLabs Music AI. " + + "Provide either a 'prompt' (text description) or a 'composition_plan' (structured plan from create_composition_plan), not both. " + + "Returns the song-id and audio content type. Use create_composition_plan first to preview the plan before spending credits.", + inputSchema: composeBodySchema, + }, + async ( + args: Record, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + const { output_format, ...body } = args as Record & { + output_format?: string; + }; + + try { + const upstream = await callElevenLabsMusic( + "/v1/music", + body, + output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError(`Music generation failed (${upstream.status}): ${errorText}`); + } + + const songId = upstream.headers.get("song-id"); + return getToolResultSuccess({ + song_id: songId, + message: "Song generated successfully. Audio is available via the REST API at POST /api/music/compose.", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Music generation failed"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/mcp/tools/music/registerCreateCompositionPlanTool.ts b/lib/mcp/tools/music/registerCreateCompositionPlanTool.ts new file mode 100644 index 00000000..4557db61 --- /dev/null +++ b/lib/mcp/tools/music/registerCreateCompositionPlanTool.ts @@ -0,0 +1,56 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { createPlanBodySchema } from "@/lib/elevenlabs/validateCreatePlanBody"; +import { callElevenLabsMusic } from "@/lib/elevenlabs/callElevenLabsMusic"; + +/** + * Registers the create_composition_plan MCP tool. + * Creates a detailed composition plan from a text prompt. + * Free — does not consume ElevenLabs credits. + * + * @param server - The MCP server instance. + */ +export function registerCreateCompositionPlanTool(server: McpServer): void { + server.registerTool( + "create_composition_plan", + { + description: + "Create a detailed composition plan from a text prompt. Free — does not consume credits. " + + "Use this before compose_music to preview and tweak the song structure, styles, and sections.", + inputSchema: createPlanBodySchema, + }, + async ( + args: Record, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const upstream = await callElevenLabsMusic("/v1/music/plan", args); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError(`Plan creation failed (${upstream.status}): ${errorText}`); + } + + const plan = await upstream.json(); + return getToolResultSuccess(plan); + } catch (err) { + const message = err instanceof Error ? err.message : "Plan creation failed"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/mcp/tools/music/registerStemSeparationTool.ts b/lib/mcp/tools/music/registerStemSeparationTool.ts new file mode 100644 index 00000000..66d6296b --- /dev/null +++ b/lib/mcp/tools/music/registerStemSeparationTool.ts @@ -0,0 +1,85 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { callElevenLabsMusicMultipart } from "@/lib/elevenlabs/callElevenLabsMusicMultipart"; +import { elevenLabsOutputFormatSchema } from "@/lib/elevenlabs/outputFormats"; + +const stemSeparationToolSchema = z.object({ + audio_url: z.string().url().describe("URL to the audio file to separate into stems."), + stem_variation_id: z + .enum(["two_stems_v1", "six_stems_v1"]) + .optional() + .default("six_stems_v1") + .describe("Two stems (vocals + accompaniment) or six stems (vocals, drums, bass, guitar, piano, other)."), + output_format: elevenLabsOutputFormatSchema.optional().describe("Audio output format for stems."), +}); + +/** + * Registers the separate_stems MCP tool. + * Accepts an audio URL, downloads it, separates into stems via ElevenLabs. + * + * @param server - The MCP server instance. + */ +export function registerStemSeparationTool(server: McpServer): void { + server.registerTool( + "separate_stems", + { + description: + "Separate an audio file into individual stems (vocals, drums, bass, etc.). " + + "Accepts an audio URL. Choose two_stems_v1 or six_stems_v1.", + inputSchema: stemSeparationToolSchema, + }, + async ( + args: z.infer, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const audioResponse = await fetch(args.audio_url); + if (!audioResponse.ok) { + return getToolResultError(`Failed to download audio from ${args.audio_url}`); + } + const blob = await audioResponse.blob(); + const filename = args.audio_url.split("/").pop() ?? "audio.mp3"; + + const formData = new FormData(); + formData.append("file", blob, filename); + formData.append("stem_variation_id", args.stem_variation_id); + + const upstream = await callElevenLabsMusicMultipart( + "/v1/music/stem-separation", + formData, + args.output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError( + `Stem separation failed (${upstream.status}): ${errorText}`, + ); + } + + return getToolResultSuccess({ + message: "Audio separated into stems. ZIP archive is available via POST /api/music/stem-separation.", + stem_variation: args.stem_variation_id, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Stem separation failed"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/mcp/tools/music/registerStreamMusicTool.ts b/lib/mcp/tools/music/registerStreamMusicTool.ts new file mode 100644 index 00000000..3cb1f302 --- /dev/null +++ b/lib/mcp/tools/music/registerStreamMusicTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { streamBodySchema } from "@/lib/elevenlabs/validateStreamBody"; +import { callElevenLabsMusic } from "@/lib/elevenlabs/callElevenLabsMusic"; + +/** + * Registers the stream_music MCP tool. + * Generates a song via the streaming endpoint. + * MCP tools can't stream, so this buffers the response and returns the song-id. + * + * @param server - The MCP server instance. + */ +export function registerStreamMusicTool(server: McpServer): void { + server.registerTool( + "stream_music", + { + description: + "Generate a song using the streaming endpoint. " + + "Returns the song-id. Streaming playback is only available via the REST API.", + inputSchema: streamBodySchema, + }, + async ( + args: Record, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + const { output_format, ...body } = args as Record & { + output_format?: string; + }; + + try { + const upstream = await callElevenLabsMusic( + "/v1/music/stream", + body, + output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError(`Music streaming failed (${upstream.status}): ${errorText}`); + } + + const songId = upstream.headers.get("song-id"); + return getToolResultSuccess({ + song_id: songId, + message: "Song generated successfully. Streaming playback is available via POST /api/music/stream.", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Music streaming failed"; + return getToolResultError(message); + } + }, + ); +} diff --git a/lib/mcp/tools/music/registerVideoToMusicTool.ts b/lib/mcp/tools/music/registerVideoToMusicTool.ts new file mode 100644 index 00000000..a4afcd75 --- /dev/null +++ b/lib/mcp/tools/music/registerVideoToMusicTool.ts @@ -0,0 +1,95 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { callElevenLabsMusicMultipart } from "@/lib/elevenlabs/callElevenLabsMusicMultipart"; +import { elevenLabsOutputFormatSchema } from "@/lib/elevenlabs/outputFormats"; + +const videoToMusicToolSchema = z.object({ + video_urls: z + .array(z.string().url()) + .min(1) + .max(10) + .describe("URLs to video files (1-10). Each will be downloaded and sent to ElevenLabs."), + description: z.string().max(1000).optional().describe("Description of desired music style."), + tags: z.array(z.string()).max(10).optional().describe("Style tags (max 10)."), + output_format: elevenLabsOutputFormatSchema.optional().describe("Audio output format."), +}); + +/** + * Registers the video_to_music MCP tool. + * Accepts video URLs, downloads them, and generates matching background music. + * + * @param server - The MCP server instance. + */ +export function registerVideoToMusicTool(server: McpServer): void { + server.registerTool( + "video_to_music", + { + description: + "Generate background music from video files. " + + "Accepts video URLs (1-10), downloads them, and generates matching music.", + inputSchema: videoToMusicToolSchema, + }, + async ( + args: z.infer, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const formData = new FormData(); + + for (const url of args.video_urls) { + const videoResponse = await fetch(url); + if (!videoResponse.ok) { + return getToolResultError(`Failed to download video from ${url}`); + } + const blob = await videoResponse.blob(); + const filename = url.split("/").pop() ?? "video.mp4"; + formData.append("videos", blob, filename); + } + + if (args.description) formData.append("description", args.description); + if (args.tags) { + for (const tag of args.tags) { + formData.append("tags", tag); + } + } + + const upstream = await callElevenLabsMusicMultipart( + "/v1/music/video-to-music", + formData, + args.output_format, + ); + + if (!upstream.ok) { + const errorText = await upstream.text().catch(() => "Unknown error"); + return getToolResultError( + `Video-to-music failed (${upstream.status}): ${errorText}`, + ); + } + + const songId = upstream.headers.get("song-id"); + return getToolResultSuccess({ + song_id: songId, + message: "Background music generated from video. Audio is available via POST /api/music/video-to-music.", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Video-to-music failed"; + return getToolResultError(message); + } + }, + ); +} From 951517540836398cb379ae4c180202f3f677cd3c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 06:00:06 -0400 Subject: [PATCH 2/2] refactor: DRY up ElevenLabs handlers and schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract createElevenLabsProxyHandler factory for compose/detailed/stream (3 handlers → 3 config objects, shared auth+validate+proxy+error logic) - Extract handleUpstreamError for shared upstream error handling - Extract buildUpstreamResponse for shared response construction - Extract musicGenerationBaseFields + promptOrPlanRefinements into schemas.ts (compose/detailed/stream validators now extend shared base instead of duplicating) - Use safeParseJson instead of duplicated try/catch JSON parsing - Net result: -55 lines, 0 duplicated handler logic Made-with: Cursor --- .../__tests__/composeHandler.test.ts | 11 +++ .../__tests__/createPlanHandler.test.ts | 9 +++ lib/elevenlabs/buildUpstreamResponse.ts | 32 +++++++++ lib/elevenlabs/composeDetailedHandler.ts | 70 ++----------------- lib/elevenlabs/composeHandler.ts | 64 ++--------------- lib/elevenlabs/createPlanHandler.ts | 23 ++---- lib/elevenlabs/handleElevenLabsProxy.ts | 67 ++++++++++++++++++ lib/elevenlabs/handleUpstreamError.ts | 25 +++++++ lib/elevenlabs/schemas.ts | 32 +++++++++ lib/elevenlabs/stemSeparationHandler.ts | 23 ++---- lib/elevenlabs/streamHandler.ts | 66 +++-------------- lib/elevenlabs/validateComposeBody.ts | 20 ++---- lib/elevenlabs/validateComposeDetailedBody.ts | 20 ++---- lib/elevenlabs/validateStreamBody.ts | 20 ++---- lib/elevenlabs/videoToMusicHandler.ts | 23 ++---- 15 files changed, 225 insertions(+), 280 deletions(-) create mode 100644 lib/elevenlabs/buildUpstreamResponse.ts create mode 100644 lib/elevenlabs/handleElevenLabsProxy.ts create mode 100644 lib/elevenlabs/handleUpstreamError.ts create mode 100644 lib/elevenlabs/schemas.ts diff --git a/lib/elevenlabs/__tests__/composeHandler.test.ts b/lib/elevenlabs/__tests__/composeHandler.test.ts index 65466735..89a1a230 100644 --- a/lib/elevenlabs/__tests__/composeHandler.test.ts +++ b/lib/elevenlabs/__tests__/composeHandler.test.ts @@ -9,12 +9,17 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + vi.mock("../callElevenLabsMusic", () => ({ callElevenLabsMusic: vi.fn(), })); import { composeHandler } from "../composeHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { callElevenLabsMusic } from "../callElevenLabsMusic"; describe("composeHandler", () => { @@ -29,6 +34,8 @@ describe("composeHandler", () => { authToken: "test-token", }); + vi.mocked(safeParseJson).mockResolvedValue({ prompt: "upbeat pop song" }); + vi.mocked(callElevenLabsMusic).mockResolvedValue( new Response("audio-bytes", { status: 200, @@ -58,6 +65,8 @@ describe("composeHandler", () => { authToken: "test-token", }); + vi.mocked(safeParseJson).mockResolvedValue({}); + const request = new NextRequest("http://localhost/api/music/compose", { method: "POST", body: JSON.stringify({}), @@ -90,6 +99,8 @@ describe("composeHandler", () => { authToken: "test-token", }); + vi.mocked(safeParseJson).mockResolvedValue({ prompt: "test" }); + vi.mocked(callElevenLabsMusic).mockResolvedValue( new Response("Internal error", { status: 500 }), ); diff --git a/lib/elevenlabs/__tests__/createPlanHandler.test.ts b/lib/elevenlabs/__tests__/createPlanHandler.test.ts index c0e9fd0a..e73a44e5 100644 --- a/lib/elevenlabs/__tests__/createPlanHandler.test.ts +++ b/lib/elevenlabs/__tests__/createPlanHandler.test.ts @@ -9,12 +9,17 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + vi.mock("../callElevenLabsMusic", () => ({ callElevenLabsMusic: vi.fn(), })); import { createPlanHandler } from "../createPlanHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { callElevenLabsMusic } from "../callElevenLabsMusic"; describe("createPlanHandler", () => { @@ -29,6 +34,8 @@ describe("createPlanHandler", () => { authToken: "test-token", }); + vi.mocked(safeParseJson).mockResolvedValue({ prompt: "cinematic piece" }); + const planData = { positive_global_styles: ["pop", "upbeat"], negative_global_styles: ["metal"], @@ -63,6 +70,8 @@ describe("createPlanHandler", () => { authToken: "test-token", }); + vi.mocked(safeParseJson).mockResolvedValue({}); + const request = new NextRequest("http://localhost/api/music/plan", { method: "POST", body: JSON.stringify({}), diff --git a/lib/elevenlabs/buildUpstreamResponse.ts b/lib/elevenlabs/buildUpstreamResponse.ts new file mode 100644 index 00000000..cbcc57f9 --- /dev/null +++ b/lib/elevenlabs/buildUpstreamResponse.ts @@ -0,0 +1,32 @@ +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Builds a passthrough Response from an upstream ElevenLabs response, + * forwarding the body stream, content-type, and song-id header. + * + * @param upstream - The successful upstream response. + * @param defaultContentType - Fallback content-type if upstream doesn't set one. + * @param forwardHeaders - Additional upstream headers to forward (e.g. "content-disposition"). + * @returns A Response that pipes the upstream body through. + */ +export function buildUpstreamResponse( + upstream: Response, + defaultContentType: string, + forwardHeaders: string[] = [], +): Response { + const contentType = upstream.headers.get("content-type") ?? defaultContentType; + const songId = upstream.headers.get("song-id"); + + const headers: Record = { + ...getCorsHeaders(), + "Content-Type": contentType, + }; + if (songId) headers["song-id"] = songId; + + for (const name of forwardHeaders) { + const value = upstream.headers.get(name); + if (value) headers[name] = value; + } + + return new Response(upstream.body, { status: 200, headers }); +} diff --git a/lib/elevenlabs/composeDetailedHandler.ts b/lib/elevenlabs/composeDetailedHandler.ts index 1f96bb0c..9ec5378d 100644 --- a/lib/elevenlabs/composeDetailedHandler.ts +++ b/lib/elevenlabs/composeDetailedHandler.ts @@ -1,70 +1,14 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { createElevenLabsProxyHandler } from "./handleElevenLabsProxy"; import { validateComposeDetailedBody } from "./validateComposeDetailedBody"; -import { callElevenLabsMusic } from "./callElevenLabsMusic"; /** * Handler for POST /api/music/compose/detailed. * Generates a song with metadata and optional word timestamps. * Proxies the multipart/mixed response from ElevenLabs directly. - * - * @param request - The incoming request with a JSON body. - * @returns The upstream multipart/mixed response or error JSON. */ -export async function composeDetailedHandler( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Request body must be valid JSON" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const validated = validateComposeDetailedBody(body); - if (validated instanceof NextResponse) return validated; - - const { output_format, ...elevenLabsBody } = validated; - - try { - const upstream = await callElevenLabsMusic( - "/v1/music/detailed", - elevenLabsBody, - output_format, - ); - - if (!upstream.ok) { - const errorText = await upstream.text().catch(() => "Unknown error"); - console.error(`ElevenLabs compose/detailed returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Music generation failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } - - const songId = upstream.headers.get("song-id"); - const contentType = upstream.headers.get("content-type") ?? "multipart/mixed"; - - const headers: Record = { - ...getCorsHeaders(), - "Content-Type": contentType, - }; - if (songId) headers["song-id"] = songId; - - return new Response(upstream.body, { status: 200, headers }); - } catch (error) { - console.error("ElevenLabs compose/detailed error:", error); - return NextResponse.json( - { status: "error", error: "Music generation failed" }, - { status: 500, headers: getCorsHeaders() }, - ); - } -} +export const composeDetailedHandler = createElevenLabsProxyHandler({ + path: "/v1/music/detailed", + validate: validateComposeDetailedBody, + defaultContentType: "multipart/mixed", + errorContext: "Music generation", +}); diff --git a/lib/elevenlabs/composeHandler.ts b/lib/elevenlabs/composeHandler.ts index 2ee8390a..265207e8 100644 --- a/lib/elevenlabs/composeHandler.ts +++ b/lib/elevenlabs/composeHandler.ts @@ -1,64 +1,14 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { createElevenLabsProxyHandler } from "./handleElevenLabsProxy"; import { validateComposeBody } from "./validateComposeBody"; -import { callElevenLabsMusic } from "./callElevenLabsMusic"; /** * Handler for POST /api/music/compose. * Generates a song from a text prompt or composition plan. * Returns binary audio with the song-id in response headers. - * - * @param request - The incoming request with a JSON body. - * @returns Binary audio response or error JSON. */ -export async function composeHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Request body must be valid JSON" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const validated = validateComposeBody(body); - if (validated instanceof NextResponse) return validated; - - const { output_format, ...elevenLabsBody } = validated; - - try { - const upstream = await callElevenLabsMusic("/v1/music", elevenLabsBody, output_format); - - if (!upstream.ok) { - const errorText = await upstream.text().catch(() => "Unknown error"); - console.error(`ElevenLabs compose returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Music generation failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } - - const songId = upstream.headers.get("song-id"); - const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; - - const headers: Record = { - ...getCorsHeaders(), - "Content-Type": contentType, - }; - if (songId) headers["song-id"] = songId; - - return new Response(upstream.body, { status: 200, headers }); - } catch (error) { - console.error("ElevenLabs compose error:", error); - return NextResponse.json( - { status: "error", error: "Music generation failed" }, - { status: 500, headers: getCorsHeaders() }, - ); - } -} +export const composeHandler = createElevenLabsProxyHandler({ + path: "/v1/music", + validate: validateComposeBody, + defaultContentType: "audio/mpeg", + errorContext: "Music generation", +}); diff --git a/lib/elevenlabs/createPlanHandler.ts b/lib/elevenlabs/createPlanHandler.ts index 1c47e208..f692b267 100644 --- a/lib/elevenlabs/createPlanHandler.ts +++ b/lib/elevenlabs/createPlanHandler.ts @@ -2,8 +2,10 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateCreatePlanBody } from "./validateCreatePlanBody"; import { callElevenLabsMusic } from "./callElevenLabsMusic"; +import { handleUpstreamError } from "./handleUpstreamError"; /** * Handler for POST /api/music/plan. @@ -17,30 +19,15 @@ export async function createPlanHandler(request: NextRequest): Promise "Unknown error"); - console.error(`ElevenLabs plan returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Plan creation failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } + const errorResponse = await handleUpstreamError(upstream, "Plan creation"); + if (errorResponse) return errorResponse; const plan = await upstream.json(); diff --git a/lib/elevenlabs/handleElevenLabsProxy.ts b/lib/elevenlabs/handleElevenLabsProxy.ts new file mode 100644 index 00000000..cd35e947 --- /dev/null +++ b/lib/elevenlabs/handleElevenLabsProxy.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { callElevenLabsMusic } from "./callElevenLabsMusic"; +import { handleUpstreamError } from "./handleUpstreamError"; +import { buildUpstreamResponse } from "./buildUpstreamResponse"; + +interface ElevenLabsProxyConfig { + /** ElevenLabs API path (e.g. "/v1/music") */ + path: string; + /** Zod-based validator that returns NextResponse on failure or validated data on success */ + validate: (body: unknown) => NextResponse | Record; + /** Fallback content-type if ElevenLabs doesn't set one */ + defaultContentType: string; + /** Prefix for error messages (e.g. "Music generation") */ + errorContext: string; + /** Extra response headers to include (e.g. Transfer-Encoding: chunked for streaming) */ + extraHeaders?: Record; +} + +/** + * Creates a handler that authenticates, validates JSON input, proxies to + * ElevenLabs, and returns the binary response with forwarded headers. + * Shared by compose, compose/detailed, and stream endpoints. + * + * @param config - The proxy configuration. + * @returns A Next.js request handler function. + */ +export function createElevenLabsProxyHandler(config: ElevenLabsProxyConfig) { + return async function (request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const body = await safeParseJson(request); + const validated = config.validate(body); + if (validated instanceof NextResponse) return validated; + + const { output_format, ...elevenLabsBody } = validated as Record & { + output_format?: string; + }; + + try { + const upstream = await callElevenLabsMusic(config.path, elevenLabsBody, output_format); + + const errorResponse = await handleUpstreamError(upstream, config.errorContext); + if (errorResponse) return errorResponse; + + const response = buildUpstreamResponse(upstream, config.defaultContentType); + + if (config.extraHeaders) { + for (const [key, value] of Object.entries(config.extraHeaders)) { + response.headers.set(key, value); + } + } + + return response; + } catch (error) { + console.error(`ElevenLabs ${config.path} error:`, error); + return NextResponse.json( + { status: "error", error: `${config.errorContext} failed` }, + { status: 500, headers: getCorsHeaders() }, + ); + } + }; +} diff --git a/lib/elevenlabs/handleUpstreamError.ts b/lib/elevenlabs/handleUpstreamError.ts new file mode 100644 index 00000000..13932dff --- /dev/null +++ b/lib/elevenlabs/handleUpstreamError.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Checks if an upstream ElevenLabs response failed and returns an error NextResponse. + * Returns null if the response is OK (caller should continue processing). + * + * @param upstream - The raw Response from ElevenLabs. + * @param context - Human-readable context for error messages (e.g. "Music generation"). + * @returns A NextResponse error, or null if the response is OK. + */ +export async function handleUpstreamError( + upstream: Response, + context: string, +): Promise { + if (upstream.ok) return null; + + const errorText = await upstream.text().catch(() => "Unknown error"); + console.error(`ElevenLabs ${context} returned ${upstream.status}: ${errorText}`); + + return NextResponse.json( + { status: "error", error: `${context} failed (status ${upstream.status})` }, + { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, + ); +} diff --git a/lib/elevenlabs/schemas.ts b/lib/elevenlabs/schemas.ts new file mode 100644 index 00000000..f14edffc --- /dev/null +++ b/lib/elevenlabs/schemas.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { compositionPlanSchema } from "./compositionPlanSchema"; +import { elevenLabsOutputFormatSchema } from "./outputFormats"; + +/** + * Base fields shared by compose, compose/detailed, and stream endpoints. + * Each endpoint extends this with its own additional fields. + */ +export const musicGenerationBaseFields = { + prompt: z.string().max(4100).nullable().optional(), + composition_plan: compositionPlanSchema.nullable().optional(), + music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), + model_id: z.enum(["music_v1"]).optional().default("music_v1"), + seed: z.number().int().min(0).max(2147483647).nullable().optional(), + force_instrumental: z.boolean().optional().default(false), + store_for_inpainting: z.boolean().optional().default(false), + output_format: elevenLabsOutputFormatSchema.optional(), +}; + +/** Refinement: exactly one of prompt or composition_plan must be provided. */ +export const promptOrPlanRefinements = [ + { + check: (data: { prompt?: string | null; composition_plan?: unknown }) => + !(data.prompt && data.composition_plan), + message: "Cannot use both prompt and composition_plan", + }, + { + check: (data: { prompt?: string | null; composition_plan?: unknown }) => + !!(data.prompt || data.composition_plan), + message: "Must provide either prompt or composition_plan", + }, +] as const; diff --git a/lib/elevenlabs/stemSeparationHandler.ts b/lib/elevenlabs/stemSeparationHandler.ts index 8ea79573..62b641f6 100644 --- a/lib/elevenlabs/stemSeparationHandler.ts +++ b/lib/elevenlabs/stemSeparationHandler.ts @@ -4,6 +4,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateStemSeparationBody } from "./validateStemSeparationBody"; import { callElevenLabsMusicMultipart } from "./callElevenLabsMusicMultipart"; +import { handleUpstreamError } from "./handleUpstreamError"; +import { buildUpstreamResponse } from "./buildUpstreamResponse"; /** * Handler for POST /api/music/stem-separation. @@ -66,25 +68,10 @@ export async function stemSeparationHandler( validated.output_format, ); - if (!upstream.ok) { - const errorText = await upstream.text().catch(() => "Unknown error"); - console.error(`ElevenLabs stem-separation returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Stem separation failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } - - const contentType = upstream.headers.get("content-type") ?? "application/zip"; - const contentDisposition = upstream.headers.get("content-disposition"); - - const headers: Record = { - ...getCorsHeaders(), - "Content-Type": contentType, - }; - if (contentDisposition) headers["Content-Disposition"] = contentDisposition; + const errorResponse = await handleUpstreamError(upstream, "Stem separation"); + if (errorResponse) return errorResponse; - return new Response(upstream.body, { status: 200, headers }); + return buildUpstreamResponse(upstream, "application/zip", ["content-disposition"]); } catch (error) { console.error("ElevenLabs stem-separation error:", error); return NextResponse.json( diff --git a/lib/elevenlabs/streamHandler.ts b/lib/elevenlabs/streamHandler.ts index 7a72a7c4..42db5585 100644 --- a/lib/elevenlabs/streamHandler.ts +++ b/lib/elevenlabs/streamHandler.ts @@ -1,65 +1,15 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { createElevenLabsProxyHandler } from "./handleElevenLabsProxy"; import { validateStreamBody } from "./validateStreamBody"; -import { callElevenLabsMusic } from "./callElevenLabsMusic"; /** * Handler for POST /api/music/stream. * Generates a song and streams audio chunks directly to the client. * Does not buffer — pipes the upstream readable stream through. - * - * @param request - The incoming request with a JSON body. - * @returns A streaming audio response or error JSON. */ -export async function streamHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Request body must be valid JSON" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const validated = validateStreamBody(body); - if (validated instanceof NextResponse) return validated; - - const { output_format, ...elevenLabsBody } = validated; - - try { - const upstream = await callElevenLabsMusic("/v1/music/stream", elevenLabsBody, output_format); - - if (!upstream.ok) { - const errorText = await upstream.text().catch(() => "Unknown error"); - console.error(`ElevenLabs stream returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Music streaming failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } - - const songId = upstream.headers.get("song-id"); - const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; - - const headers: Record = { - ...getCorsHeaders(), - "Content-Type": contentType, - "Transfer-Encoding": "chunked", - }; - if (songId) headers["song-id"] = songId; - - return new Response(upstream.body, { status: 200, headers }); - } catch (error) { - console.error("ElevenLabs stream error:", error); - return NextResponse.json( - { status: "error", error: "Music streaming failed" }, - { status: 500, headers: getCorsHeaders() }, - ); - } -} +export const streamHandler = createElevenLabsProxyHandler({ + path: "/v1/music/stream", + validate: validateStreamBody, + defaultContentType: "audio/mpeg", + errorContext: "Music streaming", + extraHeaders: { "Transfer-Encoding": "chunked" }, +}); diff --git a/lib/elevenlabs/validateComposeBody.ts b/lib/elevenlabs/validateComposeBody.ts index ec849fd8..72e3feab 100644 --- a/lib/elevenlabs/validateComposeBody.ts +++ b/lib/elevenlabs/validateComposeBody.ts @@ -1,28 +1,16 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -import { compositionPlanSchema } from "./compositionPlanSchema"; -import { elevenLabsOutputFormatSchema } from "./outputFormats"; +import { musicGenerationBaseFields, promptOrPlanRefinements } from "./schemas"; export const composeBodySchema = z .object({ - prompt: z.string().max(4100).nullable().optional(), - composition_plan: compositionPlanSchema.nullable().optional(), - music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), - model_id: z.enum(["music_v1"]).optional().default("music_v1"), - seed: z.number().int().min(0).max(2147483647).nullable().optional(), - force_instrumental: z.boolean().optional().default(false), + ...musicGenerationBaseFields, respect_sections_durations: z.boolean().optional().default(true), - store_for_inpainting: z.boolean().optional().default(false), sign_with_c2pa: z.boolean().optional().default(false), - output_format: elevenLabsOutputFormatSchema.optional(), }) - .refine((data) => !(data.prompt && data.composition_plan), { - message: "Cannot use both prompt and composition_plan", - }) - .refine((data) => data.prompt || data.composition_plan, { - message: "Must provide either prompt or composition_plan", - }); + .refine(promptOrPlanRefinements[0].check, { message: promptOrPlanRefinements[0].message }) + .refine(promptOrPlanRefinements[1].check, { message: promptOrPlanRefinements[1].message }); export type ComposeBody = z.infer; diff --git a/lib/elevenlabs/validateComposeDetailedBody.ts b/lib/elevenlabs/validateComposeDetailedBody.ts index ecaa4f24..210e7aa5 100644 --- a/lib/elevenlabs/validateComposeDetailedBody.ts +++ b/lib/elevenlabs/validateComposeDetailedBody.ts @@ -1,29 +1,17 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -import { compositionPlanSchema } from "./compositionPlanSchema"; -import { elevenLabsOutputFormatSchema } from "./outputFormats"; +import { musicGenerationBaseFields, promptOrPlanRefinements } from "./schemas"; export const composeDetailedBodySchema = z .object({ - prompt: z.string().max(4100).nullable().optional(), - composition_plan: compositionPlanSchema.nullable().optional(), - music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), - model_id: z.enum(["music_v1"]).optional().default("music_v1"), - seed: z.number().int().min(0).max(2147483647).nullable().optional(), - force_instrumental: z.boolean().optional().default(false), + ...musicGenerationBaseFields, respect_sections_durations: z.boolean().optional().default(true), - store_for_inpainting: z.boolean().optional().default(false), sign_with_c2pa: z.boolean().optional().default(false), with_timestamps: z.boolean().optional().default(false), - output_format: elevenLabsOutputFormatSchema.optional(), }) - .refine((data) => !(data.prompt && data.composition_plan), { - message: "Cannot use both prompt and composition_plan", - }) - .refine((data) => data.prompt || data.composition_plan, { - message: "Must provide either prompt or composition_plan", - }); + .refine(promptOrPlanRefinements[0].check, { message: promptOrPlanRefinements[0].message }) + .refine(promptOrPlanRefinements[1].check, { message: promptOrPlanRefinements[1].message }); export type ComposeDetailedBody = z.infer; diff --git a/lib/elevenlabs/validateStreamBody.ts b/lib/elevenlabs/validateStreamBody.ts index 610d05d8..64e7be7e 100644 --- a/lib/elevenlabs/validateStreamBody.ts +++ b/lib/elevenlabs/validateStreamBody.ts @@ -1,26 +1,14 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -import { compositionPlanSchema } from "./compositionPlanSchema"; -import { elevenLabsOutputFormatSchema } from "./outputFormats"; +import { musicGenerationBaseFields, promptOrPlanRefinements } from "./schemas"; export const streamBodySchema = z .object({ - prompt: z.string().max(4100).nullable().optional(), - composition_plan: compositionPlanSchema.nullable().optional(), - music_length_ms: z.number().int().min(3000).max(600000).nullable().optional(), - model_id: z.enum(["music_v1"]).optional().default("music_v1"), - seed: z.number().int().min(0).max(2147483647).nullable().optional(), - force_instrumental: z.boolean().optional().default(false), - store_for_inpainting: z.boolean().optional().default(false), - output_format: elevenLabsOutputFormatSchema.optional(), + ...musicGenerationBaseFields, }) - .refine((data) => !(data.prompt && data.composition_plan), { - message: "Cannot use both prompt and composition_plan", - }) - .refine((data) => data.prompt || data.composition_plan, { - message: "Must provide either prompt or composition_plan", - }); + .refine(promptOrPlanRefinements[0].check, { message: promptOrPlanRefinements[0].message }) + .refine(promptOrPlanRefinements[1].check, { message: promptOrPlanRefinements[1].message }); export type StreamBody = z.infer; diff --git a/lib/elevenlabs/videoToMusicHandler.ts b/lib/elevenlabs/videoToMusicHandler.ts index 8722c2dc..838c15b2 100644 --- a/lib/elevenlabs/videoToMusicHandler.ts +++ b/lib/elevenlabs/videoToMusicHandler.ts @@ -4,6 +4,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateVideoToMusicBody } from "./validateVideoToMusicBody"; import { callElevenLabsMusicMultipart } from "./callElevenLabsMusicMultipart"; +import { handleUpstreamError } from "./handleUpstreamError"; +import { buildUpstreamResponse } from "./buildUpstreamResponse"; const MAX_TOTAL_SIZE = 200 * 1024 * 1024; // 200MB const MAX_FILES = 10; @@ -89,25 +91,10 @@ export async function videoToMusicHandler(request: NextRequest): Promise "Unknown error"); - console.error(`ElevenLabs video-to-music returned ${upstream.status}: ${errorText}`); - return NextResponse.json( - { status: "error", error: `Video-to-music failed (status ${upstream.status})` }, - { status: upstream.status >= 500 ? 502 : upstream.status, headers: getCorsHeaders() }, - ); - } - - const songId = upstream.headers.get("song-id"); - const contentType = upstream.headers.get("content-type") ?? "audio/mpeg"; - - const headers: Record = { - ...getCorsHeaders(), - "Content-Type": contentType, - }; - if (songId) headers["song-id"] = songId; + const errorResponse = await handleUpstreamError(upstream, "Video-to-music"); + if (errorResponse) return errorResponse; - return new Response(upstream.body, { status: 200, headers }); + return buildUpstreamResponse(upstream, "audio/mpeg"); } catch (error) { console.error("ElevenLabs video-to-music error:", error); return NextResponse.json(