Skip to main content

Overview

The Voice Library provides access to both pre-built system voices and your organization’s custom cloned voices. Voices can be filtered by category, language, and search terms.

Voice Types

Resonance supports two types of voices:

System Voices

Pre-trained voices provided by Resonance
  • Curated for quality
  • No creation required
  • Available to all users
  • Cannot be deleted

Custom Voices

Voices cloned from your audio samples
  • Organization-specific
  • Private to your org
  • Can be edited and deleted
  • Require voice cloning

Voice Schema

Each voice in the library has the following structure:
interface Voice {
  id: string;              // Unique identifier (CUID)
  name: string;            // Display name
  description?: string;    // Optional description
  category: VoiceCategory; // Voice category
  language: string;        // BCP 47 language code (e.g., "en-US")
  variant: "SYSTEM" | "CUSTOM";
  orgId?: string;          // Organization ID (custom voices only)
  r2ObjectKey?: string;    // Storage key for audio sample
  createdAt: Date;
  updatedAt: Date;
}

Voice Categories

Voices are organized into 12 categories:
  • AUDIOBOOK: Long-form narration for books
  • NARRATIVE: Storytelling and narrative content
  • PODCAST: Podcast hosting and episodes

Category Labels

The UI displays human-readable labels:
export const VOICE_CATEGORY_LABELS: Record<VoiceCategory, string> = {
  AUDIOBOOK: "Audiobook",
  CONVERSATIONAL: "Conversational",
  CUSTOMER_SERVICE: "Customer Service",
  GENERAL: "General",
  NARRATIVE: "Narrative",
  CHARACTERS: "Characters",
  MEDITATION: "Meditation",
  MOTIVATIONAL: "Motivational",
  PODCAST: "Podcast",
  ADVERTISING: "Advertising",
  VOICEOVER: "Voiceover",
  CORPORATE: "Corporate",
};

Fetching Voices

The Voice Library API returns both custom and system voices:

tRPC Query

import { trpc } from '@/trpc/client';

// Fetch all voices
const { data } = trpc.voices.getAll.useQuery();

const customVoices = data.custom; // Your organization's voices
const systemVoices = data.system; // Pre-built voices

With Search Filter

const { data } = trpc.voices.getAll.useQuery({
  query: "conversational", // Search term
});
The search filter matches against:
  • Voice name (case-insensitive)
  • Voice description (case-insensitive)

Response Structure

type GetAllVoicesResponse = {
  custom: Voice[];  // Sorted by createdAt DESC
  system: Voice[];  // Sorted by name ASC
};

Voice Scoping

Voices are scoped based on variant:
1

System Voices

  • Available to all organizations
  • variant: "SYSTEM"
  • orgId: null
  • Queried with WHERE variant = 'SYSTEM'
2

Custom Voices

  • Private to creating organization
  • variant: "CUSTOM"
  • orgId: <organization-id>
  • Queried with WHERE variant = 'CUSTOM' AND orgId = <current-org>

Access Control

The API enforces organization-level access:
// Voice must be either:
// 1. A system voice, OR
// 2. A custom voice belonging to the requesting organization
const voice = await prisma.voice.findUnique({
  where: {
    id: voiceId,
    OR: [
      { variant: "SYSTEM" },
      { variant: "CUSTOM", orgId: currentOrgId },
    ],
  },
});

Managing Custom Voices

Delete Voice

Only custom voices can be deleted:
const deleteMutation = trpc.voices.delete.useMutation();

await deleteMutation.mutateAsync({
  id: "clx1234567890",
});

Deletion Process

1

Verify ownership

Ensure voice is custom and belongs to your organization
2

Delete database record

Remove voice from database
3

Delete audio file

Remove audio from R2 storage (fire-and-forget)
4

Update references

Existing generations using this voice remain intact with voiceName snapshot
Deleting a voice does not delete past generations. The generation history preserves the voice name at time of creation.

Error Handling

try {
  await deleteMutation.mutateAsync({ id: voiceId });
  toast.success("Voice deleted");
} catch (error) {
  // Will throw NOT_FOUND if:
  // - Voice doesn't exist
  // - Voice is a system voice
  // - Voice belongs to different organization
  toast.error("Failed to delete voice");
}

Voice Selection UI

The voice selector combines both types:
import { useTTSVoicesContext } from '@/features/text-to-speech/contexts/tts-voices-context';

function VoiceSelector() {
  const { customVoices, systemVoices, allVoices } = useTTSVoicesContext();

  return (
    <>
      {customVoices.length > 0 && (
        <VoiceSection title="Your Voices">
          {customVoices.map(voice => (
            <VoiceCard key={voice.id} voice={voice} />
          ))}
        </VoiceSection>
      )}
      
      <VoiceSection title="System Voices">
        {systemVoices.map(voice => (
          <VoiceCard key={voice.id} voice={voice} />
        </VoiceSection>
      </>
    </>
  );
}

Voice Preview

Voices include audio samples for preview:
// Access voice audio sample
const audioUrl = `/api/voices/${voice.id}`;

// The API route validates access and returns audio from R2
Only voices with r2ObjectKey can be previewed. Voices without audio samples cannot be used for generation.

Language Support

Voices support any BCP 47 language tag:
import locales from 'locale-codes';

// Available languages (with regions)
const LANGUAGE_OPTIONS = locales.all
  .filter((l) => l.tag && l.tag.includes("-") && l.name)
  .map((l) => ({
    value: l.tag,        // e.g., "en-US", "es-MX"
    label: l.location    // e.g., "English (United States)"
      ? `${l.name} (${l.location})`
      : l.name,
  }));

Common Languages

  • en-US - English (United States)
  • en-GB - English (United Kingdom)
  • es-ES - Spanish (Spain)
  • es-MX - Spanish (Mexico)
  • fr-FR - French (France)
  • de-DE - German (Germany)
  • it-IT - Italian (Italy)
  • pt-BR - Portuguese (Brazil)
  • ja-JP - Japanese (Japan)
  • zh-CN - Chinese (Simplified)
  • ko-KR - Korean (South Korea)

Voice Avatar

Each voice has a generated avatar based on its ID:
import { VoiceAvatar } from '@/components/voice-avatar/voice-avatar';

<VoiceAvatar
  seed={voice.id ?? voice.name}
  name={voice.name}
  className="size-8"
/>
Avatars are deterministically generated from the voice ID, providing visual consistency.

Complete Example

import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';

function VoiceLibraryView() {
  const trpc = useTRPC();
  const [searchQuery, setSearchQuery] = useState("");

  const { data } = useSuspenseQuery(
    trpc.voices.getAll.queryOptions({
      query: searchQuery || undefined,
    })
  );

  const { custom: customVoices, system: systemVoices } = data;

  return (
    <div>
      <SearchBar
        value={searchQuery}
        onChange={setSearchQuery}
        placeholder="Search voices..."
      />

      {customVoices.length > 0 && (
        <VoiceSection title="Your Voices">
          {customVoices.map(voice => (
            <VoiceCard
              key={voice.id}
              voice={voice}
              onDelete={async () => {
                await trpc.voices.delete.mutate({ id: voice.id });
              }}
            />
          ))}
        </VoiceSection>
      )}

      <VoiceSection title="System Voices">
        {systemVoices.map(voice => (
          <VoiceCard key={voice.id} voice={voice} />
        ))}
      </VoiceSection>
    </div>
  );
}

Build docs developers (and LLMs) love