Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/components/pipeline/CanonCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ export default function CanonCard({
<CharacterDetailsToggle>
<CharacterDetailEditor
entry={entry}
universeId={characterExtensions.universeId}
characters={characterExtensions.castList || []}
onPatch={(patch) => onPatchEntry(entry.id, patch)}
onExpand={characterExtensions.onExpandCharacter ? () => characterExtensions.onExpandCharacter(entry.id) : null}
Expand Down
427 changes: 424 additions & 3 deletions client/src/components/universe/CharacterDetailEditor.jsx

Large diffs are not rendered by default.

142 changes: 141 additions & 1 deletion client/src/components/universe/CharacterDetailEditor.test.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import CharacterDetailEditor from './CharacterDetailEditor';

// Mock VoicePicker — it pulls in the voice API/socket layer the relationship
// tests don't care about.
vi.mock('../voice/VoicePicker', () => ({ default: () => null }));
vi.mock('../../services/apiVoice', () => ({
listVoiceEngines: vi.fn().mockResolvedValue({ engines: [] }),
listVoiceProfiles: vi.fn(),
promotePresetProfile: vi.fn(),
promoteVoicePreset: vi.fn(),
renderVoiceProfileBenchmark: vi.fn(),
createVoiceDesignCandidate: vi.fn(),
createClonedVoiceCandidate: vi.fn(),
promoteVoiceProfile: vi.fn(),
benchmarkProfileInteractive: vi.fn(),
startFineTuningJob: vi.fn(),
}));

import {
listVoiceEngines,
listVoiceProfiles,
promoteVoicePreset,
createVoiceDesignCandidate,
benchmarkProfileInteractive,
} from '../../services/apiVoice';

const ARIA = { id: 'chr-aria', name: 'Aria' };
const BRAM = { id: 'chr-bram', name: 'Bram' };
Expand Down Expand Up @@ -165,6 +185,126 @@ describe('CharacterDetailEditor — character framework (#2175)', () => {
});

describe('CharacterDetailEditor — production package (#5378)', () => {
it('keeps the empty local-profile state distinct and promotes a portable preset locally', async () => {
listVoiceEngines.mockResolvedValue({ engines: [] });
listVoiceProfiles.mockResolvedValue({ profiles: [] });
promoteVoicePreset.mockResolvedValueOnce({
profile: {
id: 'voice-profile-1', version: 1, voiceId: 'kokoro:af_heart', modelRevision: 'kokoro-test:q8',
delivery: { rate: 1 }, approval: { status: 'approved' }, benchmark: null,
},
});
render(<CharacterDetailEditor
entry={{ ...ARIA, voiceId: 'kokoro:af_heart' }} universeId="uni-1" characters={[ARIA]} onPatch={() => {}}
/>);
fireEvent.click(screen.getByRole('button', { name: /Local voice profile/i }));
expect(await screen.findByText(/Promote the selected Kokoro or Piper preset/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Promote selected preset/i }));
await waitFor(() => expect(promoteVoicePreset).toHaveBeenCalledWith({
universeId: 'uni-1', characterId: 'chr-aria', characterName: 'Aria', voiceId: 'kokoro:af_heart',
}, { silent: true }));
expect(await screen.findByRole('button', { name: /Re-promote selected preset/i })).toBeInTheDocument();
});

it('plays persisted local benchmark renders through the voice-profile asset mount', async () => {
listVoiceEngines.mockResolvedValue({ engines: [] });
listVoiceProfiles.mockResolvedValue({
profiles: [{
id: 'voice-profile-1', version: 1, voiceId: 'kokoro:af_heart', modelRevision: 'kokoro-test:q8',
delivery: { rate: 1 }, approval: { status: 'approved' },
benchmark: { lines: [{ key: 'identity', filename: 'voice-profiles/voice-profile-1/benchmarks/v1/01-identity.wav' }] },
}],
});
render(<CharacterDetailEditor
entry={{ ...ARIA, voiceId: 'kokoro:af_heart' }} universeId="uni-1" characters={[ARIA]} onPatch={() => {}}
/>);
fireEvent.click(screen.getByRole('button', { name: /Local voice profile/i }));
expect(await screen.findByLabelText(/Voice benchmark identity/i)).toHaveAttribute(
'src', '/data/voice-profiles/voice-profile-1/benchmarks/v1/01-identity.wav',
);
});

it('generates a voice design candidate via Voice Lab', async () => {
listVoiceEngines.mockResolvedValue({ engines: [] });
listVoiceProfiles.mockResolvedValue({ profiles: [] });
createVoiceDesignCandidate.mockResolvedValueOnce({
profile: {
id: 'voice-profile-des-1', version: 1, kind: 'designed', engine: 'qwen3-tts',
approval: { status: 'draft' },
},
});

render(<CharacterDetailEditor
entry={ARIA} universeId="uni-1" characters={[ARIA]} onPatch={() => {}}
/>);
fireEvent.click(screen.getByRole('button', { name: /Local voice profile/i }));

// Switch to Design tab
fireEvent.click(screen.getByRole('button', { name: /Design/i }));
fireEvent.change(screen.getByPlaceholderText(/warm low alto/i), {
target: { value: 'calm, measured alto' },
});
fireEvent.click(screen.getByRole('button', { name: /Design Candidate Voice/i }));

await waitFor(() => expect(createVoiceDesignCandidate).toHaveBeenCalledWith({
universeId: 'uni-1',
characterId: 'chr-aria',
characterName: 'Aria',
instructions: 'calm, measured alto',
seed: 42,
rate: 1,
}, { silent: true }));
});

it('gates consented voice cloning on explicit performer consent confirmation', async () => {
listVoiceEngines.mockResolvedValue({ engines: [] });
listVoiceProfiles.mockResolvedValue({ profiles: [] });

render(<CharacterDetailEditor
entry={ARIA} universeId="uni-1" characters={[ARIA]} onPatch={() => {}}
/>);
fireEvent.click(screen.getByRole('button', { name: /Local voice profile/i }));
expect(await screen.findByText(/Machine-local voice design/i)).toBeInTheDocument();

// Switch to Clone tab
fireEvent.click(screen.getByRole('button', { name: /Clone/i }));
const cloneBtn = screen.getByRole('button', { name: /Create Cloned Candidate/i });
expect(cloneBtn).toBeDisabled();

// Check consent box
fireEvent.click(screen.getByRole('checkbox', { name: /I confirm the performer consented/i }));
// Still disabled because no file is selected yet
expect(cloneBtn).toBeDisabled();
});

it('qualifies interactive route via host latency benchmark', async () => {
listVoiceEngines.mockResolvedValue({ engines: [] });
listVoiceProfiles.mockResolvedValue({
profiles: [{
id: 'voice-profile-1', version: 1, voiceId: 'qwen3:test', modelRevision: 'qwen3-1.7b',
delivery: { rate: 1 }, approval: { status: 'approved' },
routes: { studio: { enabled: true }, interactive: { enabled: false, maxFirstAudioMs: 900 } },
}],
});
benchmarkProfileInteractive.mockResolvedValueOnce({
profile: {
id: 'voice-profile-1', version: 1, approval: { status: 'approved' },
routes: { studio: { enabled: true }, interactive: { enabled: true, maxFirstAudioMs: 900 } },
benchmark: { interactiveLatencyMs: 120 },
},
});

render(<CharacterDetailEditor
entry={ARIA} universeId="uni-1" characters={[ARIA]} onPatch={() => {}}
/>);
fireEvent.click(screen.getByRole('button', { name: /Local voice profile/i }));
fireEvent.click(await screen.findByRole('button', { name: /Qualify interactive route/i }));

await waitFor(() => expect(benchmarkProfileInteractive).toHaveBeenCalledWith(
'voice-profile-1', { maxFirstAudioMs: 900 }, { silent: true },
));
});

it('marks a voice-canon revision as approved', () => {
const onPatch = vi.fn();
render(<CharacterDetailEditor entry={ARIA} characters={[ARIA]} onPatch={onPatch} />);
Expand Down
53 changes: 44 additions & 9 deletions client/src/services/apiVoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,63 @@ import api from './apiCore';

// `options` lets callers pass `{ silent: true }` so apiCore's default toast
// doesn't fire when the caller owns its own error UI (custom catch /
// useAsyncAction). Without it the user sees a stacked toast for every
// failure — and the Memory Management panel polls every 5s.
// useAsyncAction).
export const getVoiceStatus = (options) => api.get('/voice/status', options);
export const getVoiceConfig = (options) => api.get('/voice/config', options);
export const updateVoiceConfig = (patch, options) => api.put('/voice/config', patch, options);
export const listVoices = (engine, options) => api.get(`/voice/voices${engine ? `?engine=${engine}` : ''}`, options);
export const fetchPiperVoice = (voice, options) => api.post('/voice/piper/fetch', { voice }, options);

// Returns the raw WAV bytes of the test utterance. Optional `voice` and
// `engine` overrides let the voice-picker preview audition a voice from a
// different engine than the saved one — without forcing a save first.
// Silent — VoiceTab callers own their own error toasts.
export const listVoiceProfiles = ({ universeId, characterId } = {}, options) => {
const params = new URLSearchParams();
if (universeId) params.set('universeId', universeId);
if (characterId) params.set('characterId', characterId);
const query = params.toString();
return api.get(`/voice/profiles${query ? `?${query}` : ''}`, options);
};

export const listVoiceEngines = (options) => api.get('/voice/engines', options);
export const promoteVoicePreset = (payload, options) => api.post('/voice/profiles/preset', payload, options);
export const promotePresetProfile = promoteVoicePreset;
export const createVoiceDesignCandidate = (payload, options) => api.post('/voice/profiles/design', payload, options);
export const createClonedVoiceCandidate = (payload, options) => api.post('/voice/profiles/clone', payload, options);
export const promoteVoiceProfile = (profileId, payload = {}, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/promote`, payload, options,
);
export const renderVoiceProfileBenchmark = (profileId, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/benchmark`, {}, options,
);
export const benchmarkProfileInteractive = (profileId, payload = {}, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/benchmark-interactive`, payload, options,
);

// Qwen3-TTS runtime and model management
export const getQwen3Status = (options) => api.get('/voice/qwen3/status', options);
export const downloadQwen3Model = (modelId, options) => api.post('/voice/qwen3/download-model', { modelId }, options);

// Fine-tuning
export const startFineTuningJob = (profileId, payload = {}, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/fine-tune/start`, payload, options,
);
export const getFineTuningJobStatus = (profileId, jobId, options) => api.get(
`/voice/profiles/${encodeURIComponent(profileId)}/fine-tune/${encodeURIComponent(jobId)}`, options,
);
export const cancelFineTuningJob = (profileId, jobId, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/fine-tune/${encodeURIComponent(jobId)}/cancel`, {}, options,
);
export const promoteFineTunedCheckpoint = (profileId, jobId, checkpointId, options) => api.post(
`/voice/profiles/${encodeURIComponent(profileId)}/fine-tune/${encodeURIComponent(jobId)}/promote`, { checkpointId }, options,
);

// Returns the raw WAV bytes of the test utterance.
export const testTts = (text, voice, engine) => {
const body = { text };
if (voice) body.voice = voice;
if (engine) body.engine = engine;
return api.post('/voice/test', body, { responseType: 'arraybuffer', silent: true });
};

// Memory-management — Kokoro residency + unload, Whisper transient stop/start.
// See MemoryManagement.jsx for the only consumer; it owns its own toast,
// hence the `options` parameter / `silent: true` plumbing.
// Memory-management
export const getTtsStatus = (options) => api.get('/voice/tts/status', options);
export const unloadKokoroTts = (options) => api.post('/voice/tts/unload', {}, options);
export const controlWhisper = (action, options) => api.post('/voice/whisper', { action }, options);
Expand Down
1 change: 1 addition & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
- `creative_director_projects` — Creative Director project/treatment/scene/run state, one row per project (`id`/`status`/timestamps as columns, the full record in `data` JSONB). Migrated from the monolithic `data/creative-director-projects.json` in Phase 3 (#997); CD is local-only, so the row carries no sync cursor/tombstone. Adapter: `server/services/creativeDirector/projectsDB.js`.
- `catalog_user_types` — user-defined ingredient types (the registry that defines catalog row semantics), one row per type (`id` PK, the definition in `data` JSONB, `updated_at`/`deleted_at` mirroring the federation LWW clock + tombstone). Migrated from the `data/settings.json` `catalogUserTypes` slice in Phase 4 lead-in (#1001) so type evolution versions/syncs alongside the catalog data it governs. Federates via the catalog sync `catalogTypes` envelope block (wire shape unchanged by the move). Adapter: `server/services/catalogUserTypes/db.js`, dispatched via `store.js`.
- `universes` / `universe_runs` — Universe Builder records (canon bibles, categories, composite sheets, locks, influences, and portable character production packages) one row per universe with the full sanitized record in `data` JSONB and `name`/`schema_version`/`ephemeral`/`updated_at`/`deleted`/`deleted_at` mirrored into columns; render-run history one row per run (local-only, capped 200, never federated). Character production packages carry only versioned voice direction and approved managed-image roles; local profiles, recordings, provider ids, and training artifacts are excluded from the federated wire. Migrated from `data/universes/{id}/index.json` (collectionStore) in Phase 3 Create slice 1 (#1014). **NO `sync_sequence`** — universes federate via the EXISTING `dataSync` snapshot/push model (LWW on the body's `updatedAt`), so the storage swap is invisible to peers (no schema-version bump). The store bumps an in-process mutation epoch on every write that `dataSync` folds into its checksum fingerprint, since a DB edit no longer changes the `data/universes/` directory the fingerprint used to watch. **`universe_runs` is intentionally never federated** — a regenerable render cache under a 200-row *global* cap that two producers would mutually evict, while the durable universe record already syncs (ADR [tribe + universe-runs local](./decisions/2026-06-26-tribe-and-universe-runs-local.md), #1724). Adapter: `server/services/universeBuilder/db.js`, dispatched via `store.js`.
- `voice_profiles` / `voice_profile_renders` — machine-local DB-primary records for approved `(universeId, characterId)` bindings and the latest rendered dialogue line per `(issueId, lineId)`. They store the promoted Kokoro/Piper preset, profile revision, route availability, benchmark provenance, and reproducible dialogue delivery details (engine/model revision, timing, controls, and mastering). The portable Universe character and federated pipeline issue keep only portable voice data and audio filenames; they never store a local profile id. Rendered benchmark WAVs, safe-basename source-asset metadata, and future local engine artifacts live under `data/voice-profiles/<profileId>/`. Both the PostgreSQL dump and that managed directory are included in normal backup, while peer sync intentionally carries neither. Adapter: `server/services/voice/profiles.js`.
- `tribe_people` / `tribe_touchpoints` / `tribe_memory_links` — the Tribe relationship/CRM graph (people + their care cadence, contact touchpoints, and cross-links into brain `memories`). **Intentionally machine-local — never federated** (ADR [tribe + universe-runs local](./decisions/2026-06-26-tribe-and-universe-runs-local.md), #1724): it is relationship-graph data, mirroring the deliberate "memory_links are instance-local" boundary in `memorySync.js` (memory *nodes* federate, the link graph does not), and is coupled to machine-local domains — `tribe_memory_links` extends the non-federated `memory_links` layer and `tribe_touchpoints` carry per-machine calendar-account refs. NO `sync_sequence`, no peer-sync record kind, no `dataSync` category. Adapter: `server/services/tribe.js`.
- `creative_commissions` — Creative Commissions (Autonomous Creation Engine, #2657/#2686): standing recurring creative briefs that fire on a cron cadence and drive the Creative Director directive pipeline unattended. One row per commission, the full sanitized record in `data` JSONB with `name`/`enabled`/`created_at`/`updated_at` mirrored into columns for the scheduler's "arm every enabled commission" query. The brief/identity federates as `creativeCommission` so synced feedback can attach to the same commission; `schedule`, `runs`, `assignment`, `enabled`, and feedback view stay machine-local (the per-reaction `commissionFeedback` records federate separately). The opt-in Digital Twin music-taste configuration is bounded brief metadata; raw taste sources and per-run recipes never cross the wire. The file backend is the `NODE_ENV=test`/`MEMORY_BACKEND=file` escape hatch only. Adapter: `server/services/creativeCommissions/db.js`, selected pg-vs-file by `server/services/creativeCommissions/store.js` (file backend is the `NODE_ENV=test`/`MEMORY_BACKEND=file` escape hatch only).
- `games` — Game studio workspaces (#3177): one row per managed-app asset plan, with the full reusable sprite/music binding set, current compiled-manifest pointer, compile history, and user-requested AI feedback history in `data` JSONB; `app_id`/`name`/`updated_at` are mirrored for list and relationship queries. The record is **machine-local** because managed-app registration, sprite atlases, and music-library bytes are machine-local; there is no peer-sync cursor or tombstone, and deletes are hard deletes. Compiled manifests are immutable, SHA-256-addressed artifacts under `data/games/{id}/manifests/`; their pointers and hashes live in the DB record. Adapter: `server/services/games/db.js`, selected pg-vs-collectionStore by `server/services/games/store.js` (collectionStore is test/unsupported file escape hatch only).
Expand Down
14 changes: 14 additions & 0 deletions scripts/migrations/315-voice-profiles-table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Register machine-local character voice profiles (issue #5380).
*
* The additive `voice_profiles` / `voice_profile_renders` DDL lives in `ensureSchema()` and
* `server/scripts/init-db.sql`: this runner is intentionally before the
* database pool exists, so it only records the rollout in the migration
* ledger. New installs receive the same idempotent schema at boot.
*/

export default {
async up() {
console.log('🎙️ voice profile tables created idempotently by ensureSchema at boot; nothing to do in the file runner');
},
};
Loading