"""Generate a resumable, versioned narration set; never replace old media. Run from ai_person_api with its configured Python environment. Reads the linked agent's actual default voice and validates the approved avatar bundle. Manifest activation is deliberately separate, after all 39 clips have been verified. """ from pathlib import Path import copy import hashlib import html import json import re import shutil import subprocess import sys import threading import httpx import numpy as np import soundfile as sf from sqlalchemy import select ROOT = Path(__file__).resolve().parents[3] WEB = ROOT / 'unreal_tran/unreal_tran_web' REPORT = ROOT / 'unreal_tran/ute2e/reports/bridge-narration-junhao-20260921' SOURCE = WEB / 'src/features/virtual-training-scripts' REVISION = 'junhao-v2-20260921' sys.path.insert(0, str(ROOT / 'ai_person/ai_person_api')) from app.config import get_settings from app.persistence.database import get_session_factory from app.persistence.agent_models import Agent, AgentAvatar, AgentVoice from app.persistence.asset_models import Avatar, CapabilityAsset from app.schemas.open_platform import GenerationCommand from app.services.moss_tts_service import MossTtsService from app.services.open_generation_worker import OpenGenerationWorker def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def write_json(path, value): temporary = path.with_suffix(path.suffix + '.tmp') temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', 'utf-8') temporary.replace(path) def manifest(name): return json.loads((SOURCE / name).read_text('utf-8').split('export default', 1)[1].strip().rstrip(';')) def probe(settings, path): return json.loads(subprocess.run([settings.ffprobe_binary, '-v', 'error', '-show_streams', '-show_format', '-of', 'json', str(path)], capture_output=True, encoding='utf-8', check=True).stdout) def prepare_audio(settings, raw, audio): samples, rate = sf.read(raw, always_2d=True) result = subprocess.run([settings.ffmpeg_binary, '-hide_banner', '-i', str(raw), '-af', 'silencedetect=noise=-48dB:d=0.65', '-f', 'null', '-'], capture_output=True, encoding='utf-8', check=True) starts = [float(x) for x in re.findall(r'silence_start: ([0-9.]+)', result.stderr)] ends = [float(x) for x in re.findall(r'silence_end: ([0-9.]+)', result.stderr)] if len(starts) > len(ends): ends.append(len(samples) / rate) pieces, cuts, last = [], [], 0 for start, end in zip(starts, ends): a, b = round((start + .16) * rate), round((end - .16) * rate) if b <= a: continue pieces.append(samples[last:a]); last = b cuts.append({'start': start, 'end': end, 'keptSeconds': .32}) pieces.append(samples[last:]) prepared = np.concatenate(pieces) sf.write(audio, prepared, rate, subtype='PCM_16') return {'duration': len(prepared) / rate, 'rawDuration': len(samples) / rate, 'pauseCuts': cuts} def main(): REPORT.mkdir(parents=True, exist_ok=True) settings = get_settings().model_copy(update={'service_base_url': 'http://1.14.103.234:8123'}) with httpx.Client(trust_env=False, timeout=30) as client: widget = client.get('http://127.0.0.1:6180/api/auth/v1/system-config/public').json()['data']['digitalHumanWidget'] with get_session_factory()() as db: agent = db.scalar(select(Agent).where(Agent.slug == widget['agentSlug'], Agent.is_delete == 0)) ar = db.scalar(select(AgentAvatar).where(AgentAvatar.agent_id == agent.id).order_by(AgentAvatar.is_default.desc(), AgentAvatar.sort_order)) vr = db.scalar(select(AgentVoice).where(AgentVoice.agent_id == agent.id).order_by(AgentVoice.is_default.desc(), AgentVoice.sort_order)) avatar, voice = db.get(Avatar, ar.avatar_id), db.get(CapabilityAsset, vr.capability_id) model = db.get(CapabilityAsset, voice.tts_model_id) assert voice.asset_code == 'builtin-voice-moss-junhao', 'Default voice changed; review before generation' assert model.status == 'ENABLED' and model.validation_status == 'AVAILABLE' assert voice.status == 'ENABLED' and voice.validation_status == 'AVAILABLE' avatar_id, voice_id = str(avatar.id), str(voice.id) provider_avatar, speaker = avatar.provider_avatar_id, voice.speaker_code profile = {'revision': REVISION, 'agent': agent.agent_name, 'agentSlug': agent.slug, 'agentDataVersion': agent.data_version, 'avatarName': avatar.avatar_name, 'providerAvatarId': provider_avatar, 'voice': voice.asset_name, 'voiceCapabilityId': voice.asset_code, 'engine': 'MOSS-TTS-Nano', 'speaker': speaker, 'speed': float(agent.voice_speed), 'materialVersion': 'v2', 'width': 480, 'height': 640, 'fps': 25, 'lipAndPlaybackInput': 'same mono 16kHz PCM16 WAV', 'pauseProcessing': 'silence below -48dB longer than 0.65s shortened to 0.32s'} response = client.get(str(settings.service_base_url).rstrip('/') + f'/api/v1/avatars/{provider_avatar}/assets/manifest.json') response.raise_for_status() profile['avatarManifestSha256'] = hashlib.sha256(response.content).hexdigest() expected = json.loads((ROOT / 'ai_person/ai_person_service/builtin_avatars/instructors-v2.json').read_text('utf-8'))['instructors'][0] assert profile['avatarManifestSha256'] == expected['bundle_sha256']['manifest.json'] progress_path = REPORT / 'generation.json' progress = json.loads(progress_path.read_text('utf-8')) if progress_path.exists() else {'profile': profile, 'clips': []} assert progress['profile'] == profile, 'Configuration changed; use a new revision' write_json(progress_path, progress) old_audio, old_video = manifest('narration-manifest.js'), manifest('narration-video-manifest.js') audio_manifest, video_manifest = copy.deepcopy(old_audio), copy.deepcopy(old_video) audio_manifest['voice'], video_manifest['profile'] = profile, profile renderer, engine = OpenGenerationWorker(None, settings, None, None), MossTtsService(settings) done = {c['key']: c for c in progress['clips']} try: for pack in audio_manifest['scripts']: videos = next(p for p in video_manifest['scripts'] if p['code'] == pack['code']) for clip in pack['steps']: key = f"{pack['code']}-step-{clip['number']:02}" text = clip['text'] # Avoid repeating the step title as a second, almost identical sentence. if key == 'TASK-VIRTUAL-005-step-01': text = '第一步,根据故障现象,确认是哪一个液压泵发生故障。' if key == 'TASK-VIRTUAL-006-step-07': text = '第七步,按照正常操作步骤,操作架桥车。' if key == 'TASK-VIRTUAL-007-step-05': text = '第五步,取下发生故障的前部液压泵。' if key == 'TASK-VIRTUAL-008-step-08': text = '第八步,用两个油堵封住大腔的两个油口,并擦净阀座结合面。' if key == 'TASK-VIRTUAL-008-step-09': text = '第九步,准备安装大腔平衡阀。擦净平衡阀及阀座结合面,然后取下两个油堵。' assert not any(s in text for s in ['原稿', '未展开', '没有展开']), key folder = WEB / 'public/legacy/virtual-training/narration-revisions' / REVISION folder.mkdir(parents=True, exist_ok=True) audio, video = folder / f'{key}.wav', folder / f'{key}.mp4' raw, capture = REPORT / f'{key}-raw.wav', REPORT / f'{key}.webm' if key in done: row = done[key] assert row['text'] == text and digest(audio) == row['audioSha256'] and digest(video) == row['videoSha256'], key else: print(f'{key}: synthesize', flush=True) if not raw.exists(): generated = engine.synthesize(text, voice_code=speaker, speed=profile['speed']) shutil.copyfile(engine.output_path(generated['id']), raw) prepared = prepare_audio(settings, raw, audio) print(f'{key}: render {prepared["duration"]:.2f}s', flush=True) command = GenerationCommand(title=key, text=text, avatarId=avatar_id, voiceId=voice_id, speed=profile['speed'], width=480, height=640, subtitles=False) renderer.capture(command, provider_avatar, audio, None, capture, threading.Event()) subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-y', '-i', str(capture), '-r', '25', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', str(video)], check=True) info = probe(settings, video) v = next(s for s in info['streams'] if s['codec_type'] == 'video') a = next(s for s in info['streams'] if s['codec_type'] == 'audio') assert (v['width'], v['height'], v['codec_name'], v['r_frame_rate']) == (480, 640, 'h264', '25/1') assert a['codec_name'] == 'aac' duration = float(info['format']['duration']) assert abs(duration - prepared['duration']) < .7, (key, duration, prepared) subprocess.run([settings.ffmpeg_binary, '-v', 'error', '-i', str(video), '-f', 'null', '-'], check=True) row = {'key': key, 'code': pack['code'], 'number': clip['number'], 'title': clip['expectedTitle'], 'text': text, **prepared, 'videoDuration': duration, 'audioSha256': digest(audio), 'videoSha256': digest(video), 'audioUrl': '/' + audio.relative_to(WEB / 'public').as_posix(), 'videoUrl': '/' + video.relative_to(WEB / 'public').as_posix()} progress['clips'].append(row); write_json(progress_path, progress) print(f'{key}: complete ({len(progress["clips"])}/39)', flush=True) clip.update(text=text, url=row['audioUrl'], stepDuration=row['duration'], stepSha256=row['audioSha256']) vc = next(c for c in videos['steps'] if c['stepCode'] == clip['stepCode']) vc.update(text=text, url=row['videoUrl'], duration=row['videoDuration'], sha256=row['videoSha256']) finally: engine.close() assert len(progress['clips']) == 39 progress['clips'].sort(key=lambda clip: clip['key']) write_json(progress_path, progress) # The two valve-review steps share one prompt; regenerate it in the same # voice so video-to-audio fallback and safety playback do not change timbre. safety_engine = MossTtsService(settings) try: for pack in audio_manifest['scripts']: for clip in pack['steps']: if not clip.get('safetyText'): continue key = hashlib.sha256(clip['safetyText'].encode()).hexdigest()[:16] raw = REPORT / f'safety-{key}-raw.wav' wav = WEB / f'public/legacy/virtual-training/narration-revisions/{REVISION}/safety-{key}.wav' if not raw.exists(): generated = safety_engine.synthesize(clip['safetyText'], voice_code=speaker, speed=profile['speed']) shutil.copyfile(safety_engine.output_path(generated['id']), raw) prepared = prepare_audio(settings, raw, wav) clip.update(safetyUrl='/' + wav.relative_to(WEB / 'public').as_posix(), safetyDuration=prepared['duration'], safetySha256=digest(wav)) finally: safety_engine.close() write_json(REPORT / 'audio-manifest.json', audio_manifest) write_json(REPORT / 'video-manifest.json', video_manifest) cards = [] for row in progress['clips']: url = '../../../unreal_tran_web/public' + row['videoUrl'] wav = '../../../unreal_tran_web/public' + row['audioUrl'] cards.append(f'

{html.escape(row["key"])} · {html.escape(row["title"])}

{html.escape(row["text"])}

试听音频 · {row["videoDuration"]:.2f} 秒

') (REPORT / 'index.html').write_text('四套流程 · Junhao 新讲解

005~008 · 教员1 v2 / MOSS · Junhao

39 段新讲解;保留旧版。长静音经过压缩,口型和播放使用同一份音频。技术检查通过不等同于人工发音与口型验收。

' + ''.join(cards) + '
', 'utf-8') print('ALL 39 COMPLETE; manifests ready for review and activation', flush=True) if __name__ == '__main__': main()