This guide shows you how to extend Resonance with new features, voices, TTS parameters, and API endpoints.
Adding System Voices
System voices are pre-loaded voices available to all users. Follow this workflow to add new system voices.
Add Voice Audio File
Place the audio file in scripts/system-voices/:scripts/system-voices/
├── Aaron.wav
├── Abigail.wav
└── NewVoice.wav # Your new voice
Requirements:
- Format: WAV, MP3, or other audio format
- Duration: Minimum 10 seconds
- Size: Under 20 MB
Add Voice Metadata
Edit scripts/seed-system-voices.ts and add metadata:const systemVoiceMetadata: Record<string, VoiceMetadata> = {
// ... existing voices
NewVoice: {
description: "Professional and authoritative with clear diction",
category: "CORPORATE",
language: "en-US",
},
};
Register Voice Name
Add to the canonical list in src/features/voices/data/voice-scoping.ts:export const CANONICAL_SYSTEM_VOICE_NAMES = [
"Aaron",
"Abigail",
// ...
"NewVoice",
] as const;
Run Seed Script
This will:
- Create database record
- Upload audio to R2
- Update voice with R2 object key
The seed script is idempotent - it updates existing voices instead of creating duplicates.
Adding Custom Voice Categories
Voice categories help users filter and discover voices.
Update Prisma Schema
Edit prisma/schema.prisma and add to the enum:enum VoiceCategory {
AUDIOBOOK
CONVERSATIONAL
// ... existing categories
DOCUMENTARY // New category
EDUCATIONAL // New category
}
Create Migration
npx prisma migrate dev --name add_voice_categories
npx prisma generate
Add Category Labels
Update src/features/voices/data/voice-categories.ts:export const VOICE_CATEGORY_LABELS: Record<VoiceCategory, string> = {
AUDIOBOOK: "Audiobook",
// ... existing labels
DOCUMENTARY: "Documentary",
EDUCATIONAL: "Educational",
};
Update UI (Optional)
Categories are automatically available in dropdowns via VOICE_CATEGORIES array.
Modifying TTS Parameters
TTS parameters control the generation quality and characteristics.
Current Parameters
// src/trpc/routers/generations.ts
create: orgProcedure
.input(
z.object({
text: z.string().min(1).max(TEXT_MAX_LENGTH),
voiceId: z.string().min(1),
temperature: z.number().min(0).max(2).default(0.8),
topP: z.number().min(0).max(1).default(0.95),
topK: z.number().min(1).max(10000).default(1000),
repetitionPenalty: z.number().min(1).max(2).default(1.2),
})
)
Adding New Parameters
Example: Adding a speed parameter to control speech rate.
Update Database Schema
model Generation {
// ... existing fields
speed Float @default(1.0) // 0.5 = slow, 2.0 = fast
}
npx prisma migrate dev --name add_generation_speed
npx prisma generate
Update tRPC Input Schema
// src/trpc/routers/generations.ts
create: orgProcedure
.input(
z.object({
// ... existing parameters
speed: z.number().min(0.5).max(2.0).default(1.0),
})
)
Pass to TTS API
const { data, error } = await chatterbox.POST("/generate", {
body: {
// ... existing params
speed: input.speed,
},
});
Store in Database
const generation = await prisma.generation.create({
data: {
// ... existing fields
speed: input.speed,
},
});
Add UI Control
Update the settings panel in src/features/text-to-speech/components/settings-panel-settings.tsx:<FormField
control={form.control}
name="speed"
render={({ field }) => (
<FormItem>
<FormLabel>Speech Speed</FormLabel>
<FormControl>
<Slider
min={0.5}
max={2.0}
step={0.1}
value={[field.value]}
onValueChange={(vals) => field.onChange(vals[0])}
/>
</FormControl>
<FormDescription>
0.5 = slow, 1.0 = normal, 2.0 = fast
</FormDescription>
</FormItem>
)}
/>
Parameters are automatically type-safe across the stack thanks to tRPC and Prisma.
Creating New tRPC Routers
Add new API endpoints by creating tRPC routers.
Example: Adding a “Favorites” Feature
Create Database Model
model Favorite {
id String @id @default(cuid())
orgId String
generationId String
generation Generation @relation(fields: [generationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([orgId, generationId])
@@index([orgId])
}
model Generation {
// ... existing fields
favorites Favorite[]
}
npx prisma migrate dev --name add_favorites
npx prisma generate
Create Router File
Create src/trpc/routers/favorites.ts:import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { prisma } from "@/lib/db";
import { createTRPCRouter, orgProcedure } from "../init";
export const favoritesRouter = createTRPCRouter({
getAll: orgProcedure.query(async ({ ctx }) => {
const favorites = await prisma.favorite.findMany({
where: { orgId: ctx.orgId },
include: {
generation: {
select: {
id: true,
text: true,
voiceName: true,
createdAt: true,
},
},
},
orderBy: { createdAt: "desc" },
});
return favorites;
}),
toggle: orgProcedure
.input(z.object({ generationId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check if already favorited
const existing = await prisma.favorite.findUnique({
where: {
orgId_generationId: {
orgId: ctx.orgId,
generationId: input.generationId,
},
},
});
if (existing) {
// Remove favorite
await prisma.favorite.delete({
where: { id: existing.id },
});
return { favorited: false };
} else {
// Add favorite
await prisma.favorite.create({
data: {
orgId: ctx.orgId,
generationId: input.generationId,
},
});
return { favorited: true };
}
}),
});
Register Router
Update src/trpc/routers/_app.ts:import { favoritesRouter } from './favorites';
export const appRouter = createTRPCRouter({
voices: voicesRouter,
generations: generationsRouter,
billing: billingRouter,
favorites: favoritesRouter, // Add new router
});
Use in Client
// In a component
import { trpc } from "@/trpc/client";
function FavoritesPanel() {
const { data: favorites } = trpc.favorites.getAll.useQuery();
const toggleMutation = trpc.favorites.toggle.useMutation();
const handleToggle = (generationId: string) => {
toggleMutation.mutate({ generationId });
};
return (
<div>
{favorites?.map((fav) => (
<div key={fav.id}>
{fav.generation.text}
<button onClick={() => handleToggle(fav.generationId)}>
Unfavorite
</button>
</div>
))}
</div>
);
}
tRPC automatically infers types, so favorites is fully typed without manual type annotations.
Adding Feature Modules
Create self-contained feature modules for new functionality.
Example: Adding an “Analytics” Feature
Create Feature Directory
mkdir -p src/features/analytics/{components,views,hooks,data}
Create Data Layer
// src/features/analytics/data/constants.ts
export const DATE_RANGE_OPTIONS = [
{ label: "Last 7 days", value: 7 },
{ label: "Last 30 days", value: 30 },
{ label: "Last 90 days", value: 90 },
] as const;
Create Hook
// src/features/analytics/hooks/use-analytics.ts
import { trpc } from "@/trpc/client";
export function useAnalytics(dateRange: number) {
const { data, isLoading } = trpc.analytics.getStats.useQuery({
days: dateRange,
});
return { stats: data, isLoading };
}
Create Components
// src/features/analytics/components/stats-card.tsx
export function StatsCard({ title, value, trend }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<div className="text-sm text-muted-foreground">{trend}</div>
</CardContent>
</Card>
);
}
Create View
// src/features/analytics/views/analytics-view.tsx
import { useState } from "react";
import { StatsCard } from "../components/stats-card";
import { useAnalytics } from "../hooks/use-analytics";
import { DATE_RANGE_OPTIONS } from "../data/constants";
export function AnalyticsView() {
const [range, setRange] = useState(30);
const { stats, isLoading } = useAnalytics(range);
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>Analytics</h1>
<div className="grid grid-cols-3 gap-4">
<StatsCard
title="Total Generations"
value={stats.totalGenerations}
trend="+12% from last period"
/>
{/* More stats */}
</div>
</div>
);
}
Add Route
// src/app/(dashboard)/analytics/page.tsx
import type { Metadata } from "next";
import { AnalyticsView } from "@/features/analytics/views/analytics-view";
import { trpc, HydrateClient, prefetch } from "@/trpc/server";
export const metadata: Metadata = { title: "Analytics" };
export default async function AnalyticsPage() {
prefetch(trpc.analytics.getStats.queryOptions({ days: 30 }));
return (
<HydrateClient>
<AnalyticsView />
</HydrateClient>
);
}
Extending Voice Upload
Adding Voice Preprocessing
Example: Normalize audio volume before upload.
// src/lib/audio-processing.ts
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export async function normalizeAudio(inputBuffer: Buffer): Promise<Buffer> {
// Use ffmpeg to normalize audio
const tempInput = `/tmp/input-${Date.now()}.wav`;
const tempOutput = `/tmp/output-${Date.now()}.wav`;
await fs.writeFile(tempInput, inputBuffer);
await execAsync(
`ffmpeg -i ${tempInput} -af loudnorm ${tempOutput}`
);
const normalizedBuffer = await fs.readFile(tempOutput);
// Cleanup
await fs.unlink(tempInput);
await fs.unlink(tempOutput);
return normalizedBuffer;
}
Example: Track voice gender and age range.
Update Schema
enum VoiceGender {
MALE
FEMALE
NEUTRAL
}
enum VoiceAgeRange {
CHILD
YOUNG_ADULT
MIDDLE_AGED
SENIOR
}
model Voice {
// ... existing fields
gender VoiceGender? @default(NEUTRAL)
ageRange VoiceAgeRange? @default(MIDDLE_AGED)
}
Update Upload Form
const createVoiceSchema = z.object({
name: z.string().min(1),
category: z.enum(VOICE_CATEGORIES),
language: z.string().min(1),
description: z.string().nullish(),
gender: z.enum(["MALE", "FEMALE", "NEUTRAL"]).default("NEUTRAL"),
ageRange: z.enum(["CHILD", "YOUNG_ADULT", "MIDDLE_AGED", "SENIOR"]),
});
Use in Filters
const maleVoices = await prisma.voice.findMany({
where: {
gender: "MALE",
ageRange: "MIDDLE_AGED",
},
});
Adding Middleware
Example: Rate Limiting Middleware
// src/trpc/middleware/rate-limit.ts
import { TRPCError } from "@trpc/server";
import { middleware } from "../init";
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 100; // requests per window
const WINDOW_MS = 60 * 1000; // 1 minute
export const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
const key = ctx.orgId || ctx.userId || "anonymous";
const now = Date.now();
const current = rateLimitMap.get(key);
if (current && now < current.resetAt) {
if (current.count >= RATE_LIMIT) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Rate limit exceeded",
});
}
current.count++;
} else {
rateLimitMap.set(key, {
count: 1,
resetAt: now + WINDOW_MS,
});
}
return next();
});
Testing Extensions
Unit Testing tRPC Routers
// tests/trpc/favorites.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createCallerFactory } from "@/trpc/init";
import { appRouter } from "@/trpc/routers/_app";
const createCaller = createCallerFactory(appRouter);
describe("favoritesRouter", () => {
const caller = createCaller({
userId: "user_123",
orgId: "org_456",
});
it("should toggle favorite", async () => {
const result = await caller.favorites.toggle({
generationId: "gen_789",
});
expect(result.favorited).toBe(true);
});
it("should get all favorites", async () => {
const favorites = await caller.favorites.getAll();
expect(Array.isArray(favorites)).toBe(true);
});
});
Best Practices
Type Safety First
Always define Zod schemas for inputs and rely on Prisma for data types.
Feature Isolation
Keep features self-contained. Avoid cross-feature imports.
Error Handling
Use appropriate tRPC error codes:
UNAUTHORIZED: Missing auth
FORBIDDEN: Insufficient permissions
NOT_FOUND: Resource doesn’t exist
BAD_REQUEST: Invalid input
Database Indexes
Add indexes for commonly filtered fields:@@index([orgId, createdAt])
Common Patterns
Optimistic Updates
const utils = trpc.useUtils();
const toggleFavorite = trpc.favorites.toggle.useMutation({
onMutate: async ({ generationId }) => {
// Cancel outgoing refetches
await utils.favorites.getAll.cancel();
// Snapshot previous value
const previous = utils.favorites.getAll.getData();
// Optimistically update
utils.favorites.getAll.setData(undefined, (old) => [
...old,
{ generationId, createdAt: new Date() },
]);
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
utils.favorites.getAll.setData(undefined, context.previous);
},
onSettled: () => {
// Refetch after mutation
utils.favorites.getAll.invalidate();
},
});
Infinite Queries
// Router
getInfinite: orgProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const items = await prisma.generation.findMany({
where: { orgId: ctx.orgId },
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
});
let nextCursor: string | undefined = undefined;
if (items.length > input.limit) {
const nextItem = items.pop();
nextCursor = nextItem.id;
}
return { items, nextCursor };
});
// Client
const { data, fetchNextPage, hasNextPage } =
trpc.generations.getInfinite.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
Next Steps
Project Structure
Review codebase organization patterns
Database Schema
Understand data models and relationships