-- 031_user_voice_prompts.sql (2026-05-18, Phase 2 of CosyVoice 3.0 다중 엔진). -- -- engine 별 voice_clone_prompt 영속 캐싱 매핑. -- -- 배경: sql/024 의 user_voices.embedding_storage_path 는 단일 엔진 (Qwen3) 가정. -- CosyVoice 3.0 (Phase 1 admin spawn 완료) 같은 다른 엔진이 추가되면서, 같은 -- ref_audio 에서 엔진별로 다른 prompt 가 만들어진다 (Qwen3 = ICL prompt items -- dict, CosyVoice = campplus 192-dim speaker embedding). 한 사용자 voice 가 두 -- 엔진 모두 prompt 캐시를 가질 수 있어야 한다. -- -- 설계: -- - 별도 매핑 테이블 (PK = user_id + voice_name + engine_kind) -- - 기존 user_voices.embedding_storage_path 는 호환성 위해 유지 (Qwen3 fast-path) -- - 백필: 기존 embedding_storage_path 가 채워진 row → engine_kind='qwen3' 로 매핑 -- -- 회귀 0 가드: -- - 기존 코드 경로 (`update_voice_embedding_path` + `user_voices.embedding_storage_path`) -- 는 그대로 동작 (Qwen3 호환). 새 매핑 테이블은 추가 정보일 뿐. -- - 새 엔진 prompt 가 없으면 polling 으로 새 row 가 생긴 다음 set_voice_clone 시 -- 사용. 그 사이엔 ref_audio b64 폴백 (안전망). -- -- 참조: -- - doc/runbook/tts_engine_multi.md Phase 2 (voice_clone_prompt 캐싱 분기) -- - doc/runbook/tts_prompt_cache.md (Qwen3 prompt 캐싱 기존 흐름) -- - doc/runbook/voice_dual_register.md (4단계 UI 호환) CREATE TABLE IF NOT EXISTS public.user_voice_prompts ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, voice_name TEXT NOT NULL, engine_kind TEXT NOT NULL, storage_path TEXT NOT NULL, ref_transcript TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, voice_name, engine_kind), CONSTRAINT user_voice_prompts_engine_kind_check CHECK (engine_kind IN ('qwen3', 'cosyvoice35')) ); CREATE INDEX IF NOT EXISTS idx_user_voice_prompts_engine ON public.user_voice_prompts (engine_kind, user_id); COMMENT ON TABLE public.user_voice_prompts IS '엔진별 voice_clone_prompt 영속 캐싱 매핑. 같은 (user_id, voice_name) 가 여러 engine_kind 의 prompt 를 동시 보유 가능. 자세한 배경은 doc/runbook/tts_engine_multi.md Phase 2.'; COMMENT ON COLUMN public.user_voice_prompts.engine_kind IS 'TTS 엔진 종류. qwen3 (운영 default) | cosyvoice35 (Phase 1 beta). tts_scale_instances.engine_kind 와 동일 enum.'; COMMENT ON COLUMN public.user_voice_prompts.storage_path IS 'voice-clones bucket 의 prompt 파일 경로. 예: /.qwen3.prompt.pt 또는 /.cosyvoice35.prompt.pt'; COMMENT ON COLUMN public.user_voice_prompts.ref_transcript IS '추출 시점의 ref_text 스냅샷. 변경되면 prompt 재추출 트리거 (apply_dual_register).'; -- 백필: 기존 user_voices.embedding_storage_path 가 NOT NULL 인 row 를 qwen3 로 매핑. -- (체이닝되어도 ON CONFLICT DO NOTHING 으로 idempotent.) INSERT INTO public.user_voice_prompts (user_id, voice_name, engine_kind, storage_path, ref_transcript) SELECT user_id, name, 'qwen3', embedding_storage_path, transcript FROM public.user_voices WHERE embedding_storage_path IS NOT NULL AND embedding_storage_path <> '' ON CONFLICT (user_id, voice_name, engine_kind) DO NOTHING; -- RLS — user_voices 와 동일 정책 (본인 데이터만). ALTER TABLE public.user_voice_prompts ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS user_voice_prompts_owner ON public.user_voice_prompts; CREATE POLICY user_voice_prompts_owner ON public.user_voice_prompts FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);