U
    ^;j)Y                     @  s  d Z ddlmZ ddlZddlZddlZddlZddlmZ ddl	m
Z
 edZG dd deZed	d
ZddddgddddgddddgddddgddddgdZdddddgZddddddZddddddZd d! Zd"d#d#d#d#d#d$d$d$d$d$d%d%d&d&d&d'Zed(ZdVd)d)d)d)d*d+d,d-Zd.d/d0d1Zd*d*d2d3d4Zed5d6Zd*d)d7d8d9d:ZdWd*d<d*d=d>d?Zd*d)d@d*dAdBdCZ dXd*dDd*d*d*d*dEd*d*dF	dGdHZ!dYd*dDd*dJdKdLZ"dZdOdPd*d<dOdQdOdOdOdRdS
dTdUZ#dS )[u  TTS backends for data pipeline.

Two implementations:
    - ElevenLabs (paid, emotional prosody) — recommended
    - edge-tts (free fallback)

Env vars:
    ELEVENLABS_API_KEY  (required for elevenlabs)
    ELEVENLABS_VOICE_ID (optional, default to multilingual-friendly voice)
    )annotationsN)Path)Listttsc                   @  s   e Zd ZdZdS )QuotaExhaustedu   Raised when ElevenLabs returns 401/402/quota — non-retryable.
    synth_all catches this once and aborts the whole batch so the user
    can see a single clear message and re-run with a different key.N)__name__
__module____qualname____doc__ r   r   X/home/ubuntu/service/kemix-engine/package/face/animasync-face-v3/scripts/compiler/tts.pyr      s   r   ZELEVENLABS_MODELZ	eleven_v3ZFCdKzv68Ofr4VUDcZXIyZKsAmBSHXsuxsZ1lZZXlGZoHB9Xhox1bqMl1TvkmelZZubHeGTOAkECknc02ZmoZm3yAHyFEFKtbCIM5n7GFZk9073AMdU5sAUtPMH1ilZhWXqitL3DEOLD49pgNWRZz2P4oCxSHhXan3ew4COvZTbMNBJ27fH2U0VgpSNkoZ1W00IGEmNmwmsDeYy7agZOEaq3WGNtNvFJ5co9mJEZxi3rF0t7dg7uN2M0WUhrZ8tsLeAV5vPVuzCCvqbbUZs0XGIcqmceN2l7kjsqoZZiWLjl1zCuqXRkW6494veZ8QclKarwLctvzN2plke3ZXfNU2rGpBa01ckF309OYZ93nuHbke4dTER9x2pDwEZYDDaC9XKjODs7hY78qEWZBNr4zvrC1bGIdIstzjFQ)angersadnessjoyneutralsurprisec           
        s   dd | D }|sdS ddl m} | }i  |D ]v}|dp@d}t|d}||  d7  < |dppd	d	d	g}t|d
krtt|d nd	} |d	|  |< q0t| fdd|	 D }	t|	dkr|	d S t|	 fdddS )u  Pick the most-frequent base emotion across a scenario's turns.

    `turns` is a list of dicts with 'emotion' (and optionally 'vad').
    Returns the base name ('joy', 'sadness', etc.). Tie-break: highest
    summed |arousal| of the tied bases. Empty input → 'neutral'.
    c                 S  s"   g | ]}| d pd r|qS )text )getstrip).0tr   r   r   
<listcomp>p   s      z+dominant_base_for_turns.<locals>.<listcomp>r   r   )Counteremotion   vad           c                   s   g | ]\}}| kr|qS r   r   )r   bc)	top_countr   r   r   ~   s      c                   s     | dS )Nr   )r   )r   )arousal_sumr   r   <lambda>       z)dominant_base_for_turns.<locals>.<lambda>)key)
collectionsr   r   EMOTION_TO_BASElenabsfloatmaxvaluesitems)
turnsrealr   countsr   emobaser   aZtiedr   )r"   r!   r   dominant_base_for_turnsg   s$    	 r4   r   r   r   r   r   )r   r   laughter
excitement	agreement	gratituder   cryingsulkapologystruggler   refusalr   flustershyZELEVENLABS_VOICE_IDz
str | Nonestr)r   overrideseedpool_overridereturnc                 C  s~   |r|S t rt S |dkrt}n&|r,t| }nt| p6dd}t| }|dkrT|d S tt|d	 d}||t
|  S )u  Pick voice ID from pool keyed by emotion → base emotion.

    seed: stable key (e.g. text or scenario_id+turn_idx) to deterministically
    pick a voice from the pool. Without seed, picks first voice in pool.
    Deterministic selection ensures same turn always gets same voice on re-run.

    pool_override: force voice selection from a specific base-emotion pool
    (e.g. 'neutral' for monologues — same voice across all emotions in turn).
    Zfemale_monologuer   Nr   utf-8   )SINGLE_VOICE_OVERRIDEFEMALE_MONOLOGUE_POOLVOICE_POOLS_BY_BASEr'   r   inthashlibmd5encode	hexdigestr(   )r   rA   rB   rC   poolr2   hr   r   r   voice_id_for_emotion   s    
