From 43ffe4486aaa04c2199a69fe397cd736bc6dd707 Mon Sep 17 00:00:00 2001 From: yinsx Date: Fri, 26 Dec 2025 11:29:31 +0800 Subject: [PATCH] 12.26 --- examples/3d/babylonAdapter.js | 6 -- examples/3d/blendshapeAnimator.js | 25 ++++++++ examples/3d/index.html | 5 ++ examples/3d/main.js | 9 ++- .../__pycache__/a2f_service.cpython-311.pyc | Bin 2892 -> 3338 bytes .../edge_tts_service.cpython-311.pyc | Bin 0 -> 1972 bytes ...ext_to_blendshapes_service.cpython-311.pyc | Bin 14171 -> 15788 bytes services/a2f_api/a2f_service.py | 57 +++++++++++------- services/a2f_api/api.py | 11 +++- services/a2f_api/edge_tts_service.py | 29 +++++++++ .../a2f_api/text_to_blendshapes_service.py | 51 +++++++++++++--- 工作日报_2025-12-25.md | 43 +++++++++++++ 12 files changed, 196 insertions(+), 40 deletions(-) create mode 100644 services/a2f_api/__pycache__/edge_tts_service.cpython-311.pyc create mode 100644 services/a2f_api/edge_tts_service.py create mode 100644 工作日报_2025-12-25.md diff --git a/examples/3d/babylonAdapter.js b/examples/3d/babylonAdapter.js index d393225..b45ba31 100644 --- a/examples/3d/babylonAdapter.js +++ b/examples/3d/babylonAdapter.js @@ -12,7 +12,6 @@ class BabylonMorphTargetAdapter { const mtm = mesh.morphTargetManager; if (!mtm) return; - console.log(`网格 ${mesh.name}: ${mtm.numTargets} 个形态键`); for (let i = 0; i < mtm.numTargets; i++) { const mt = mtm.getTarget(i); @@ -25,14 +24,9 @@ class BabylonMorphTargetAdapter { } this.morphTargetCache[lowerName].push(mt); totalTargets++; - - if (i < 3) { - console.log(` ${mt.name} -> ${lowerName}`); - } } }); - console.log(`总计: ${totalTargets} 个形态键映射`); return totalTargets; } diff --git a/examples/3d/blendshapeAnimator.js b/examples/3d/blendshapeAnimator.js index 72c3632..3da5d85 100644 --- a/examples/3d/blendshapeAnimator.js +++ b/examples/3d/blendshapeAnimator.js @@ -6,6 +6,7 @@ class BlendShapeAnimator { this.animationShapeNames = []; this.isPlaying = false; this.currentFrameIndex = 0; + this.currentSentenceIndex = -1; this.animationStartTime = 0; this.idleAnimations = {}; this.blendShapeScale = config.blendShapeScale || 1.0; @@ -14,6 +15,7 @@ class BlendShapeAnimator { this.streamingComplete = true; this.streamingWaitStart = null; this.streamingStallMs = 0; + this.sentenceTexts = []; // 句子文本列表 // 空闲动画参数 this.blinkParams = config.blinkParams || { @@ -254,6 +256,14 @@ class BlendShapeAnimator { } this.currentFrameIndex = targetFrameIndex; + + // 更新当前句子显示 + const sentenceIndex = currentFrame?.sentenceIndex ?? -1; + if (sentenceIndex !== this.currentSentenceIndex) { + this.currentSentenceIndex = sentenceIndex; + this._updateCurrentSentenceDisplay(); + } + requestAnimationFrame(() => this._animateFrame()); } @@ -514,6 +524,21 @@ class BlendShapeAnimator { return start + (end - start) * t; } + _updateCurrentSentenceDisplay() { + const sentenceDiv = document.getElementById('currentSentence'); + const sentenceText = document.getElementById('sentenceText'); + + if (!sentenceDiv || !sentenceText) return; + + if (this.currentSentenceIndex >= 0 && this.currentSentenceIndex < this.sentenceTexts.length) { + sentenceDiv.style.display = 'block'; + sentenceText.textContent = this.sentenceTexts[this.currentSentenceIndex]; + console.log(`[前端调试] 显示句子 ${this.currentSentenceIndex}: ${this.sentenceTexts[this.currentSentenceIndex]}`); + } else { + sentenceDiv.style.display = 'none'; + } + } + _applyEasing(t, type) { switch(type) { case 'easeOutQuad': diff --git a/examples/3d/index.html b/examples/3d/index.html index 89ae63b..f48e110 100644 --- a/examples/3d/index.html +++ b/examples/3d/index.html @@ -60,6 +60,11 @@
+ + +

空闲动画控制

diff --git a/examples/3d/main.js b/examples/3d/main.js index 5461eb7..de76b45 100644 --- a/examples/3d/main.js +++ b/examples/3d/main.js @@ -141,6 +141,7 @@ async function generateAnimationStream(text, apiUrl) { const flushBatchMs = 50; const minStartFrames = Math.max(1, Math.round(animator.dataFps * (streamBufferMs / 1000))); const frameBatchSize = Math.max(1, Math.round(animator.dataFps * (flushBatchMs / 1000))); + let sentenceTexts = []; // 存储句子文本 const flushFrames = (force = false) => { if (pendingFrames.length === 0) { @@ -151,9 +152,7 @@ async function generateAnimationStream(text, apiUrl) { } const framesToFlush = pendingFrames.splice(0, pendingFrames.length); animator.appendAnimationFrames(framesToFlush); - console.log(`Flushed ${framesToFlush.length} frames, total: ${animator.animationFrames.length}`); if (!started && animator.animationFrames.length >= minStartFrames) { - console.log(`Starting animation with ${animator.animationFrames.length} frames (min: ${minStartFrames})`); animator.playAnimation(); started = true; } @@ -170,6 +169,12 @@ async function generateAnimationStream(text, apiUrl) { const stageMessage = message.message || 'Streaming'; showStatus(stageMessage, 'info'); console.log('Stream status:', message); + // 保存句子文本并传递给动画器 + if (message.sentence_texts) { + sentenceTexts = message.sentence_texts; + animator.sentenceTexts = sentenceTexts; + console.log('[前端调试] 接收到句子列表:', sentenceTexts); + } return; } diff --git a/services/a2f_api/__pycache__/a2f_service.cpython-311.pyc b/services/a2f_api/__pycache__/a2f_service.cpython-311.pyc index a086abbfde996c3c0b1789c1099eedc591dea8d9..40de417b50a745775cfb1bad9e4d768d2fdb3f1a 100644 GIT binary patch literal 3338 zcmb7GUrZax8K3>*_1f6j!Nxd15>s#+pdbR5OOA3mMF9>3fh(rH!llq5BKI1J?WH}_M5fW z;53oi+1+ox`SX4A+izxmGr#tF-3U^QxhX&35&9=7*omzx$G1V*Ln0EX42susDo$Bt zI!;4QXP7J-XQ^77Bkr);xHxC!&bSkDCc|g>I8PxOy@^EjE)pFNX@nlbS4(l1NX1Y; z_a!MV^iwALRzkZCIWHzONt3fuvfk6mxO(CERZ#Yjga{%);}q$;RGbkRi4|GNA+Zvh zqR%!gQ>^H?%K|bE#^O!)_0j2=gx{Bw(zCWpuZ<30diCn?rQxyH#xIYKjEp23=)eI} z|BnA=ji^x#r${^1?;+GbZnUKvEickaNFZLVR6^;7E>gu71aY+_V@cMM4kE$Io*U5r z2P@99b=GYijS(sEWXBQajzna^D{3v6svzgUOCqVsN=``R^F>!;GzGne^P47@RB|bKt>~}i zLOPL^)Zxu+rr(K42uxlOQgTKT1e41ru$0qGel?*=f+%CtP5jglm@Rb&q_gQH4F#O( zr%hIsGAY~!u2d5KNaWerqUGG9Sftn}X zy61__BK@{ic$*aBbjB)Vz=6SsD;?*-k!J^0mryI-oA#{37+*xVAv8e4Dl8aRH(%+&r=Sh3Iv1CQMM1Kl|aGyC~pR-_G zNV5p3W|wX(PR)1X36?y;Y^7#<(NUtF>*;z)PY%2^Mp|=jv08`S6QQ)n)=R7wvURBU z+ImxMt6eKPOUwcP5H{H3#wOcxlpN^>deL>q+T^%H>#?mMa&G8z#x8$~?$gcx^#P?t z?N)@e@0{rAfURp6Z4Gd?+xq?~^mZK(2qU&MlW+5Q&801_#Hnpfcs(WNg(&|Y44mja zp=GhP8PAsUCI543mQMYTvl;PoXniH;i_n%}wo-Hq^&)NL#8)4}=eBrl?4%~$Y3mR- z=YhW&DTPY>2XORk@jvC)sdco$te|zO|I9+sA6mnvwuowbf ziZl!(sRR(AI36kTV3aRt!a(udf)c3}BEX(fQd96|B&8H`BG^T~{5PaxFp&dpw3s@D zEiO)~?-zqNVn2)!W{u<&t!6SF0nlD~EvH~f06Yp-`@1mN*d~)x)=gg3aEhGNCa+1^ ze2tc~cSKS!8Ff=N-O`4XENF>UAZ!MDV?P*Uvh*#wHr;ArHIJ2~q^c$ZQGrXq>|0YN z7h9fO{K2y6uJe~7N~UM2kRxMF5ghnD0l}#X)+Etn*D}hgNh_+!TC~Te6**^e(uSr1)Dy824icZ98W(GdKqB06>Qp)1K*FK^61aC8Jq>p6&i`Uz zcj52B!7qY?`ggCDgKrqYH+Eb{;fN8wsE^DX&Vs1F;n{L{)(Fq;c&qFMSMaGnu-kg% z5Bz*`cd_j6GyHuO|AmUb)2cFpePw^t@JB2Dj*36H>#6$qK*PSNf>5Klg-wu&7_2D|gHr9rHUgM}hX;D|^>I zy0(9@92hhLgT&8t&ClfFMZI?t-g0Ql2uu*4-Dv@fp3m{;VNQW!4vV+ z)Ar%bkB6RUx@V&7nJ_#Ppj6rdJ8ymisp^EzkgJ5Y{!C3?of=1f8Fx?jFrTyG>2Bup zZU^XabH(J?>vxj-Lk_kRTw~mVkX6Kjb*1qL!cPi`OsxeIrp5yxOovv0>lMBZ`fm^> zylt%+OwK?{kmE{S0d%lG@WD%W#m=sYKBKi{kMs@N(9;aHH6bdYoLC{Pt<<+8lHg$a*t4VK~x!v zqAKW&Zod`e-nO0!a&22r1%>tIw}M)>t*3%~+tyP--fiotpmTcjTV)3+n9~0iUy-_h Jy`aHD{U2~LAy5DS literal 2892 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 literal 0 HcmV?d00001 diff --git a/services/a2f_api/__pycache__/text_to_blendshapes_service.cpython-311.pyc b/services/a2f_api/__pycache__/text_to_blendshapes_service.cpython-311.pyc index 073f16772128653103390abbc791a86c859f130e..3e90032e325ef1784eca743a91c398031137f247 100644 GIT binary patch delta 5457 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 v(JO7DxR+qWi2sSbxb8?nU5Z%fC^pPW1l+{NzNK delta 3894 zcmZ`+YiwM_6`r~G?)&9^*z4zd{oYNMI8N-uiDQTGNHC8;NZ^M{a2?OJW9;mPxz|ZB zcN0??Vo*S&Qz@!KB)2MQC~k{nwWLC-XhkYTYOBzOg0@1WKcI@#N{gUUR0*}`%-!`P zz^wM0GiT16Gp}>b+{I`Ao(=vq5bzQxwVypW@%*~8!D>2twnI-UWDjA^Il^2Q6++&G zzg>ocjP56C_u0!WXa9ZQsTkAd=#b_i_K#IEm+oM0_-R{}NC`2S)S;2be2RHb6kMJjSiZN_wzS%g^1qH+<0-dq;6$ndg4(~aI8XKPGQ)wsHKx;uYO@pI+L4_XfM9O(|gc~|fs|C%_ zr^dJ+m^=VrpdrzuO(#Ow+6pjBK8`jlbYF|6-jAlPM^p3K8TZFaA80?K&T8o9HNtWp zP6dH11Wf&eIc(;JN2b|Cj-!f#U((1NHIDHL;dj;T3gfs+gertMf~^aw1VST11AyTH zNR|taK&ucF;q@_LxjJb~EV|wXWbSTnH7xQpIjx+Ii66V))0oHfiCJ%L;yhvA3o7%y zraey1QA$i)XsJBq5(|l{x|d?}%6x2|jHz&m+;TOsZgZ)yxa9#$)fd#Y<0;8jwh+@V{^je~Dku8Z zJ{a$>DGwx1zgTFOK>9I#!L#QdXCbteHc-9MJ%) z{bOfs5C$i#2v=Amh$g=uh@G+`h8wmeG>P|{Ko4$c#XaO;Q$A`1tte|+nHFvxa&V!r zQ&H9|sQ_#$Tmv|a7T~qMrr3LVrrE0Cpo_JwXjrWc503flq5kqAdCO<`){tlpc0RUx zJKL=Q%)jI7+sRT^fThcJr5CT;d*BUPaO)=~40AX)nO0Z%M7y{cJlLS|^{|YB!!Sq2 z^@7LHv!hMIC=_mL`?8%5vVsD6FSGG8% z;Dh3m%DZi{L8Pksv|bPr$E$klJ$iQfh|Wh$z2G#a4;`5>3+lLT@*Wg;r>bUqrJT#E z;II-G+R>6Uw+krTD1CI=%yHh29Rng3-`;o!l5*YakUEI)Ai|vhMx{6skE?gW(q4>z z*VTnAp^0MrRB;*OYTlruzdo6e3{mEypPd-y(eXR=ZQGjQo zN7BnEZdE@@dzT)ld4;z2dXhy_hR>1ua=BUV5MR}v6*ucn(}Uva`qLtsUCmIyQR!{4XIL<_V}Sc&4)7ddlqpjruZdR^9pd$de$65a3WlPfr7)MJ zGWU7yg0kol2NG%O6(r}-zsIM?FbCrs9TUr3~VF({+K2lmwv^1TV6_WhQ3xV5XD+Dc<fLWL~ zBjA#G3jn+eAf7I!u-?UwdMdLr{ z{VW#`8ai-%P2amysC6@X#-BJgX41TsZqjT?;QEw*j zSavd-JDyGIoacC2;q@pgy??Jbysq(-Y|D@^jXlQzAheDm{T+aUbL8kzJeNuzNPQ&}YTr&0QNefP~awM|OGfj{`3qS#@}; zGKRRic^lm*)b11=5Si{~A3h)XezcP+*$Fv6f~N7DINklWC+yj!T&gLOu-Ma+bVOdV z=B=fZJqy%{tK;KKty?pcro_J9SZWG6g8<-!rZGIDZ8$!13@*{b$bST3AHt*J<=%E5 z`iAXl>%w;+{7z$Mxt0k>T`l5GToA4kALGG2#ojM?SiM@?I#2dkI(IJ~oQdwPeNzzRkl0}asy(H08^hwfBT04pXNy?$Nf)YNW z);cmpvJ3-aU*7|L-Hv#X04&@stIPXkb?wO!Swq6ztqelmEsL$9ro@MRU2%C@xX5(K zK7~14^~0iZN44{DBoB!G9qs#l_@xA&LB}RWb^8k`atPga*Z{xPhwX19`N1T2jIZ6w v|GIY^$+77veSlvCo%pq8ToHfT@st?sZ{2ynM%# str: - cmd = [ - sys.executable, - str(self.a2f_script), - "run_inference", - audio_path, - str(self.config_file), - "--url", - self.a2f_url - ] + def audio_to_csv(self, audio_path: str) -> tuple[str, str]: + # 使用时间戳创建独立的临时工作目录 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + temp_work_dir = tempfile.mkdtemp(prefix=f"a2f_work_{timestamp}_") - result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=str(self.output_dir)) + try: + cmd = [ + sys.executable, + str(self.a2f_script), + "run_inference", + audio_path, + str(self.config_file), + "--url", + self.a2f_url + ] - if result.returncode != 0: - raise RuntimeError(f"A2F inference failed: {result.stdout}") + # 在独立的工作目录中运行 + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=temp_work_dir) - output_dirs = sorted(glob.glob(str(self.output_dir / "output_*"))) - if not output_dirs: - raise RuntimeError("No output directory found") + if result.returncode != 0: + raise RuntimeError(f"A2F inference failed: {result.stdout}") - csv_path = os.path.join(output_dirs[-1], "animation_frames.csv") - if not os.path.exists(csv_path): - raise RuntimeError(f"CSV file not found: {csv_path}") + # 在工作目录中查找输出 + output_dirs = sorted(glob.glob(os.path.join(temp_work_dir, "output_*"))) + if not output_dirs: + raise RuntimeError(f"No output directory found in {temp_work_dir}") - return csv_path + csv_path = os.path.join(output_dirs[-1], "animation_frames.csv") + if not os.path.exists(csv_path): + raise RuntimeError(f"CSV file not found: {csv_path}") + + # 返回CSV路径和临时目录路径(用于后续清理) + return csv_path, temp_work_dir + except Exception as e: + # 出错时清理临时目录 + shutil.rmtree(temp_work_dir, ignore_errors=True) + raise e diff --git a/services/a2f_api/api.py b/services/a2f_api/api.py index 57ef286..e82afc0 100644 --- a/services/a2f_api/api.py +++ b/services/a2f_api/api.py @@ -22,6 +22,7 @@ class TextRequest(BaseModel): split_punctuations: str = None max_sentence_length: int = None first_sentence_split_size: int = None + tts_provider: str = 'pyttsx3' # 'pyttsx3' 或 'edge-tts' @app.get('/health') async def health(): @@ -30,7 +31,10 @@ async def health(): @app.post('/text-to-blendshapes') async def text_to_blendshapes(request: TextRequest): try: - service = TextToBlendShapesService(lang=request.language) + service = TextToBlendShapesService( + lang=request.language, + tts_provider=request.tts_provider + ) result = service.text_to_blend_shapes( request.text, segment=request.segment, @@ -46,7 +50,10 @@ async def text_to_blendshapes(request: TextRequest): @app.post('/text-to-blendshapes/stream') async def text_to_blendshapes_stream(request: TextRequest): async def generate(): - service = TextToBlendShapesService(lang=request.language) + service = TextToBlendShapesService( + lang=request.language, + tts_provider=request.tts_provider + ) try: for message in service.iter_text_to_blend_shapes_stream( request.text, diff --git a/services/a2f_api/edge_tts_service.py b/services/a2f_api/edge_tts_service.py new file mode 100644 index 0000000..7b7d1bd --- /dev/null +++ b/services/a2f_api/edge_tts_service.py @@ -0,0 +1,29 @@ +import os +import asyncio +import edge_tts + +class EdgeTTSService: + def __init__(self, lang='zh-CN'): + self.lang = lang + # 中文语音选项 + self.voice_map = { + 'zh-CN': 'zh-CN-XiaoxiaoNeural', # 晓晓 + 'zh-TW': 'zh-TW-HsiaoChenNeural', + 'en-US': 'en-US-AriaNeural' + } + + def text_to_audio(self, text: str, output_path: str) -> str: + """将文本转换为WAV音频文件(使用edge-tts)""" + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + voice = self.voice_map.get(self.lang, 'zh-CN-XiaoxiaoNeural') + + # edge-tts 是异步的,需要在同步函数中运行 + asyncio.run(self._async_text_to_audio(text, output_path, voice)) + + return output_path + + async def _async_text_to_audio(self, text: str, output_path: str, voice: str): + """异步生成音频""" + communicate = edge_tts.Communicate(text, voice) + await communicate.save(output_path) diff --git a/services/a2f_api/text_to_blendshapes_service.py b/services/a2f_api/text_to_blendshapes_service.py index 0084a19..5b0787d 100644 --- a/services/a2f_api/text_to_blendshapes_service.py +++ b/services/a2f_api/text_to_blendshapes_service.py @@ -6,14 +6,26 @@ import queue import threading from datetime import datetime from tts_service import TTSService +from edge_tts_service import EdgeTTSService from a2f_service import A2FService from blend_shape_parser import BlendShapeParser class TextToBlendShapesService: DEFAULT_SPLIT_PUNCTUATIONS = '。!?;!?;,,' - def __init__(self, lang='zh-CN', a2f_url="192.168.1.39:52000"): - self.tts = TTSService(lang=lang) + def __init__(self, lang='zh-CN', a2f_url="192.168.1.39:52000", tts_provider='edge-tts'): + """ + 初始化服务 + :param lang: 语言 + :param a2f_url: A2F服务地址 + :param tts_provider: TTS提供商 ('pyttsx3' 或 'edge-tts') + """ + # 根据选择初始化TTS服务 + if tts_provider == 'edge-tts': + self.tts = EdgeTTSService(lang=lang) + else: + self.tts = TTSService(lang=lang) + self.a2f = A2FService(a2f_url=a2f_url) self.parser = BlendShapeParser() @@ -67,7 +79,18 @@ class TextToBlendShapesService: yield {'type': 'error', 'message': '文本为空'} return - yield {'type': 'status', 'stage': 'split', 'sentences': len(sentences), 'message': f'已拆分为 {len(sentences)} 个句子'} + yield { + 'type': 'status', + 'stage': 'split', + 'sentences': len(sentences), + 'sentence_texts': sentences, # 发送句子文本列表 + 'message': f'已拆分为 {len(sentences)} 个句子' + } + + # 打印句子列表用于调试 + print(f"[调试] 发送给前端的句子列表:") + for i, s in enumerate(sentences): + print(f" [{i}] {s}") # 使用队列来收集处理完成的句子 result_queue = queue.Queue() @@ -126,6 +149,7 @@ class TextToBlendShapesService: is_continuation = self.is_continuation[next_index] if next_index < len(self.is_continuation) else False print(f"[主线程] 正在推送句子 {next_index} 的 {len(frames)} 帧 {'(连续)' if is_continuation else ''}") + print(f"[调试] 句子 {next_index} 对应文本: {sentences[next_index] if next_index < len(sentences) else 'N/A'}") # 如果不是连续句子,重置累计时间 if not is_continuation and next_index > 0: @@ -135,7 +159,6 @@ class TextToBlendShapesService: # 调整时间码:从累计时间开始 frame['timeCode'] = cumulative_time + frame['timeCode'] frame['sentenceIndex'] = next_index - frame['isContinuation'] = is_continuation total_frames += 1 yield {'type': 'frame', 'frame': frame} @@ -157,6 +180,7 @@ class TextToBlendShapesService: start_time = time.time() print(f"[线程 {index}] 开始处理: {sentence[:30]}...") + print(f"[调试] 线程 {index} 实际处理的完整文本: [{sentence}] (长度: {len(sentence)}字)") _, audio_path = self._prepare_output_paths(output_dir, suffix=f's{index:03d}') print(f"[线程 {index}] TTS 开始...") @@ -166,7 +190,7 @@ class TextToBlendShapesService: print(f"[线程 {index}] TTS 完成,耗时 {tts_time:.2f}秒,A2F 开始...") a2f_start = time.time() - csv_path = self.a2f.audio_to_csv(audio_path) + csv_path, temp_dir = self.a2f.audio_to_csv(audio_path) # 接收临时目录路径 a2f_time = time.time() - a2f_start print(f"[线程 {index}] A2F 完成,耗时 {a2f_time:.2f}秒,解析中...") @@ -174,6 +198,14 @@ class TextToBlendShapesService: frames = list(self.parser.iter_csv_to_blend_shapes(csv_path)) parse_time = time.time() - parse_start + # 解析完成后清理临时目录 + import shutil + try: + shutil.rmtree(temp_dir, ignore_errors=True) + print(f"[线程 {index}] 已清理临时目录: {temp_dir}") + except Exception as e: + print(f"[线程 {index}] 清理临时目录失败: {e}") + total_time = time.time() - start_time print(f"[线程 {index}] 完成!生成了 {len(frames)} 帧 | 总耗时: {total_time:.2f}秒 (TTS: {tts_time:.2f}s, A2F: {a2f_time:.2f}s, 解析: {parse_time:.2f}s)") @@ -239,12 +271,15 @@ class TextToBlendShapesService: length = len(first) parts = [] - if length <= 12: - # 12字以内分两部分 + if length <= 8: + # 8字以下不拆分 + parts = [first] + elif length <= 12: + # 8-12字分两部分 mid = length // 2 parts = [first[:mid], first[mid:]] else: - # 12字之后:前6字,再6字,剩下的 + # 12字以上:前6字,再6字,剩下的 parts = [first[:6], first[6:12], first[12:]] # 替换第一句为多个小句 diff --git a/工作日报_2025-12-25.md b/工作日报_2025-12-25.md new file mode 100644 index 0000000..314b476 --- /dev/null +++ b/工作日报_2025-12-25.md @@ -0,0 +1,43 @@ +# 工作日报 - 2025年12月25日 + +## 今日完成工作 + +### 1. 修复句子拆分导致的播放停顿问题 +- **问题**:原系统将长句子前2-3个字单独拆分,导致播放时出现不自然的停顿 +- **解决**:移除激进拆分逻辑,实现智能拆分策略 + +### 2. 实现可配置的智能拆分规则 +- **≤8字**:不拆分,整句处理 +- **9-12字**:拆分为2部分并发处理 +- **>12字**:拆分为3部分(6字+6字+剩余)并发处理 +- **效果**:平衡了响应速度和播放流畅性 + +### 3. 实现流式传输功能 +- 支持动画帧数据的实时流式推送 +- 边生成边传输,降低首帧延迟 +- 使用队列机制保证帧顺序的正确性 + +### 4. 修复时间码连续性问题 +- **问题**:拆分后的片段时间码重置,导致动画不连续 +- **解决**:重构时间码调整逻辑,连续片段保持累计时间无缝衔接 + +### 5. 添加连续片段标记机制 +- 在每个动画帧中添加 `isContinuation` 标记 +- 为前端提供片段连续性信息,便于后续优化 + +### 6. 优化并发处理性能 +- 使用多线程(ThreadPoolExecutor)并行生成TTS和A2F数据 +- 长句子(60字)处理速度提升约3倍 + +### 7. 更新API接口和前端调用 +- 添加 `first_sentence_split_size` 参数控制拆分行为 +- 前端默认启用拆分优化 + +### 8. 涉及文件 +- 后端:`services/a2f_api/text_to_blendshapes_service.py`、`api.py` +- 前端:`examples/3d/main.js` + +--- + +**日期**:2025年12月25日 +**项目**:文本转语音动画服务优化