You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

198 regels
13 KiB

  1. """Generate a resumable, versioned narration set; never replace old media.
  2. Run from ai_person_api with its configured Python environment. Reads the linked
  3. agent's actual default voice and validates the approved avatar bundle. Manifest
  4. activation is deliberately separate, after all 39 clips have been verified.
  5. """
  6. from pathlib import Path
  7. import copy
  8. import hashlib
  9. import html
  10. import json
  11. import re
  12. import shutil
  13. import subprocess
  14. import sys
  15. import threading
  16. import httpx
  17. import numpy as np
  18. import soundfile as sf
  19. from sqlalchemy import select
  20. ROOT = Path(__file__).resolve().parents[3]
  21. WEB = ROOT / 'unreal_tran/unreal_tran_web'
  22. REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-narration-junhao-20260921'
  23. SOURCE = WEB / 'src/features/virtual-training-scripts'
  24. REVISION = 'junhao-v2-20260921'
  25. sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api'))
  26. from app.config import get_settings
  27. from app.persistence.database import get_session_factory
  28. from app.persistence.agent_models import Agent, AgentAvatar, AgentVoice
  29. from app.persistence.asset_models import Avatar, CapabilityAsset
  30. from app.schemas.open_platform import GenerationCommand
  31. from app.services.moss_tts_service import MossTtsService
  32. from app.services.open_generation_worker import OpenGenerationWorker
  33. def digest(path):
  34. return hashlib.sha256(path.read_bytes()).hexdigest()
  35. def write_json(path, value):
  36. temporary = path.with_suffix(path.suffix + '.tmp')
  37. temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  38. temporary.replace(path)
  39. def manifest(name):
  40. return json.loads((SOURCE / name).read_text('utf-8').split('export default', 1)[1].strip().rstrip(';'))
  41. def probe(settings, path):
  42. return json.loads(subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_streams',
  43. '-show_format', '-of', 'json', str(path)], capture_output=True, encoding='utf-8', check=True).stdout)
  44. def prepare_audio(settings, raw, audio):
  45. samples, rate = sf.read(raw, always_2d=True)
  46. result = subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-i', str(raw), '-af',
  47. 'silencedetect=noise=-48dB:d=0.65', '-f', 'null', '-'], capture_output=True, encoding='utf-8', check=True)
  48. starts = [float(x) for x in re.findall(r'silence_start: ([0-9.]+)', result.stderr)]
  49. ends = [float(x) for x in re.findall(r'silence_end: ([0-9.]+)', result.stderr)]
  50. if len(starts) > len(ends): ends.append(len(samples) / rate)
  51. pieces, cuts, last = [], [], 0
  52. for start, end in zip(starts, ends):
  53. a, b = round((start + .16) * rate), round((end - .16) * rate)
  54. if b <= a: continue
  55. pieces.append(samples[last:a]); last = b
  56. cuts.append({'start': start, 'end': end, 'keptSeconds': .32})
  57. pieces.append(samples[last:])
  58. prepared = np.concatenate(pieces)
  59. sf.write(audio, prepared, rate, subtype='PCM_16')
  60. return {'duration': len(prepared) / rate, 'rawDuration': len(samples) / rate, 'pauseCuts': cuts}
  61. def main():
  62. REPORT.mkdir(parents=True, exist_ok=True)
  63. settings = get_settings().model_copy(update={'service_base_url': 'http://1.14.103.234:8123'})
  64. with httpx.Client(trust_env=False, timeout=30) as client:
  65. widget = client.get('http://127.0.0.1:6180/api/auth/v1/system-config/public').json()['data']['digitalHumanWidget']
  66. with get_session_factory()() as db:
  67. agent = db.scalar(select(Agent).where(Agent.slug == widget['agentSlug'], Agent.is_delete == 0))
  68. ar = db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id == agent.id).order_by(AgentAvatar.is_default.desc(), AgentAvatar.sort_order))
  69. vr = db.scalar(select(AgentVoice).where(AgentVoice.agent_id == agent.id).order_by(AgentVoice.is_default.desc(), AgentVoice.sort_order))
  70. avatar, voice = db.get(Avatar, ar.avatar_id), db.get(CapabilityAsset, vr.capability_id)
  71. model = db.get(CapabilityAsset, voice.tts_model_id)
  72. assert voice.asset_code == 'builtin-voice-moss-junhao', 'Default voice changed; review before generation'
  73. assert model.status == 'ENABLED' and model.validation_status == 'AVAILABLE'
  74. assert voice.status == 'ENABLED' and voice.validation_status == 'AVAILABLE'
  75. avatar_id, voice_id = str(avatar.id), str(voice.id)
  76. provider_avatar, speaker = avatar.provider_avatar_id, voice.speaker_code
  77. profile = {'revision': REVISION, 'agent': agent.agent_name, 'agentSlug': agent.slug,
  78. 'agentDataVersion': agent.data_version, 'avatarName': avatar.avatar_name,
  79. 'providerAvatarId': provider_avatar, 'voice': voice.asset_name,
  80. 'voiceCapabilityId': voice.asset_code, 'engine': 'MOSS-TTS-Nano',
  81. 'speaker': speaker, 'speed': float(agent.voice_speed), 'materialVersion': 'v2',
  82. 'width': 480, 'height': 640, 'fps': 25, 'lipAndPlaybackInput': 'same mono 16kHz PCM16 WAV',
  83. 'pauseProcessing': 'silence below -48dB longer than 0.65s shortened to 0.32s'}
  84. response = client.get(str(settings.service_base_url).rstrip('/') + f'/api/v1/avatars/{provider_avatar}/assets/manifest.json')
  85. response.raise_for_status()
  86. profile['avatarManifestSha256'] = hashlib.sha256(response.content).hexdigest()
  87. expected = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0]
  88. assert profile['avatarManifestSha256'] == expected['bundle_sha256']['manifest.json']
  89. progress_path = REPORT / 'generation.json'
  90. progress = json.loads(progress_path.read_text('utf-8')) if progress_path.exists() else {'profile': profile, 'clips': []}
  91. assert progress['profile'] == profile, 'Configuration changed; use a new revision'
  92. write_json(progress_path, progress)
  93. old_audio, old_video = manifest('narration-manifest.js'), manifest('narration-video-manifest.js')
  94. audio_manifest, video_manifest = copy.deepcopy(old_audio), copy.deepcopy(old_video)
  95. audio_manifest['voice'], video_manifest['profile'] = profile, profile
  96. renderer, engine = OpenGenerationWorker(None, settings, None, None), MossTtsService(settings)
  97. done = {c['key']: c for c in progress['clips']}
  98. try:
  99. for pack in audio_manifest['scripts']:
  100. videos = next(p for p in video_manifest['scripts'] if p['code'] == pack['code'])
  101. for clip in pack['steps']:
  102. key = f"{pack['code']}-step-{clip['number']:02}"
  103. text = clip['text']
  104. # Avoid repeating the step title as a second, almost identical sentence.
  105. if key == 'TASK-VIRTUAL-005-step-01': text = '第一步,根据故障现象,确认是哪一个液压泵发生故障。'
  106. if key == 'TASK-VIRTUAL-006-step-07': text = '第七步,按照正常操作步骤,操作架桥车。'
  107. if key == 'TASK-VIRTUAL-007-step-05': text = '第五步,取下发生故障的前部液压泵。'
  108. if key == 'TASK-VIRTUAL-008-step-08': text = '第八步,用两个油堵封住大腔的两个油口,并擦净阀座结合面。'
  109. if key == 'TASK-VIRTUAL-008-step-09': text = '第九步,准备安装大腔平衡阀。擦净平衡阀及阀座结合面,然后取下两个油堵。'
  110. assert not any(s in text for s in ['原稿', '未展开', '没有展开']), key
  111. folder = WEB / 'public/legacy/virtual-training/narration-revisions' / REVISION
  112. folder.mkdir(parents=True, exist_ok=True)
  113. audio, video = folder / f'{key}.wav', folder / f'{key}.mp4'
  114. raw, capture = REPORT / f'{key}-raw.wav', REPORT / f'{key}.webm'
  115. if key in done:
  116. row = done[key]
  117. assert row['text'] == text and digest(audio) == row['audioSha256'] and digest(video) == row['videoSha256'], key
  118. else:
  119. print(f'{key}: synthesize', flush=True)
  120. if not raw.exists():
  121. generated = engine.synthesize(text, voice_code=speaker, speed=profile['speed'])
  122. shutil.copyfile(engine.output_path(generated['id']), raw)
  123. prepared = prepare_audio(settings, raw, audio)
  124. print(f'{key}: render {prepared["duration"]:.2f}s', flush=True)
  125. command = GenerationCommand(title=key, text=text, avatarId=avatar_id, voiceId=voice_id,
  126. speed=profile['speed'], width=480, height=640, subtitles=False)
  127. renderer.capture(command, provider_avatar, audio, None, capture, threading.Event())
  128. subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-y', '-i', str(capture), '-r', '25',
  129. '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
  130. '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(video)], check=True)
  131. info = probe(settings, video)
  132. v = next(s for s in info['streams'] if s['codec_type'] == 'video')
  133. a = next(s for s in info['streams'] if s['codec_type'] == 'audio')
  134. assert (v['width'], v['height'], v['codec_name'], v['r_frame_rate']) == (480, 640, 'h264', '25/1')
  135. assert a['codec_name'] == 'aac'
  136. duration = float(info['format']['duration'])
  137. assert abs(duration - prepared['duration']) < .7, (key, duration, prepared)
  138. subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-i', str(video), '-f', 'null', '-'], check=True)
  139. row = {'key': key, 'code': pack['code'], 'number': clip['number'], 'title': clip['expectedTitle'],
  140. 'text': text, **prepared, 'videoDuration': duration, 'audioSha256': digest(audio),
  141. 'videoSha256': digest(video), 'audioUrl': '/' + audio.relative_to(WEB / 'public').as_posix(),
  142. 'videoUrl': '/' + video.relative_to(WEB / 'public').as_posix()}
  143. progress['clips'].append(row); write_json(progress_path, progress)
  144. print(f'{key}: complete ({len(progress["clips"])}/39)', flush=True)
  145. clip.update(text=text, url=row['audioUrl'], stepDuration=row['duration'], stepSha256=row['audioSha256'])
  146. vc = next(c for c in videos['steps'] if c['stepCode'] == clip['stepCode'])
  147. vc.update(text=text, url=row['videoUrl'], duration=row['videoDuration'], sha256=row['videoSha256'])
  148. finally:
  149. engine.close()
  150. assert len(progress['clips']) == 39
  151. progress['clips'].sort(key=lambda clip: clip['key'])
  152. write_json(progress_path, progress)
  153. # The two valve-review steps share one prompt; regenerate it in the same
  154. # voice so video-to-audio fallback and safety playback do not change timbre.
  155. safety_engine = MossTtsService(settings)
  156. try:
  157. for pack in audio_manifest['scripts']:
  158. for clip in pack['steps']:
  159. if not clip.get('safetyText'): continue
  160. key = hashlib.sha256(clip['safetyText'].encode()).hexdigest()[:16]
  161. raw = REPORT / f'safety-{key}-raw.wav'
  162. wav = WEB / f'public/legacy/virtual-training/narration-revisions/{REVISION}/safety-{key}.wav'
  163. if not raw.exists():
  164. generated = safety_engine.synthesize(clip['safetyText'], voice_code=speaker, speed=profile['speed'])
  165. shutil.copyfile(safety_engine.output_path(generated['id']), raw)
  166. prepared = prepare_audio(settings, raw, wav)
  167. clip.update(safetyUrl='/' + wav.relative_to(WEB / 'public').as_posix(),
  168. safetyDuration=prepared['duration'], safetySha256=digest(wav))
  169. finally:
  170. safety_engine.close()
  171. write_json(REPORT / 'audio-manifest.json', audio_manifest)
  172. write_json(REPORT / 'video-manifest.json', video_manifest)
  173. cards = []
  174. for row in progress['clips']:
  175. url = '../../../unreal_tran_web/public' + row['videoUrl']
  176. wav = '../../../unreal_tran_web/public' + row['audioUrl']
  177. cards.append(f'<article><h2>{html.escape(row["key"])} · {html.escape(row["title"])}</h2><p>{html.escape(row["text"])}</p><video controls preload="none" src="{url}"></video><p><a href="{wav}">试听音频</a> · {row["videoDuration"]:.2f} 秒</p></article>')
  178. (REPORT / 'index.html').write_text('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>四套流程 · Junhao 新讲解</title><style>body{font:16px/1.7 system-ui;background:#eff5f3;color:#143e36;max-width:1150px;margin:30px auto;padding:20px}main{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}article{padding:18px;background:white;border-radius:12px}h2{font-size:16px}video{width:100%;aspect-ratio:3/4}a{color:#00816b}</style><h1>005~008 · 教员1 v2 / MOSS · Junhao</h1><p>39 段新讲解;保留旧版。长静音经过压缩,口型和播放使用同一份音频。技术检查通过不等同于人工发音与口型验收。</p><main>' + ''.join(cards) + '</main><script>document.addEventListener("play",e=>document.querySelectorAll("video,audio").forEach(x=>{if(x!==e.target)x.pause()}),true)</script></html>', 'utf-8')
  179. print('ALL 39 COMPLETE; manifests ready for review and activation', flush=True)
  180. if __name__ == '__main__': main()