Skip to main content
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.
1

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
2

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",
  },
};
3

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;
4

Run Seed Script

npm run db:seed
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.
1

Update Prisma Schema

Edit prisma/schema.prisma and add to the enum:
enum VoiceCategory {
  AUDIOBOOK
  CONVERSATIONAL
  // ... existing categories
  DOCUMENTARY     // New category
  EDUCATIONAL     // New category
}
2

Create Migration

npx prisma migrate dev --name add_voice_categories
npx prisma generate
3

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",
};
4

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.
1

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
2

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),
    })
  )
3

Pass to TTS API

const { data, error } = await chatterbox.POST("/generate", {
  body: {
    // ... existing params
    speed: input.speed,
  },
});
4

Store in Database

const generation = await prisma.generation.create({
  data: {
    // ... existing fields
    speed: input.speed,
  },
});
5

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

1

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
2

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 };
      }
    }),
});
3

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
});
4

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

1

Create Feature Directory

mkdir -p src/features/analytics/{components,views,hooks,data}
2

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;
3

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 };
}
4

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>
  );
}
5

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>
  );
}
6

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;
}

Adding Voice Metadata Fields

Example: Track voice gender and age range.
1

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)
}
2

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"]),
});
3

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

Build docs developers (and LLMs) love