86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
/**
|
|
* Provider-neutral per-agent voice design.
|
|
*
|
|
* A `VoiceDesign` describes an agent's vocal identity (not personality) as a
|
|
* 3-layer recipe. It is consumed by any TTS provider: as an inline voice
|
|
* prompt where supported, or as the seed for a registered/cloned voice
|
|
* (see `voice-registration.ts`). Nothing here is VoxCPM-specific.
|
|
*
|
|
* The type itself lives in `@openmaic/dsl` (it is part of the persisted
|
|
* `GeneratedAgentConfig` contract, so the roster's voice travels with the
|
|
* stage document); this module re-exports it and owns the runtime helpers.
|
|
*/
|
|
import type { VoiceDesign } from '@openmaic/dsl';
|
|
|
|
export type { VoiceDesign } from '@openmaic/dsl';
|
|
|
|
const VOICE_DESIGN_PROMPT_MAX_CHARS = 200;
|
|
|
|
/** Prefix for deterministic auto-voice ids (provider-neutral, backend-name-safe). */
|
|
export const AUTO_VOICE_ID_PREFIX = 'auto-' as const;
|
|
|
|
function sanitizeVoiceDesignPart(value?: string): string {
|
|
return (
|
|
(value || '')
|
|
.replace(/[\p{C}]+/gu, ' ')
|
|
// Strip parentheses: VoxCPM uses `(prompt)text` delimiters, so a paren in the
|
|
// descriptor/persona would corrupt the bootstrap synthesis prompt.
|
|
.replace(/[()()]/gu, ' ')
|
|
.replace(/\s+/gu, ' ')
|
|
.trim()
|
|
.slice(0, VOICE_DESIGN_PROMPT_MAX_CHARS)
|
|
.trim()
|
|
);
|
|
}
|
|
|
|
/** Compose the 3 layers into one comma-joined prompt, dropping blank layers. */
|
|
export function buildVoiceDesignPrompt(design: VoiceDesign): string {
|
|
return [design.identity, design.texture, design.delivery]
|
|
.map((part) => sanitizeVoiceDesignPart(part))
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
|
|
/** Coerce an arbitrary (LLM-produced) value into a VoiceDesign, or undefined. */
|
|
export function normalizeVoiceDesign(raw: unknown): VoiceDesign | undefined {
|
|
if (!raw || typeof raw !== 'object') return undefined;
|
|
const record = raw as Record<string, unknown>;
|
|
const pick = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
|
const design = {
|
|
identity: pick(record.identity),
|
|
texture: pick(record.texture),
|
|
delivery: pick(record.delivery),
|
|
};
|
|
if (!design.identity && !design.texture && !design.delivery) return undefined;
|
|
return design;
|
|
}
|
|
|
|
/**
|
|
* Deterministic voice id derived from the descriptor (+ provider + model).
|
|
* Stable across re-synthesis, recomputable anywhere from the descriptor on the
|
|
* agent, and namespaced by provider so a shared registry can't collide.
|
|
*
|
|
* Note: language is intentionally NOT part of the id — the descriptor text is
|
|
* already written in the course language, and language only selects the one-time
|
|
* bootstrap sample sentence (which affects neither output language nor timbre).
|
|
* Keeping it out means every TTS path (narration passes a directive, discussion
|
|
* passes a locale) resolves to the SAME id for the same agent.
|
|
*/
|
|
export async function getDeterministicVoiceId(
|
|
design: VoiceDesign,
|
|
opts: { providerId?: string; model?: string } = {},
|
|
): Promise<string> {
|
|
const seed = [
|
|
opts.providerId || '',
|
|
design.identity,
|
|
design.texture,
|
|
design.delivery,
|
|
opts.model || '',
|
|
].join('|');
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed));
|
|
const hex = Array.from(new Uint8Array(digest))
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
return `${AUTO_VOICE_ID_PREFIX}${hex.slice(0, 16)}`;
|
|
}
|