Skip to content

Commit 88cbeb8

Browse files
committed
feat(aesthetic): distinct grand-hotel music + better cyber-fixer voice
- grand-hotel no longer reuses noir's jazz loop: music.src now points at a new lazy-generated /api/music/grand-hotel/atmosphere route that renders the world's musicStylePrompt (1920s lobby band) via ElevenLabs Music on first request, caches to disk, and falls back to the noir loop without a key — mirroring the existing lazy SFX pipeline. - cyber-fixer voice: replace the stock 'Adam' (generic-AI sounding) with Matt (natural, hyper-conversational) and retune voiceDirection for a natural voice (was tuned for the robotic one).
1 parent 200f50a commit 88cbeb8

5 files changed

Lines changed: 305 additions & 5 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
// Mock the disk-cache store so tests don't touch the filesystem.
5+
const mockReadAtmosphereMusic = vi.fn();
6+
const mockSaveAtmosphereMusic = vi.fn();
7+
const mockDeleteAtmosphereMusic = vi.fn();
8+
vi.mock("@/lib/ai/atmosphereMusicStore", () => ({
9+
readAtmosphereMusic: (world: string) => mockReadAtmosphereMusic(world),
10+
saveAtmosphereMusic: (world: string, data: Buffer) => mockSaveAtmosphereMusic(world, data),
11+
deleteAtmosphereMusic: (world: string) => mockDeleteAtmosphereMusic(world),
12+
}));
13+
14+
// Mock the ElevenLabs client so no network call is made.
15+
const mockElevenLabsFetch = vi.fn();
16+
vi.mock("@/lib/elevenlabs/client", () => ({
17+
elevenLabsFetch: (path: string, init?: RequestInit) => mockElevenLabsFetch(path, init),
18+
}));
19+
20+
function makeRequest(url: string): NextRequest {
21+
return new NextRequest(new Request(url));
22+
}
23+
24+
describe("/api/music/[world]/atmosphere", () => {
25+
beforeEach(() => {
26+
mockReadAtmosphereMusic.mockReset();
27+
mockSaveAtmosphereMusic.mockReset();
28+
mockDeleteAtmosphereMusic.mockReset();
29+
mockElevenLabsFetch.mockReset();
30+
delete process.env.ELEVENLABS_API_KEY;
31+
});
32+
33+
it("returns cached bytes immutable on a cache hit", async () => {
34+
mockReadAtmosphereMusic.mockResolvedValueOnce({
35+
data: Buffer.from("cached-music"),
36+
contentType: "audio/mpeg",
37+
});
38+
const { GET } = await import("./route");
39+
40+
const response = await GET(makeRequest("http://localhost/api/music/grand-hotel/atmosphere"), {
41+
params: Promise.resolve({ world: "grand-hotel" }),
42+
});
43+
44+
expect(response.status).toBe(200);
45+
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
46+
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
47+
expect(Buffer.from(await response.arrayBuffer()).toString()).toBe("cached-music");
48+
expect(mockElevenLabsFetch).not.toHaveBeenCalled();
49+
});
50+
51+
it("redirects to the noir fallback when no key is configured", async () => {
52+
mockReadAtmosphereMusic.mockResolvedValueOnce(null);
53+
const { GET } = await import("./route");
54+
55+
const response = await GET(makeRequest("http://localhost/api/music/grand-hotel/atmosphere"), {
56+
params: Promise.resolve({ world: "grand-hotel" }),
57+
});
58+
59+
expect(response.status).toBe(307);
60+
expect(response.headers.get("Location")).toContain("/assets/noir/noir-jazz-loop.mp3");
61+
expect(mockElevenLabsFetch).not.toHaveBeenCalled();
62+
});
63+
64+
it("generates, caches, and serves bytes when a key is configured", async () => {
65+
process.env.ELEVENLABS_API_KEY = "test-key";
66+
mockReadAtmosphereMusic.mockResolvedValueOnce(null);
67+
mockElevenLabsFetch.mockResolvedValueOnce({
68+
ok: true,
69+
arrayBuffer: async () => Buffer.from("fresh-music"),
70+
});
71+
const { GET } = await import("./route");
72+
73+
const response = await GET(makeRequest("http://localhost/api/music/grand-hotel/atmosphere"), {
74+
params: Promise.resolve({ world: "grand-hotel" }),
75+
});
76+
77+
expect(response.status).toBe(200);
78+
expect(Buffer.from(await response.arrayBuffer()).toString()).toBe("fresh-music");
79+
expect(mockElevenLabsFetch).toHaveBeenCalledWith(
80+
"/music",
81+
expect.objectContaining({ method: "POST" })
82+
);
83+
expect(mockSaveAtmosphereMusic).toHaveBeenCalledWith("grand-hotel", expect.anything());
84+
});
85+
86+
it("404s for an unknown world", async () => {
87+
const { GET } = await import("./route");
88+
89+
const response = await GET(makeRequest("http://localhost/api/music/not-a-world/atmosphere"), {
90+
params: Promise.resolve({ world: "not-a-world" }),
91+
});
92+
93+
expect(response.status).toBe(404);
94+
expect(mockReadAtmosphereMusic).not.toHaveBeenCalled();
95+
});
96+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { NextRequest } from "next/server";
2+
import { Buffer } from "node:buffer";
3+
import { isBuiltInAestheticId } from "@/lib/aesthetic/types";
4+
import { getAestheticDefinition } from "@/lib/aesthetic/definitions";
5+
import { elevenLabsFetch } from "@/lib/elevenlabs/client";
6+
import {
7+
readAtmosphereMusic,
8+
saveAtmosphereMusic,
9+
deleteAtmosphereMusic,
10+
} from "@/lib/ai/atmosphereMusicStore";
11+
12+
export const runtime = "nodejs";
13+
14+
/** Recorded noir score served when generation is unavailable. */
15+
const FALLBACK_MUSIC = "/assets/noir/noir-jazz-loop.mp3";
16+
17+
/**
18+
* Lazy generated per-world atmosphere music — the music twin of the SFX route.
19+
*
20+
* A world's music src may point at `/api/music/<world>/atmosphere` instead of a
21+
* recorded loop (grand-hotel does). On first request the world's
22+
* `identity.musicStylePrompt` is rendered through the ElevenLabs Music API,
23+
* cached to disk, and served immutable. Without an ELEVENLABS_API_KEY (or on
24+
* failure) the request redirects to the noir fallback loop so music never
25+
* hard-breaks.
26+
*
27+
* `?regen=1` busts the cache and re-renders the score.
28+
*/
29+
export async function GET(
30+
request: NextRequest,
31+
{ params }: { params: Promise<{ world: string }> }
32+
): Promise<Response> {
33+
const { world } = await params;
34+
// The route accepts `<world>` or `<world>.mp3` (audio elements like a real ext).
35+
const cleanWorld = (world ?? "").replace(/\.mp3$/, "");
36+
if (!isBuiltInAestheticId(cleanWorld)) {
37+
return new Response("Not found", { status: 404 });
38+
}
39+
40+
const regen = request.nextUrl.searchParams.get("regen") === "1";
41+
42+
if (regen) {
43+
await deleteAtmosphereMusic(cleanWorld);
44+
} else {
45+
const cached = await readAtmosphereMusic(cleanWorld);
46+
if (cached) {
47+
return new Response(new Uint8Array(cached.data), {
48+
status: 200,
49+
headers: {
50+
"Content-Type": cached.contentType,
51+
"Cache-Control": "public, max-age=31536000, immutable",
52+
},
53+
});
54+
}
55+
}
56+
57+
const prompt = getAestheticDefinition(cleanWorld).identity.musicStylePrompt;
58+
const apiKey = process.env.ELEVENLABS_API_KEY;
59+
60+
if (apiKey && prompt) {
61+
try {
62+
const response = await elevenLabsFetch("/music", {
63+
method: "POST",
64+
headers: { "Content-Type": "application/json" },
65+
body: JSON.stringify({
66+
prompt,
67+
music_length_ms: 30000,
68+
force_instrumental: true,
69+
}),
70+
});
71+
if (response.ok) {
72+
const data = Buffer.from(await response.arrayBuffer());
73+
await saveAtmosphereMusic(cleanWorld, data);
74+
return new Response(new Uint8Array(data), {
75+
status: 200,
76+
headers: {
77+
"Content-Type": "audio/mpeg",
78+
"Cache-Control": "public, max-age=31536000, immutable",
79+
},
80+
});
81+
}
82+
console.error(
83+
`[atmosphere-music] generation failed for ${cleanWorld}:`,
84+
response.status,
85+
await response.text().catch(() => "")
86+
);
87+
} catch (error) {
88+
console.error(`[atmosphere-music] generation error for ${cleanWorld}:`, error);
89+
}
90+
}
91+
92+
// No key / generation failed — fall back to the recorded noir loop so the
93+
// world still has a score. Temporary redirect: a later request retries
94+
// generation instead of caching the fallback forever.
95+
return Response.redirect(new URL(FALLBACK_MUSIC, request.nextUrl.origin), 307);
96+
}

src/lib/aesthetic/definitions.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -422,14 +422,19 @@ const cyberFixer: AestheticDefinition = {
422422
generate: "inject grid load",
423423
error: "connection dropped",
424424
},
425-
voiceId: "pNInz6obpgDQGcFmaJgB",
425+
// Matt — natural, hyper-conversational American male. Replaces the stock
426+
// "Adam" voice, which read as a generic/robotic TTS for the streetwise fixer.
427+
voiceId: "pwMBn0SsmN1220Aorv15",
426428
imageStylePrompt:
427429
"cyberpunk aesthetic, futuristic cyberpunk command center, neon lights, high-tech overlays, glowing digital HUD, cybernetic implants, wireframe graphics, cyan and magenta accents, rainy futuristic cityscape window, synthwave style, no text or watermark",
428430
// Dense neon/HUD detail — the higher-fidelity Gemini Pro image model.
429431
imageModel: "gemini-3-pro-image",
430432
identity: {
431433
glowStrength: 1.4,
432-
voiceDirection: { stability: 0.2, similarityBoost: 0.6, style: 0.8, speed: 1.1 },
434+
// Tuned for Matt (a natural conversational voice): moderate stability so he
435+
// stays steady, modest style so the delivery reads as a real person with
436+
// edge rather than an exaggerated AI, and a slight speed lift for fixer energy.
437+
voiceDirection: { stability: 0.45, similarityBoost: 0.75, style: 0.35, speed: 1.08 },
433438
musicStylePrompt:
434439
"dark synthwave — driving arpeggiated bass, neon pads, gated reverb drums, 110bpm, retro-futuristic and propulsive, Blade Runner street energy",
435440
musicPresets: [
@@ -962,15 +967,17 @@ const grandHotel: AestheticDefinition = {
962967
// The hotel's foley is GENERATED: each src hits /api/sfx/grand-hotel/<kind>,
963968
// which renders the identity.sfxPrompts recipe through ElevenLabs text-to-SFX
964969
// on first request, caches it to disk, and falls back to the noir assets when
965-
// no ELEVENLABS_API_KEY is configured. Music still reuses the jazz loop (the
966-
// Composer Lab generates real scores on demand).
970+
// no ELEVENLABS_API_KEY is configured. Music is likewise GENERATED: its src
971+
// hits /api/music/grand-hotel/atmosphere, which renders the
972+
// identity.musicStylePrompt through the ElevenLabs Music API on first request
973+
// and falls back to the noir jazz loop when no key is configured.
967974
audio: {
968975
sfx: {
969976
typewriter: { src: "/api/sfx/grand-hotel/typewriter.mp3", volume: 0.55 },
970977
thunder: { src: "/api/sfx/grand-hotel/thunder.mp3", volume: 0.6 },
971978
phone: { src: "/api/sfx/grand-hotel/phone.mp3", volume: 0.65 },
972979
},
973-
music: { src: "/assets/noir/noir-jazz-loop.mp3", volume: 0.2 },
980+
music: { src: "/api/music/grand-hotel/atmosphere", volume: 0.2 },
974981
ambient: {
975982
rain: {
976983
src: "/api/sfx/grand-hotel/ambient.mp3",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// @vitest-environment node
2+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
3+
import { promises as fs } from "node:fs";
4+
import path from "node:path";
5+
import os from "node:os";
6+
import {
7+
saveAtmosphereMusic,
8+
readAtmosphereMusic,
9+
deleteAtmosphereMusic,
10+
} from "./atmosphereMusicStore";
11+
12+
describe("atmosphereMusicStore", () => {
13+
const originalDir = process.env.A2UI_ATMOSPHERE_MUSIC_DIR;
14+
let tempDir = "";
15+
16+
beforeEach(async () => {
17+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "a2ui-atmosphere-music-"));
18+
process.env.A2UI_ATMOSPHERE_MUSIC_DIR = tempDir;
19+
});
20+
21+
afterEach(async () => {
22+
process.env.A2UI_ATMOSPHERE_MUSIC_DIR = originalDir;
23+
if (tempDir) {
24+
await fs.rm(tempDir, { recursive: true, force: true });
25+
}
26+
});
27+
28+
it("saves, reads, and deletes a world's music (round-trip)", async () => {
29+
const dummyBuffer = Buffer.from("dummy-music-content");
30+
const saved = await saveAtmosphereMusic("grand-hotel", dummyBuffer);
31+
expect(saved).toBe(true);
32+
33+
const read = await readAtmosphereMusic("grand-hotel");
34+
expect(read?.contentType).toBe("audio/mpeg");
35+
expect(read?.data.toString()).toBe("dummy-music-content");
36+
37+
await deleteAtmosphereMusic("grand-hotel");
38+
expect(await readAtmosphereMusic("grand-hotel")).toBeNull();
39+
});
40+
41+
it("returns null if a world has no cached music", async () => {
42+
expect(await readAtmosphereMusic("noir")).toBeNull();
43+
});
44+
45+
it("rejects slugs that aren't simple names (path-traversal guard)", async () => {
46+
const saved = await saveAtmosphereMusic("../escape", Buffer.from("x"));
47+
expect(saved).toBe(false);
48+
expect(await readAtmosphereMusic("../escape")).toBeNull();
49+
});
50+
});

src/lib/ai/atmosphereMusicStore.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import "server-only";
2+
3+
import path from "node:path";
4+
import fs from "node:fs/promises";
5+
import { Buffer } from "node:buffer";
6+
7+
/**
8+
* Disk cache for generated per-world atmosphere music (ElevenLabs Music),
9+
* mirroring the SFX store: one file per <world>, generated lazily on first
10+
* request by /api/music/[world]/atmosphere and immutable afterwards.
11+
*/
12+
13+
function getAtmosphereMusicStoreDir() {
14+
return (
15+
process.env.A2UI_ATMOSPHERE_MUSIC_DIR ?? path.join(process.cwd(), ".data", "atmosphere-music")
16+
);
17+
}
18+
19+
/** Only allow simple slug names so the path can't escape the store dir. */
20+
function isSafeSlug(value: string): boolean {
21+
return /^[a-z0-9-]{1,64}$/.test(value);
22+
}
23+
24+
function atmosphereMusicFilePath(world: string): string | null {
25+
if (!isSafeSlug(world)) return null;
26+
return path.join(getAtmosphereMusicStoreDir(), `${world}.mp3`);
27+
}
28+
29+
export async function readAtmosphereMusic(
30+
world: string
31+
): Promise<{ data: Buffer; contentType: string } | null> {
32+
const filePath = atmosphereMusicFilePath(world);
33+
if (!filePath) return null;
34+
const data = await fs.readFile(filePath).catch(() => null);
35+
if (!data) return null;
36+
return { data, contentType: "audio/mpeg" };
37+
}
38+
39+
export async function saveAtmosphereMusic(world: string, data: Buffer): Promise<boolean> {
40+
const filePath = atmosphereMusicFilePath(world);
41+
if (!filePath) return false;
42+
await fs.mkdir(getAtmosphereMusicStoreDir(), { recursive: true });
43+
await fs.writeFile(filePath, data);
44+
return true;
45+
}
46+
47+
export async function deleteAtmosphereMusic(world: string): Promise<void> {
48+
const filePath = atmosphereMusicFilePath(world);
49+
if (!filePath) return;
50+
await fs.unlink(filePath).catch(() => null);
51+
}

0 commit comments

Comments
 (0)