rQ   zlist | None)r   c                 C  sv   | dkrdddddS | \}}}t t|}ttdtdd	d
|  }ttdtddd|  }d}|||ddS )u  Map VAD → ElevenLabs voice_settings for expressive prosody.

    For v3 + audio tags: tags carry the emotion, voice_settings carry
    consistency. Stacking high `style` on top of expressive tags
    triple-amplifies expression and produces distortion (raspy / accent
    drift / foreigner-sounding) — that was the failure mode observed on
    2026-05-07. We keep style modest and stability solid.

    Intensity is |A| magnitude — both high-positive (excited) and
    low-negative (sad) arousal scale prosody equally.
    Ng?g      ?g333333?T)	stabilitysimilarity_booststyleZuse_speaker_boostr   g      ?g?g      ?g?g?)r)   r*   r+   min)r   VAD	intensityrT   rR   rS   r   r   r   _build_voice_settings   s    
rZ   )r   rD   c                 C  s$   |   } | r | d dkr | d S | S )uF  Ensure text ends with sentence-ending punctuation so v3 doesn't cut the tail.

    Note: `、` (U+3001) is a Japanese reading-comma, not a Korean sentence
    terminator — it was here by mistake. Korean uses ASCII `.?!` plus
    sometimes `…`. CJK terminators `。？！` are kept as a safety net for
    pasted text.
    u   .?!。？！….)r   )r   r   r   r   _ensure_trailing_punct   s    r]   reu9   [ㅠㅜ]{2,}|[ㅋㅎ]{2,}|[ㅏㅓㅗㅡㅣ]{2,}|[ㅡ]{2,}ztuple[str, bool, bool])r   r   rD   c                 C  sn   ddl }t|d| }t|d| }|dd| } |dd| } |dd| } |dd|  } | ||fS )	u  v3 reads bare Korean jamo runs (ㅠㅠ, ㅋㅋ, ㅎㅎ) literally — comes out
    as "어어/유유/크크" instead of sobbing/laughing. Replace with v3 audio
    tags so they render as real reactions.

    ㅠㅠ / ㅜㅜ → [crying]     (inline, at the emoticon position. Deleting it
                              and relying on the head [crying]/[sorrowful] tag
                              produced NO audible sob at all, so inline stays.
                              It's imperfect — v3 occasionally rolls it as a
                              laugh or a sigh-with-gap — but at least the cry
                              is present. Re-roll bad takes rather than remove.)
    ㅋㅋ / ㅎㅎ → stripped     (removed entirely, NOT tagged — the clip is
                              still emotion=laughter so the head [laughs]
                              tag carries the laugh; we just don't want the
                              literal ㅋㅋ jamo in the text at all)
    bare vowel runs (ㅏㅏ, ㅡㅡ…) → stripped (garbage)

    No leading period — that produced a hard sentence break before the
    reaction (audible "speak…long pause…laugh" instead of "speak laugh").
    The canonical tags fire mid-clause without it.

    Returns (cleaned_text, had_sob, had_chuckle) — flags reserved but
    head-tag suppression is OFF by default now. Head tag carries the
    emotional prosody for the whole line; the body tag just stamps the
    reaction sound. Suppressing the head was making the voiced emotion
    drop out entirely.
    r   Nu   [ㅠㅜ]{2,}u   [ㅋㅎ]{2,}z
 [crying]  u   [ㅏㅓㅗㅡㅣ]{2,}z\s+)r^   boolsearchsubr   )r   r   r^   had_sobhad_chuckler   r   r   _scrub_korean_emoticons   s    re      rJ   )r   	min_charsrD   c                 C  s"   t |  |k r| dd S | S )u   v3 fails / hallucinates on very short inputs (e.g. "응.", "네.").

    Pad with neutral filler so the model has enough context. We add an
    ellipsis-style trailer that v3 reads as a brief pause, not extra speech.
    r\   z...)r(   r   rstrip)r   rg   r   r   r   _pad_short_text  s    ri   r`   )r   r   use_v3_tagsrD   c                 C  s   ddl }|dd| } t| |\} }}t| } t| } |rF|rF|dkrJ| S dddddd	d
ddddddddd}||dp|d}|r|dkrd}|rd| d|   S | S )ux  Prepend emotion tag for v3 models (supports bracketed audio tags).

    Adds a leading period buffer before the tag — v3 sometimes eats the first
    token after an audio tag, which truncates the first word of real content.
    Also ensures trailing punctuation so the model doesn't cut the final word.
    Short texts are padded — v3 hallucinates on <12-char inputs.
    r   Nz
\[[^\]]*\]r_   r   z	[happily]z[laughs]z	[excited]z[sorrowful]z[crying]z[sighs]z[resigned tone]z[frustrated]z[angry]z[flatly]z[surprised]z
[stammers]z
[whispers])r   r5   r6   r7   r8   r   r9   r:   r;   r<   r   r=   r   r>   r?   r   )r   r9   r:   r;   z. )r^   rb   re   ri   r]   r   r   )r   r   rj   r^   rc   rd   Ztag_maptagr   r   r   _format_text_with_emotion  s6    
rl   r   list)	r   out_pathvoice_idmodelapi_keyr   r   
voice_seed
voice_poolc	                   s  ddl m  t||pj|dp(tp6tdsDtdd k}	t	| ||	t
|t }
 fdd}dd	l}d
}t|D ]:}z|
d	|I d	H  W  d	S  tk
r } zt| dkp dkp dkp dkp dkp dkp dk}|rzFtdjd dt|j dt|dd dt|dd d	dd W n tk
r   Y nX tdd	d  |dkpd k}d!kpd"kpd#k}tfd$d%d&D }tfd'd%d(D }|p|p|p|}|r||d) k r|r,d*nd+}td,|d-|  |dd. }|rXd!n|rbdn|rld/nd0}td1| d2d	d3  d4|d)  d5| d6|d7d8 t|I d	H  W Y q W 5 d	}~X Y qX qd	S )9u2  Synthesize one clip with ElevenLabs, using emotion+VAD for prosody.

    If voice_id is None, picks voice automatically based on emotion → base mapping.
    voice_seed: stable key for deterministic voice pick from the pool.
    voice_pool: override base-emotion pool (e.g. 'neutral' for monologues).
    r   )
ElevenLabs)rA   rB   rC   ZELEVENLABS_API_KEYz#ELEVENLABS_API_KEY env var requiredZv3c                    s   dd l }  d}|jjdd}d}|d}|D ]}|| qBW 5 Q R X z@| jddd	d
dt|ddddddtgdd |jdd W nD t	| j
fk
r } z td| d | W 5 d }~X Y nX d S )Nr   )rq   Zmp3_44100_128)ro   r   Zmodel_idZoutput_formatvoice_settingsz.raw.mp3wbZffmpegz-yz	-loglevelerrorz-iz-afz adelay=180|180,apad=pad_dur=0.18z-c:aZ
libmp3lamez-b:aZ128kT)check)
missing_okz[tts] ffmpeg pad failed (z); saving raw audio)
subprocessZtext_to_speechconvertwith_suffixopenwriterunr@   unlinkFileNotFoundErrorCalledProcessErrorLOGwarningreplace)rz   ZclientZaudioZraw_pathfchunke)rt   rq   formatted_textrp   rn   ro   ru   r   r   _synca  sF    

        
z#synth_one_elevenlabs.<locals>._syncN   Z401Z402Z403ZquotaZunauthorizedZpayment_requiredZbillingz/tmp/tts_quota_error.txtz=== full exception ===
z

=== type ===
z

=== status_code attr ===
Zstatus_codezn/az

=== body attr ===
body
rE   )encodingzElevenLabs auth/quota error:    Z409Zalready_runningZ429Z
rate_limitZtoo_many_requestsc                 3  s   | ]}| kV  qd S Nr   r   r    )msgr   r   	<genexpr>  s     z'synth_one_elevenlabs.<locals>.<genexpr>)z 500 z 502 z 503 z 504 c                 3  s   | ]}| kV  qd S r   r   r   )lowr   r   r     s     )timeoutZ
connectionzremote disconnectedr   g       @g       @g      N@r   g      ?Z5xxnet[tts] z
 on voice    u   …, retry /z in z.1fs)Zelevenlabs.clientrt   rQ   stemDEFAULT_EL_MODELosgetenvRuntimeErrorlowerrl   rZ   asyncioget_event_looprandomrangerun_in_executor	Exceptionr@   r   
write_texttyper   getattrr   anyrU   uniformr   infosleep)r   rn   ro   rp   rq   r   r   rr   rs   rj   loopr   r   Zmax_retriesZattemptr   Zis_quotaZis_409Zis_429Zis_5xxZis_netZ	retryabler2   delaykindr   )	rt   rq   r   r   rp   r   rn   ro   ru   r   synth_one_elevenlabsD  sl    /2
"8r   ko-KR-SunHiNeural)r   rn   voicec                   sh   t jdd|d| dt|t jjt jjd	I dH }| I dH \}}|jdkrdtd|	 dd	  dS )
zFree fallback via edge-tts CLI.zedge-ttsz--voicez--textz--write-media)stdoutstderrNr   zedge-tts failed: r   )
r   create_subprocess_execr@   rz   DEVNULLPIPEcommunicate
returncoder   decode)r   rn   r   proc_errr   r   r   synth_one_edge  s          
r   
elevenlabs   z	List[str]z
List[Path]z
List[list]z
List[bool])
texts	out_pathsbackendconcurrencyemotionsvadsvoice_seedsvoice_pools	voice_idsrD   c	           
        s   t 
t kstt|dgt 
 dddd dkrFtnt	dd 	
fdd	tjfd
dtt 
D  I dH  rtd dt 
 d  d rtd d dd  d S )zParallel TTS for a batch. Returns success flag per item.

    emotions/vads: per-item lists aligned with texts. If provided,
    passed into the TTS call for emotional prosody.
    Fr   r   )flagreasonr   rJ   )ic                   s  4 I d H   d r,W 5 Q I d H R  d S |  }|  rn| jdkrnd| < d7 W 5 Q I d H R  d S zt}dkrd k	r|  |d< 
d k	r
|  |d< d k	rʈ|  d k	rʈ|  |d< d k	r|  d k	r|  |d	< d k	r|  d k	r|  |d
< 	|  |f|I d H  d| < W n tk
rx } z(d d< t| d< td|  W 5 d }~X Y n: tk
r } zt	d|  d|  W 5 d }~X Y nX W 5 Q I d H R X d S )Nr   i  Tr   r   r   r   rr   rs   ro   r   u*   [tts] QUOTA EXHAUSTED — aborting batch: z[tts] fail z: )
existsstatst_sizedictr   r@   r   rw   r   r   )r   pathZ
per_kwargsr   )abortedr   r   kwargsokr   	semaphoreskippedsynth_fnr   r   r   r   r   r   r   one  s:    
 zsynth_all.<locals>.onec                   s   g | ]} |qS r   r   )r   r   )r   r   r   r     s     zsynth_all.<locals>.<listcomp>Nr   r   z  turns skipped (already on disk)r   u<   
[tts] ⚠ batch aborted on quota/auth error.
      reason: r   r   uz   
      partial results saved. Re-run with a different ELEVENLABS_API_KEY to resume — already-done turns will be skipped.)	r(   AssertionErrorr   	Semaphorer   r   gatherr   print)
r   r   r   r   r   r   r   r   r   r   r   )r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   	synth_all  s    

,!&r   )NNN)rf   )NNNNNNN)r   )r   r   NNNNN)$r
   
__future__r   r   rK   loggingr   pathlibr   typingr   	getLoggerr   r   r   r   r   rI   rH   FEMALE_BY_BASEMALE_BY_BASEr4   r'   rG   rQ   rZ   r]   
__import__compileZ_KOREAN_EMOTICON_REre   ri   rl   r   r   r   r   r   r   r   <module>   s   

&	         
   %2          u       