1	"""
2	# Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved.
3
4	Licensed under the Apache License, Version 2.0 (the "License");
5	you may not use this file except in compliance with the License.
6	You may obtain a copy of the License at
7
8	    https://www.apache.org/licenses/LICENSE-2.0
9
10	Unless required by applicable law or agreed to in writing, software
11	distributed under the License is distributed on an "AS IS" BASIS,
12	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13	See the License for the specific language governing permissions and
14	limitations under the License.
15
16	"""
17
18	import numpy as np
19
20	from engines.defaults import (
21	    default_argument_parser,
22	    default_config_parser,
23	    default_setup,
24	)
25	from engines.infer import INFER
26	import librosa
27	from tqdm import tqdm
28	import time
29
30
31	def export_json(bs_array, json_path):
32	    from models.utils import export_blendshape_animation, ARKitBlendShape
33	    export_blendshape_animation(bs_array, json_path, ARKitBlendShape, fps=30.0)
34
35	if __name__ == '__main__':
36	    args = default_argument_parser().parse_args()
37	    args.config_file = 'configs/lam_audio2exp_config_streaming.py'
38	    cfg = default_config_parser(args.config_file, args.options)
39
40
41	    cfg = default_setup(cfg)
42	    infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg))
43	    infer.model.eval()
44
45	    audio, sample_rate = librosa.load(cfg.audio_input, sr=16000)
46	    context = None
47	    input_num = audio.shape[0]//16000+1
48	    gap = 16000
49	    all_exp = []
50	    for i in tqdm(range(input_num)):
51
52	        start = time.time()
53	        output, context = infer.infer_streaming_audio(audio[i*gap:(i+1)*gap], sample_rate, context)
54	        end = time.time()
55	        print('Inference time {}'.format(end - start))
56	        all_exp.append(output['expression'])
57
58	    all_exp = np.concatenate(all_exp,axis=0)
59
60	    export_json(all_exp, cfg.save_json_path)

"""
# Copyright 2024-2025 The Alibaba 3DAIGC Team Authors. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

"""

import numpy as np

from engines.defaults import (
    default_argument_parser,
    default_config_parser,
    default_setup,
)
from engines.infer import INFER
import librosa
from tqdm import tqdm
import time

def export_json(bs_array, json_path):
    from models.utils import export_blendshape_animation, ARKitBlendShape
    export_blendshape_animation(bs_array, json_path, ARKitBlendShape, fps=30.0)

if __name__ == '__main__':
    args = default_argument_parser().parse_args()
    args.config_file = 'configs/lam_audio2exp_config_streaming.py'
    cfg = default_config_parser(args.config_file, args.options)

    cfg = default_setup(cfg)
    infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg))
    infer.model.eval()

    audio, sample_rate = librosa.load(cfg.audio_input, sr=16000)
    context = None
    input_num = audio.shape[0]//16000+1
    gap = 16000
    all_exp = []
    for i in tqdm(range(input_num)):

        start = time.time()
        output, context = infer.infer_streaming_audio(audio[i*gap:(i+1)*gap], sample_rate, context)
        end = time.time()
        print('Inference time {}'.format(end - start))
        all_exp.append(output['expression'])

    all_exp = np.concatenate(all_exp,axis=0)

    export_json(all_exp, cfg.save_json_path)


Bash

pip show edge-tts pyttsx3 2>/dev/null; which espeak espeak-ng 2>/dev/null; python3 -c "import edge_tts; print('edge-tts OK')" 2>/dev/null || echo "edge-tts absent"

Check available TTS options


edge-tts absent


which ffmpeg && ffmpeg -version 2>&1 | head -1; pip3 install edge-tts --quiet 2>&1 | tail -3

Check ffmpeg and install edge-tts


/usr/bin/ffmpeg
ffmpeg version 8.0.1-3ubuntu2 Copyright (c) 2000-2025 the FFmpeg developers
/bin/bash: line 1: pip3: command not found


python3 -m pip install edge-tts 2>&1 | tail -5

Install edge-tts with python3 -m pip


/usr/bin/python3: No module named pip


which python && python --version; python -m pip install edge-tts 2>&1 | tail -5

Find working python and install edge-tts


/bin/bash: line 1: python: command not found


$HOME/miniconda3/bin/pip install edge-tts 2>&1 | tail -5 && $HOME/miniconda3/bin/python -c "import edge_tts; print('OK')"

Install edge-tts in miniconda base env


Downloading frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl (234 kB)
Downloading propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (61 kB)
Installing collected packages: tabulate, propcache, multidict, frozenlist, attrs, aiohappyeyeballs, yarl, aiosignal, aiohttp, edge-tts

Successfully installed aiohappyeyeballs-2.6.2 aiohttp-3.14.1 aiosignal-1.4.0 attrs-26.1.0 edge-tts-7.2.8 frozenlist-1.8.0 multidict-6.7.1 propcache-0.5.2 tabulate-0.10.0 yarl-1.24.2
OK


ls /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/ && echo "---" && $HOME/miniconda3/bin/python -c "
import json
with open('/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/bsData.json') as f:
    d = json.load(f)
print('names count:', len(d['names']))
print('names sample:', d['names'][:5])
print('frames count:', len(d['frames']))
print('first frame:', d['frames'][0][:5])
"

Check barbara bsData format


Error: Exit code 1
animation.glb
bsData.json
offset.ply
skin.glb
vertex_order.json
---
Traceback (most recent call last):
  File "<string>", line 8, in <module>
    print('first frame:', d['frames'][0][:5])
                          ~~~~~~~~~~~~~~^^^^
KeyError: slice(None, 5, None)
names count: 52
names sample: ['browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight']
frames count: 240


cool ca fonctionne, implemente ça sur mon gui d'avatar


Installation et configuration de Blender


$HOME/miniconda3/bin/python -c "
import json
with open('/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/bsData.json') as f:
    d = json.load(f)
print('type frames[0]:', type(d['frames'][0]))
import json; print(json.dumps(d['frames'][0])[:200])
print('metadata:', d.get('metadata'))
"

Check bsData frame format


type frames[0]: <class 'dict'>
{"weights": [0.0009596033487468958, 0.0009596033487468958, 0.001191797899082303, 0.0008496011141687632, 0.0008496011141687632, 0.006510531529784203, 0.0020005949772894382, 0.0020005949772894382, 0.360
metadata: {'fps': 30.0, 'frame_count': 240, 'blendshape_names': ['browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight', 'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight', 'eyeBlinkLeft', 'eyeBlinkRight', 'eyeLookDownLeft', 'eyeLookDownRight', 'eyeLookInLeft', 'eyeLookInRight', 'eyeLookOutLeft', 'eyeLookOutRight', 'eyeLookUpLeft', 'eyeLookUpRight', 'eyeSquintLeft', 'eyeSquintRight', 'eyeWideLeft', 'eyeWideRight', 'jawForward', 'jawLeft', 'jawOpen', 'jawRight', 'mouthClose', 'mouthDimpleLeft', 'mouthDimpleRight', 'mouthFrownLeft', 'mouthFrownRight', 'mouthFunnel', 'mouthLeft', 'mouthLowerDownLeft', 'mouthLowerDownRight', 'mouthPressLeft', 'mouthPressRight', 'mouthPucker', 'mouthRight', 'mouthRollLower', 'mouthRollUpper', 'mouthShrugLower', 'mouthShrugUpper', 'mouthSmileLeft', 'mouthSmileRight', 'mouthStretchLeft', 'mouthStretchRight', 'mouthUpperUpLeft', 'mouthUpperUpRight', 'noseSneerLeft', 'noseSneerRight', 'tongueOut']}


Je vérifie la structure du config A2E avant d'implémenter.


cat /mnt/DATA/WORK/LAM_Audio2Expression/configs/lam_audio2exp_config_streaming.py | head -40

Read A2E streaming config


weight = 'pretrained_models/lam_audio2exp_streaming.tar'  # path to model weight
ex_vol = True # extract
audio_input = './assets/sample_audio/BarackObama_english.wav'
save_json_path = 'bsData.json'

audio_sr = 16000
fps = 30.0

movement_smooth = False
brow_movement = False
id_idx = 0

resume = False  # whether to resume training process
evaluate = True  # evaluate after each epoch training process
test_only = False  # test process

seed = None  # train process will init a random seed and record
save_path = "exp/audio2exp"
num_worker = 16  # total worker in all gpu
batch_size = 16  # total batch size in all gpu
batch_size_val = None  # auto adapt to bs 1 for each gpu
batch_size_test = None  # auto adapt to bs 1 for each gpu
epoch = 100  # total epoch, data loop = epoch // eval_epoch
eval_epoch = 100  # sche total eval & checkpoint epoch

sync_bn = False
enable_amp = False
empty_cache = False
find_unused_parameters = False

mix_prob = 0
param_dicts = None  # example: param_dicts = [dict(keyword="block", lr_scale=0.1)]

# model settings
model = dict(
    type="DefaultEstimator",
    backbone=dict(
        type="Audio2Expression",
        pretrained_encoder_type='wav2vec',
        pretrained_encoder_path='facebook/wav2vec2-base-960h',


# Check if skin.glb has morph targets by looking at file size and checking vertex_order
ls -lh /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/
wc -c /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb

Check skin.glb file size and assets


total 7.5M
-rwxrwxr-x+1 wildlama wildlama 2.2M Mar 14  2025 animation.glb
-rwxrwxr-x+1 wildlama wildlama 386K Jun 15 23:35 bsData.json
-rwxrwxr-x+1 wildlama wildlama 1.3M Apr 16  2025 offset.ply
-rwxrwxr-x+1 wildlama wildlama 3.5M Mar 17  2025 skin.glb
-rwxrwxr-x+1 wildlama wildlama 205K Mar 14  2025 vertex_order.json
3612156 /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb


Je vérifie si skin.glb est un mesh standard ou gaussian splat.


# Check LAM_WebRender JS files
find $HOME/miniconda3 -name "*.js" -path "*gaussian*render*" 2>/dev/null | head -10
find $HOME/miniconda3 -path "*gradio_gaussian*" -type f 2>/dev/null | head -20

Find gradio_gaussian_render JS files


# Check the first bytes of skin.glb to understand its format
$HOME/miniconda3/bin/python -c "
import struct
with open('/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb', 'rb') as f:
    data = f.read(20)
print('magic:', data[:4])
print('version:', struct.unpack('<I', data[4:8])[0])
print('length:', struct.unpack('<I', data[8:12])[0])
"

Check GLB file magic bytes


magic: b'glTF'
version: 2
length: 3612156


Le GLB est un standard GLTF v2. Le renderer JS est là aussi. Je vais l'utiliser directement.


ls -lh /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/
# Look at the JS to understand the API
wc -c /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js

Check gaussian render JS file


total 3.6M
-rw-rw-r-- 1 wildlama wildlama 2.2M Jun 15 23:27 index.js
-rw-rw-r-- 1 wildlama wildlama 1.5M Jun 15 23:27 style.css
2271908 /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js


# Check what functions/API the index.js exposes
grep -o 'getExpressionData\|setExpressionData\|loadAvatar\|init\|render\|blendshape\|morph\|export' \
  /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js \
  | sort -u | head -30

Find key API functions in the renderer JS


export
getExpressionData
init
morph
render


# Find getExpressionData usage to understand data format
grep -o '.{100}getExpressionData.\{100\}' \
  /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js \
  | head -5

# Also check how the component is initialized in the Gradio app
grep -o '.{80}loadAvatar.\{80\}' \
  /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js \
  | head -3

Find API call patterns in renderer


(Bash completed with no output)


grep -c "getExpressionData" /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js
# Show surrounding context of getExpressionData
grep -o '.\{0,120\}getExpression.\{0,120\}' /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js | head -5

Analyze getExpressionData context


3
ugrep: error: error at position 91
n\x80-\xbf][\x80-\xbf]*){0,120}
  exceeds complexity limits___/


grep -a -o "getExpressionData[^;]*" /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js | head -10

Find getExpressionData pattern


getExpressionData = n == null ? void 0 : n.getExpressionData, ws.useFlame && (_.useFlame = ws.useFlame != "false"), console.log(y, A), _.viewer = new vg({
getExpressionData && (this.expressionData = this.updateBS(this.getExpressionData())), this.viewer.useFlame == !1) {
getExpressionData || (this.getExpressionData = () => this.getArkitFaceFrame(o)), !this.viewer)


# Try to find how LAM WebRender JS renders - look for init/canvas setup in a different way
$HOME/miniconda3/bin/python -c "
data = open('/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js','rb').read().decode('utf-8','ignore')
# find getExpressionData context
idx = data.find('getExpressionData')
if idx>=0:
    print(repr(data[max(0,idx-200):idx+200]))
"

Extract getExpressionData context from JS


' === null || c === void 0 ? void 0 : c.z) || 0;\n        let A = 16777215;\n        ws.backgroundColor && (A = parseInt(ws.backgroundColor, 16)), _.getChatState = n == null ? void 0 : n.getChatState, _.getExpressionData = n == null ? void 0 : n.getExpressionData, ws.useFlame && (_.useFlame = ws.useFlame != "false"), console.log(y, A), _.viewer = new vg({\n          rootElement: e,\n          threejsCa'


cat /home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/gaussian_render.py | head -60

Read gaussian_render Python component


"""gr.GaussianRender() component."""

from __future__ import annotations

from typing import (Any, Literal)
from gradio.components.base import Component
from gradio.data_classes import FileData
from gradio.events import Events

class gaussian_render(Component):
    """
    Creates an webrtc component link to aliyun-rtc-sdk which can be used to pull or push media stream.
    """

    EVENTS: Events = [
        'start',
        'start_render'
    ]

    data_model = FileData

    def __init__(
        self,
        *,
        container: bool = True,
        scale: int | None = None,
        min_width: int = 160,
        interactive: bool | None = None,
        visible: bool = True,
        elem_id: str | None = None,
        elem_classes: list[str] | str | None = None,
        render: bool = True,
        key: int | str | None = None,
        height: int | str | None = None,
        width: int | str | None = None,
        assets: str | None = None,
        audio_url: str | None = None,
    ):
        """
        Parameters:
            container: If True, will place the component in a container - providing some extra padding around the border.
            scale: relative size compared to adjacent Components. For example if Components A and B are in a Row, and A has scale=2, and B has scale=1, A will be twice as wide as B. Should be an integer. scale applies in Rows, and to top-level Components in Blocks where fill_height=True.
            min_width: minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first.
            interactive: if True, will allow users to upload and edit an image; if False, can only be used to display images. If not provided, this is inferred based on whether the component is used as an input or output.
            visible: If False, component will be hidden.
            elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.
            elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
            render: If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.
            key: if assigned, will be used to assume identity across a re-render. Components that have the same key across a re-render will have their value preserved.
            height: the height of the webrtc component
            width: the width of the webrtc component
        """
        self.height = height
        self.width = width
        self.assets = assets
        self.audio_url = audio_url

        print('gaussian render init')


# Look at how the app_lam_audio2exp.py uses the renderer
grep -n "gaussian_render\|getExpressionData\|GaussianRender\|getChatState" /mnt/DATA/WORK/LAM_Audio2Expression/app_lam_audio2exp.py | head -20

Check how the Gradio app uses the renderer


22:from gradio_gaussian_render import gaussian_render
257:                gs = gaussian_render(width=380, height=680)


# See how app_lam_audio2exp.py passes data to the renderer
sed -n '250,320p' /mnt/DATA/WORK/LAM_Audio2Expression/app_lam_audio2exp.py

Read renderer usage in Gradio app


                submit = gr.Button('Generate',
                                   elem_id='lam_generate',
                                   variant='primary')

        if h5_rendering:
            gr.set_static_paths(Path.cwd().absolute() / "assets/")
            with gr.Row():
                gs = gaussian_render(width=380, height=680)

        working_dir = gr.State()
        selected_audio = gr.Textbox(visible=False)
        selected_render_file = gr.Textbox(visible=False)

        submit.click(
            fn=assert_input_image,
            inputs=[input_image,input_zip_textbox],
            queue=False,
        ).success(
            fn=prepare_working_dir,
            outputs=[working_dir],
            queue=False,
        ).success(
            fn=core_fn,
            inputs=[input_image, audio_input,
                    working_dir, input_zip_textbox],
            outputs=[selected_audio, selected_render_file],
            queue=False,
        ).success(
            fn=audio_loading,
            outputs=[selected_audio],
            js='''(output_component) => window.loadAudio(output_component)'''
        ).success(
            fn=do_render(),
            outputs=[selected_render_file],
            js='''(selected_render_file) => window.start(selected_render_file)'''
        )

        demo.queue()
        demo.launch(inbrowser=True)

def launch_gradio_app():
    os.environ.update({
        'APP_ENABLED': '1',
        'APP_MODEL_NAME':'',
        'APP_INFER': 'configs/lam_audio2exp_streaming_config.py',
        'APP_TYPE': 'infer.audio2exp',
        'NUMBA_THREADING_LAYER': 'omp',
    })

    args = default_argument_parser().parse_args()
    args.config_file = 'configs/lam_audio2exp_config_streaming.py'
    cfg = default_config_parser(args.config_file, args.options)
    cfg = default_setup(cfg)

    cfg.ex_vol = True
    infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg))

    demo_lam_audio2exp(infer, cfg)

if __name__ == '__main__':
    launch_gradio_app()


# Check what getChatState and getExpressionData look like in the app
grep -n "getChatState\|getExpressionData\|js=" /mnt/DATA/WORK/LAM_Audio2Expression/app_lam_audio2exp.py | head -20

Find JS callbacks in the Gradio app


280:            js='''(output_component) => window.loadAudio(output_component)'''
284:            js='''(selected_render_file) => window.start(selected_render_file)'''


$HOME/miniconda3/bin/python -c "
data = open('/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js','rb').read().decode('utf-8','ignore')
for key in ['window.start', 'window.loadAudio', 'loadAudio', 'window.load']:
    idx = data.find(key)
    if idx >= 0:
        print(f'--- {key} at {idx} ---')
        print(repr(data[max(0,idx-100):idx+200]))
        print()
"

Find window.start and loadAudio API in renderer JS


--- window.start at 2263062 ---
'(e), ru(t), ru(n));\n    }\n  };\n}\nfunction tG(r) {\n  const e = Q$();\n  let t;\n  Z$(async () => {\n    window.start = n;\n  }), J$(() => {\n  });\n  async function n(i) {\n    t && (t.dispose(), console.log("dispose"), t = void 0);\n    const s = document.getElementById("render-root");\n    console.log(i), t'

--- window.loadAudio at 2268965 ---
'in_width: u = void 0 } = e, { loading_status: h } = e, { gradio: d } = e, f;\n  pG(async () => {\n    window.loadAudio = g;\n  });\n  const p = new iG();\n  async function g(_) {\n    _ && (p.loadAudio(_), console.log("audio_url", _));\n  }\n  const m = (_) => {\n    console.log("音频播放"), p.play(!0);\n  }, v ='

--- loadAudio at 2263896 ---
'nnect(this.audioContext.destination);\n  }\n  /**\n   * 加载音频文件\n   * @param url 音频文件的 URL\n   */\n  async loadAudio(e) {\n    const t = await fetch(e);\n    if (!t.ok)\n      throw new Error(`无法加载音频文件: ${t.statusText}`);\n    const n = await t.arrayBuffer();\n    this.audioBuffer = await this.audioContext.deco'

--- window.load at 2268965 ---
'in_width: u = void 0 } = e, { loading_status: h } = e, { gradio: d } = e, f;\n  pG(async () => {\n    window.loadAudio = g;\n  });\n  const p = new iG();\n  async function g(_) {\n    _ && (p.loadAudio(_), console.log("audio_url", _));\n  }\n  const m = (_) => {\n    console.log("音频播放"), p.play(!0);\n  }, v ='


$HOME/miniconda3/bin/python -c "
data = open('/home/wildlama/miniconda3/envs/lam_a2e/lib/python3.10/site-packages/gradio_gaussian_render/templates/component/index.js','rb').read().decode('utf-8','ignore')
# Find window.start function body - look for what 'i' (the path arg) does
idx = data.find('window.start = n')
print(repr(data[idx:idx+600]))
"

Read window.start function body


'window.start = n;\n  }), J$(() => {\n  });\n  async function n(i) {\n    t && (t.dispose(), console.log("dispose"), t = void 0);\n    const s = document.getElementById("render-root");\n    console.log(i), t = await as.getInstance(s, i), t.startRenderEvent.on("startRender", () => {\n      e("start_render"), console.log("start render");\n    });\n  }\n  return [];\n}\nclass nG extends q$ {\n  constructor(e) {\n    super(), j$(this, e, tG, eG, Y$, {});\n  }\n}\nclass iG {\n  constructor() {\n    Te(this, "audioContext");\n    Te(this, "audioBuffer");\n    Te(this, "sourceNode");\n    Te(this, "gainNode");\n    this.aud'


$HOME/miniconda3/bin/python -c "
import struct, json, gzip

with open('/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb', 'rb') as f:
    # GLB header
    magic, version, total_len = struct.unpack('<4sII', f.read(12))
    # First chunk (JSON)
    chunk_len, chunk_type = struct.unpack('<II', f.read(8))
    json_data = f.read(chunk_len)
    gltf = json.loads(json_data)

print('Meshes:', len(gltf.get('meshes', [])))
for i, mesh in enumerate(gltf.get('meshes', [])[:3]):
    print(f'  mesh[{i}] name={mesh.get(\"name\")}, primitives={len(mesh.get(\"primitives\",[]))}')
    for j, prim in enumerate(mesh.get('primitives', [])[:2]):
        targets = prim.get('targets', [])
        print(f'    prim[{j}]: {len(targets)} morph targets')
        if targets:
            print(f'    first target keys: {list(targets[0].keys())}')

print('Nodes:', len(gltf.get('nodes', [])))
extras = gltf.get('extras', {})
print('extras keys:', list(extras.keys()) if extras else 'none')
" 2>&1 | head -30

Parse skin.glb GLTF JSON to check for morph targets


Meshes: 1
  mesh[0] name=网格, primitives=1
    prim[0]: 51 morph targets
    first target keys: ['POSITION']
Nodes: 264
extras keys: none


51 morph targets — Three.js peut les animer directement. Parfait.


$HOME/miniconda3/bin/python -c "
import struct, json
with open('/mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb', 'rb') as f:
    struct.unpack('<4sII', f.read(12))
    chunk_len, chunk_type = struct.unpack('<II', f.read(8))
    gltf = json.loads(f.read(chunk_len))

mesh = gltf['meshes'][0]
prim = mesh['primitives'][0]
extras = prim.get('extras', {})
print('extras:', list(extras.keys()))
# Check mesh extras for target names
print('mesh extras:', mesh.get('extras', {}))
# Check if targetNames is in extras
if 'targetNames' in extras:
    print('targetNames:', extras['targetNames'])
# Check extensions
print('extensions:', list(prim.get('extensions', {}).keys()))
"

Check morph target names in GLB


extras: []
mesh extras: {'targetNames': ['mouthShrugLower', 'mouthShrugUpper', 'mouthSmileLeft', 'mouthSmileRight', 'mouthStretchLeft', 'mouthStretchRight', 'mouthUpperUpLeft', 'mouthUpperUpRight', 'noseSneerLeft', 'noseSneerRight', 'mouthFrownLeft', 'mouthFrownRight', 'mouthFunnel', 'mouthLeft', 'mouthLowerDownRight', 'mouthPressLeft', 'mouthPressRight', 'mouthPucker', 'mouthRight', 'mouthRollLower', 'mouthRollUpper', 'eyeSquintRight', 'eyeWideLeft', 'eyeWideRight', 'jawForward', 'jawLeft', 'jawOpen', 'jawRight', 'mouthClose', 'mouthDimpleLeft', 'mouthDimpleRight', 'eyeBlinkLeft', 'eyeBlinkRight', 'eyeLookDownLeft', 'eyeLookDownRight', 'eyeLookInLeft', 'eyeLookInRight', 'eyeLookOutLeft', 'eyeLookOutRight', 'eyeLookUpRight', 'eyeSquintLeft', 'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight', 'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight', 'eyeLookUpLeft', 'mouthLowerDownLeft']}
extensions: []


ls $HOME/miniconda3/bin/edge-tts 2>/dev/null && echo "CLI OK"
# Check what Python runs the agent-gui
ls /mnt/DATA/WORK/agent-gui/
find /mnt/DATA/WORK/agent-gui -name "*.cfg" -o -name "pyproject.toml" -o -name "setup.py" 2>/dev/null | head -5

Check edge-tts CLI and agent-gui Python setup


/home/wildlama/miniconda3/bin/edge-tts
CLI OK
PLAN.md
README.md
agent_gui
configs
data
static
tests


cat /mnt/DATA/WORK/LAM_Audio2Expression/engines/defaults.py | head -60

Read A2E defaults/config parser


"""
The code is base on https://github.com/Pointcept/Pointcept
"""

import os
import sys
import argparse
import multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel

import utils.comm as comm
from utils.env import get_random_seed, set_seed
from utils.config import Config, DictAction

def create_ddp_model(model, *, fp16_compression=False, **kwargs):
    """
    Create a DistributedDataParallel model if there are >1 processes.
    Args:
        model: a torch.nn.Module
        fp16_compression: add fp16 compression hooks to the ddp object.
            See more at https://pytorch.org/docs/stable/ddp_comm_hooks.html#torch.distributed.algorithms.ddp_comm_hooks.default_hooks.fp16_compress_hook
        kwargs: other arguments of :module:`torch.nn.parallel.DistributedDataParallel`.
    """
    if comm.get_world_size() == 1:
        return model
    # kwargs['find_unused_parameters'] = True
    if "device_ids" not in kwargs:
        kwargs["device_ids"] = [comm.get_local_rank()]
        if "output_device" not in kwargs:
            kwargs["output_device"] = [comm.get_local_rank()]
    ddp = DistributedDataParallel(model, **kwargs)
    if fp16_compression:
        from torch.distributed.algorithms.ddp_comm_hooks import default as comm_hooks

        ddp.register_comm_hook(state=None, hook=comm_hooks.fp16_compress_hook)
    return ddp

def worker_init_fn(worker_id, num_workers, rank, seed):
    """Worker init func for dataloader.

    The seed of each worker equals to num_worker * rank + worker_id + user_seed

    Args:
        worker_id (int): Worker id.
        num_workers (int): Number of workers.
        rank (int): The rank of current process.
        seed (int): The random seed to use.
    """

    worker_seed = num_workers * rank + worker_id + seed
    set_seed(worker_seed)

def default_argument_parser(epilog=None):
    parser = argparse.ArgumentParser(
        epilog=epilog
        or f"""


grep -n "audio_input\|save_json\|DictAction\|options" /mnt/DATA/WORK/LAM_Audio2Expression/engines/defaults.py | head -20

Check how options are parsed in the config


14:from utils.config import Config, DictAction
64:    Change some config options:
99:        "--options", nargs="+", action=DictAction, help="custom options"
104:def default_config_parser(file_path, options):
112:    if options is not None:
113:        cfg.merge_from_dict(options)


grep -n "merge_from_dict\|class Config" /mnt/DATA/WORK/LAM_Audio2Expression/utils/config.py | head -10

Check Config class in utils


35:class ConfigDict(Dict):
72:class Config:
553:    def merge_from_dict(self, options, allow_list_keys=True):
562:            >>> cfg.merge_from_dict(options)
571:            >>> cfg.merge_from_dict(options, allow_list_keys=True)


Parfait. J'ai tout ce qu'il faut. Je lance l'implémentation complète.


mkdir -p /mnt/DATA/WORK/agent-gui/static/avatar/barbara
mkdir -p /mnt/DATA/WORK/agent-gui/data/tts
cp /mnt/DATA/WORK/LAM_Audio2Expression/assets/sample_lam/barbara/arkitWithBSData/skin.glb \
   /mnt/DATA/WORK/agent-gui/static/avatar/barbara/skin.glb
echo "Copied."

Copy barbara skin.glb to agent-gui static


Copied.


Write

"""CLI: audio.wav → bsData.json  (runs inside lam_a2e conda env)"""
import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--audio', required=True)
    parser.add_argument('--output', required=True)
    args = parser.parse_args()

    import librosa
    import numpy as np
    from engines.defaults import default_argument_parser, default_config_parser, default_setup
    from engines.infer import INFER
    from models.utils import export_blendshape_animation, ARKitBlendShape

    cfg_args = default_argument_parser().parse_args([])
    cfg_args.config_file = 'configs/lam_audio2exp_config_streaming.py'
    cfg = default_config_parser(cfg_args.config_file, {
        'audio_input': args.audio,
        'save_json_path': args.output,
    })
    cfg = default_setup(cfg)

    infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg))
    infer.model.eval()

    audio, sr = librosa.load(args.audio, sr=16000)
    context = None
    all_exp = []
    for i in range(audio.shape[0] // 16000 + 1):
        out, context = infer.infer_streaming_audio(audio[i * 16000:(i + 1) * 16000], sr, context)
        all_exp.append(out['expression'])

    all_exp = np.concatenate(all_exp, axis=0)
    export_blendshape_animation(all_exp, args.output, ARKitBlendShape, fps=30.0)
    print(f'OK {len(all_exp)} frames', flush=True)

if __name__ == '__main__':
    main()


File created successfully at: /mnt/DATA/WORK/LAM_Audio2Expression/run_inference.py (file state is current in your context — no need to Read it back)

"""CLI: audio.wav → bsData.json  (runs inside lam_a2e conda env)"""
import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--audio', required=True)
    parser.add_argument('--output', required=True)
    args = parser.parse_args()

    import librosa
    import numpy as np
    from engines.defaults import default_argument_parser, default_config_parser, default_setup
    from engines.infer import INFER
    from models.utils import export_blendshape_animation, ARKitBlendShape

    cfg_args = default_argument_parser().parse_args([])
    cfg_args.config_file = 'configs/lam_audio2exp_config_streaming.py'
    cfg = default_config_parser(cfg_args.config_file, {
        'audio_input': args.audio,
        'save_json_path': args.output,
    })
    cfg = default_setup(cfg)

    infer = INFER.build(dict(type=cfg.infer.type, cfg=cfg))
    infer.model.eval()

    audio, sr = librosa.load(args.audio, sr=16000)
    context = None
    all_exp = []
    for i in range(audio.shape[0] // 16000 + 1):
        out, context = infer.infer_streaming_audio(audio[i * 16000:(i + 1) * 16000], sr, context)
        all_exp.append(out['expression'])

    all_exp = np.concatenate(all_exp, axis=0)
    export_blendshape_animation(all_exp, args.output, ARKitBlendShape, fps=30.0)
    print(f'OK {len(all_exp)} frames', flush=True)

if __name__ == '__main__':
    main()


from __future__ import annotations

import subprocess
from pathlib import Path

_EDGE_TTS = Path.home() / "miniconda3/bin/edge-tts"

def synthesize(text: str, wav_path: Path, voice: str = "en-US-AriaNeural") -> None:
    mp3_path = wav_path.with_suffix(".mp3")
    subprocess.run(
        [str(_EDGE_TTS), "--text", text, "--voice", voice, "--write-media", str(mp3_path)],
        check=True,
        timeout=60,
        capture_output=True,
    )
    subprocess.run(
        ["ffmpeg", "-y", "-i", str(mp3_path), "-ar", "16000", "-ac", "1", str(wav_path)],
        check=True,
        capture_output=True,
    )
    mp3_path.unlink(missing_ok=True)


File created successfully at: /mnt/DATA/WORK/agent-gui/agent_gui/tts.py (file state is current in your context — no need to Read it back)

from __future__ import annotations

import subprocess
from pathlib import Path

_EDGE_TTS = Path.home() / "miniconda3/bin/edge-tts"

def synthesize(text: str, wav_path: Path, voice: str = "en-US-AriaNeural") -> None:
    mp3_path = wav_path.with_suffix(".mp3")
    subprocess.run(
        [str(_EDGE_TTS), "--text", text, "--voice", voice, "--write-media", str(mp3_path)],
        check=True,
        timeout=60,
        capture_output=True,
    )
    subprocess.run(
        ["ffmpeg", "-y", "-i", str(mp3_path), "-ar", "16000", "-ac", "1", str(wav_path)],
        check=True,
        capture_output=True,
    )
    mp3_path.unlink(missing_ok=True)


from __future__ import annotations

import json
import subprocess
import tempfile
from pathlib import Path

_LAM_DIR = Path("/mnt/DATA/WORK/LAM_Audio2Expression")
_CONDA = Path.home() / "miniconda3/bin/conda"
_SCRIPT = _LAM_DIR / "run_inference.py"

def audio_to_blendshapes(audio_path: Path) -> dict:
    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as fh:
        out = Path(fh.name)
    try:
        subprocess.run(
            [
                str(_CONDA), "run", "--no-capture-output", "-n", "lam_a2e",
                "python", str(_SCRIPT),
                "--audio", str(audio_path),
                "--output", str(out),
            ],
            check=True,
            cwd=str(_LAM_DIR),
            timeout=300,
        )
        return json.loads(out.read_text())
    finally:
        out.unlink(missing_ok=True)


File created successfully at: /mnt/DATA/WORK/agent-gui/agent_gui/avatar_lam.py (file state is current in your context — no need to Read it back)

from __future__ import annotations

import json
import subprocess
import tempfile
from pathlib import Path

_LAM_DIR = Path("/mnt/DATA/WORK/LAM_Audio2Expression")
_CONDA = Path.home() / "miniconda3/bin/conda"
_SCRIPT = _LAM_DIR / "run_inference.py"

def audio_to_blendshapes(audio_path: Path) -> dict:
    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as fh:
        out = Path(fh.name)
    try:
        subprocess.run(
            [
                str(_CONDA), "run", "--no-capture-output", "-n", "lam_a2e",
                "python", str(_SCRIPT),
                "--audio", str(audio_path),
                "--output", str(out),
            ],
            check=True,
            cwd=str(_LAM_DIR),
            timeout=300,
        )
        return json.loads(out.read_text())
    finally:
        out.unlink(missing_ok=True)


Maintenant je modifie `app.py` pour ajouter les endpoints avatar et le thread background.


Edit

from __future__ import annotations

import argparse
import json
import mimetypes
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"

from __future__ import annotations

import argparse
import json
import mimetypes
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
TTS_DIR = PROJECT_ROOT / "data" / "tts"
TTS_DIR.mkdir(parents=True, exist_ok=True)

# keyed by session_id: None=pending, dict=ready, str=error message
_anim_store: dict[str, None | dict | str] = {}
_anim_lock = threading.Lock()


The file /mnt/DATA/WORK/agent-gui/agent_gui/app.py has been updated successfully. (file state is current in your context — no need to Read it back)

from __future__ import annotations

import argparse
import json
import mimetypes
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
TTS_DIR = PROJECT_ROOT / "data" / "tts"
TTS_DIR.mkdir(parents=True, exist_ok=True)

# keyed by session_id: None=pending, dict=ready, str=error message
_anim_store: dict[str, None | dict | str] = {}
_anim_lock = threading.Lock()

from __future__ import annotations

import argparse
import json
import mimetypes
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"

class AgentGuiServer(ThreadingHTTPServer):
    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
        super().__init__(server_address, RequestHandlerClass)
        self.config_path = config_path
        self.sessions = SessionStore(sessions_root)

    @property
    def agents(self):
        return load_agents(self.config_path)

class Handler(BaseHTTPRequestHandler):
    server: AgentGuiServer

    def log_message(self, format: str, *args):
        print("[agent-gui] " + format % args)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        return self.serve_static(parsed.path)

    def do_POST(self):
        if self.path == "/api/session":
            return self.json_response({"session_id": new_session_id()})
        if self.path == "/api/chat":
            return self.handle_chat()
        return self.json_response({"error": "Endpoint inconnu"}, status=404)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def serve_static(self, request_path: str):
        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
        path = (STATIC_ROOT / relative).resolve()
        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
            return self.json_response({"error": "Chemin interdit"}, status=403)
        if not path.exists() or not path.is_file():
            return self.json_response({"error": "Fichier introuvable"}, status=404)
        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def read_json(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8") if length else "{}"
        return json.loads(body)

    def json_response(self, payload, status: int = 200):
        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main(argv: list[str] | None = None):
    parser = argparse.ArgumentParser(description="Agent GUI local")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
    args = parser.parse_args(argv)

    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
    print(f"Config agents: {args.config}")
    print("Ctrl+C pour arrêter.")
    server.serve_forever()

if __name__ == "__main__":
    main()

+TTS_DIR = PROJECT_ROOT / "data" / "tts"

+# keyed by session_id: None=pending, dict=ready, str=error message

+_anim_store: dict[str, None | dict | str] = {}


    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        return self.serve_static(parsed.path)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        if parsed.path == "/api/avatar/anim":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            with _anim_lock:
                val = _anim_store.get(session_id)
            if val is None:
                return self.json_response({"status": "pending"})
            if isinstance(val, str):
                return self.json_response({"status": "error", "message": val})
            return self.json_response({"status": "ready", "bsData": val})
        if parsed.path == "/api/avatar/audio":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            wav = TTS_DIR / f"{session_id}.wav"
            if not wav.exists():
                return self.json_response({"error": "audio not found"}, status=404)
            body = wav.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "audio/wav")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        return self.serve_static(parsed.path)


    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        if parsed.path == "/api/avatar/anim":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            with _anim_lock:
                val = _anim_store.get(session_id)
            if val is None:
                return self.json_response({"status": "pending"})
            if isinstance(val, str):
                return self.json_response({"status": "error", "message": val})
            return self.json_response({"status": "ready", "bsData": val})
        if parsed.path == "/api/avatar/audio":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            wav = TTS_DIR / f"{session_id}.wav"
            if not wav.exists():
                return self.json_response({"error": "audio not found"}, status=404)
            body = wav.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "audio/wav")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        return self.serve_static(parsed.path)

from __future__ import annotations

import argparse
import json
import mimetypes
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
TTS_DIR = PROJECT_ROOT / "data" / "tts"
TTS_DIR.mkdir(parents=True, exist_ok=True)

# keyed by session_id: None=pending, dict=ready, str=error message
_anim_store: dict[str, None | dict | str] = {}
_anim_lock = threading.Lock()

class AgentGuiServer(ThreadingHTTPServer):
    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
        super().__init__(server_address, RequestHandlerClass)
        self.config_path = config_path
        self.sessions = SessionStore(sessions_root)

    @property
    def agents(self):
        return load_agents(self.config_path)

class Handler(BaseHTTPRequestHandler):
    server: AgentGuiServer

    def log_message(self, format: str, *args):
        print("[agent-gui] " + format % args)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        return self.serve_static(parsed.path)

    def do_POST(self):
        if self.path == "/api/session":
            return self.json_response({"session_id": new_session_id()})
        if self.path == "/api/chat":
            return self.handle_chat()
        return self.json_response({"error": "Endpoint inconnu"}, status=404)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def serve_static(self, request_path: str):
        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
        path = (STATIC_ROOT / relative).resolve()
        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
            return self.json_response({"error": "Chemin interdit"}, status=403)
        if not path.exists() or not path.is_file():
            return self.json_response({"error": "Fichier introuvable"}, status=404)
        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def read_json(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8") if length else "{}"
        return json.loads(body)

    def json_response(self, payload, status: int = 200):
        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main(argv: list[str] | None = None):
    parser = argparse.ArgumentParser(description="Agent GUI local")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
    args = parser.parse_args(argv)

    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
    print(f"Config agents: {args.config}")
    print("Ctrl+C pour arrêter.")
    server.serve_forever()

if __name__ == "__main__":
    main()

+        if parsed.path == "/api/avatar/anim":

+            session_id = parse_qs(parsed.query).get("session_id", [""])[0]

+                val = _anim_store.get(session_id)

+                return self.json_response({"status": "pending"})

+                return self.json_response({"status": "error", "message": val})

+            return self.json_response({"status": "ready", "bsData": val})

+        if parsed.path == "/api/avatar/audio":

+            wav = TTS_DIR / f"{session_id}.wav"

+                return self.json_response({"error": "audio not found"}, status=404)

+            self.send_header("Content-Type", "audio/wav")

+            self.send_header("Content-Length", str(len(body)))


    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]

            assistant_text = next(
                (e["content"] for e in stored_events if e.get("type") == "assistant_message"),
                None,
            )
            if assistant_text:
                _start_avatar_pipeline(session_id, assistant_text)

            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                    "avatar_anim": "pending" if assistant_text else None,
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)


    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]

            assistant_text = next(
                (e["content"] for e in stored_events if e.get("type") == "assistant_message"),
                None,
            )
            if assistant_text:
                _start_avatar_pipeline(session_id, assistant_text)

            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                    "avatar_anim": "pending" if assistant_text else None,
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

from __future__ import annotations

import argparse
import json
import mimetypes
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
TTS_DIR = PROJECT_ROOT / "data" / "tts"
TTS_DIR.mkdir(parents=True, exist_ok=True)

# keyed by session_id: None=pending, dict=ready, str=error message
_anim_store: dict[str, None | dict | str] = {}
_anim_lock = threading.Lock()

class AgentGuiServer(ThreadingHTTPServer):
    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
        super().__init__(server_address, RequestHandlerClass)
        self.config_path = config_path
        self.sessions = SessionStore(sessions_root)

    @property
    def agents(self):
        return load_agents(self.config_path)

class Handler(BaseHTTPRequestHandler):
    server: AgentGuiServer

    def log_message(self, format: str, *args):
        print("[agent-gui] " + format % args)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        if parsed.path == "/api/avatar/anim":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            with _anim_lock:
                val = _anim_store.get(session_id)
            if val is None:
                return self.json_response({"status": "pending"})
            if isinstance(val, str):
                return self.json_response({"status": "error", "message": val})
            return self.json_response({"status": "ready", "bsData": val})
        if parsed.path == "/api/avatar/audio":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            wav = TTS_DIR / f"{session_id}.wav"
            if not wav.exists():
                return self.json_response({"error": "audio not found"}, status=404)
            body = wav.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "audio/wav")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        return self.serve_static(parsed.path)

    def do_POST(self):
        if self.path == "/api/session":
            return self.json_response({"session_id": new_session_id()})
        if self.path == "/api/chat":
            return self.handle_chat()
        return self.json_response({"error": "Endpoint inconnu"}, status=404)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]
            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def serve_static(self, request_path: str):
        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
        path = (STATIC_ROOT / relative).resolve()
        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
            return self.json_response({"error": "Chemin interdit"}, status=403)
        if not path.exists() or not path.is_file():
            return self.json_response({"error": "Fichier introuvable"}, status=404)
        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def read_json(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8") if length else "{}"
        return json.loads(body)

    def json_response(self, payload, status: int = 200):
        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main(argv: list[str] | None = None):
    parser = argparse.ArgumentParser(description="Agent GUI local")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
    args = parser.parse_args(argv)

    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
    print(f"Config agents: {args.config}")
    print("Ctrl+C pour arrêter.")
    server.serve_forever()

if __name__ == "__main__":
    main()

+                (e["content"] for e in stored_events if e.get("type") == "assistant_message"),

+                _start_avatar_pipeline(session_id, assistant_text)

+                    "avatar_anim": "pending" if assistant_text else None,


Maintenant j'ajoute la fonction `_start_avatar_pipeline` avant la classe `Handler`.


def _start_avatar_pipeline(session_id: str, text: str) -> None:
    with _anim_lock:
        _anim_store[session_id] = None  # pending

    def _run() -> None:
        try:
            from .tts import synthesize
            from .avatar_lam import audio_to_blendshapes

            wav_path = TTS_DIR / f"{session_id}.wav"
            synthesize(text, wav_path)
            bs_data = audio_to_blendshapes(wav_path)
            with _anim_lock:
                _anim_store[session_id] = bs_data
        except Exception as exc:
            with _anim_lock:
                _anim_store[session_id] = str(exc)

    threading.Thread(target=_run, daemon=True).start()

class AgentGuiServer(ThreadingHTTPServer):


def _start_avatar_pipeline(session_id: str, text: str) -> None:
    with _anim_lock:
        _anim_store[session_id] = None  # pending

    def _run() -> None:
        try:
            from .tts import synthesize
            from .avatar_lam import audio_to_blendshapes

            wav_path = TTS_DIR / f"{session_id}.wav"
            synthesize(text, wav_path)
            bs_data = audio_to_blendshapes(wav_path)
            with _anim_lock:
                _anim_store[session_id] = bs_data
        except Exception as exc:
            with _anim_lock:
                _anim_store[session_id] = str(exc)

    threading.Thread(target=_run, daemon=True).start()

class AgentGuiServer(ThreadingHTTPServer):

from __future__ import annotations

import argparse
import json
import mimetypes
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

from .adapters import adapter_for
from .avatar import available_states, get_avatar
from .config import find_agent, load_agents
from .sessions import SessionStore, new_session_id, utc_now

PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_ROOT = PROJECT_ROOT / "static"
DEFAULT_CONFIG = PROJECT_ROOT / "configs" / "agents.json"
DEFAULT_SESSIONS = PROJECT_ROOT / "data" / "sessions"
TTS_DIR = PROJECT_ROOT / "data" / "tts"
TTS_DIR.mkdir(parents=True, exist_ok=True)

# keyed by session_id: None=pending, dict=ready, str=error message
_anim_store: dict[str, None | dict | str] = {}
_anim_lock = threading.Lock()

class AgentGuiServer(ThreadingHTTPServer):
    def __init__(self, server_address, RequestHandlerClass, config_path: Path, sessions_root: Path):
        super().__init__(server_address, RequestHandlerClass)
        self.config_path = config_path
        self.sessions = SessionStore(sessions_root)

    @property
    def agents(self):
        return load_agents(self.config_path)

class Handler(BaseHTTPRequestHandler):
    server: AgentGuiServer

    def log_message(self, format: str, *args):
        print("[agent-gui] " + format % args)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            return self.json_response({"ok": True, "service": "agent-gui", "time": utc_now()})
        if parsed.path == "/api/agents":
            return self.json_response({"agents": self.server.agents})
        if parsed.path == "/api/avatar":
            state = parse_qs(parsed.query).get("state", ["idle"])[0]
            return self.json_response({"avatar": get_avatar(state), "states": available_states()})
        if parsed.path == "/api/sessions":
            return self.json_response({"sessions": self.server.sessions.list_sessions()})
        if parsed.path == "/api/history":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            return self.json_response({"events": self.server.sessions.read(session_id)})
        if parsed.path == "/api/avatar/anim":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            with _anim_lock:
                val = _anim_store.get(session_id)
            if val is None:
                return self.json_response({"status": "pending"})
            if isinstance(val, str):
                return self.json_response({"status": "error", "message": val})
            return self.json_response({"status": "ready", "bsData": val})
        if parsed.path == "/api/avatar/audio":
            session_id = parse_qs(parsed.query).get("session_id", [""])[0]
            wav = TTS_DIR / f"{session_id}.wav"
            if not wav.exists():
                return self.json_response({"error": "audio not found"}, status=404)
            body = wav.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "audio/wav")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        return self.serve_static(parsed.path)

    def do_POST(self):
        if self.path == "/api/session":
            return self.json_response({"session_id": new_session_id()})
        if self.path == "/api/chat":
            return self.handle_chat()
        return self.json_response({"error": "Endpoint inconnu"}, status=404)

    def handle_chat(self):
        try:
            payload = self.read_json()
            agent_id = str(payload.get("agent", "echo"))
            message = str(payload.get("message", "")).strip()
            session_id = str(payload.get("session_id") or new_session_id())
            if not message:
                return self.json_response({"error": "Message vide"}, status=400)

            agent = find_agent(self.server.agents, agent_id)
            self.server.sessions.append(session_id, {"type": "user_message", "agent": agent_id, "content": message})
            self.server.sessions.append(session_id, {"type": "status", "agent": agent_id, "content": "Agent en réflexion..."})

            result = adapter_for(agent).run(message, agent, session_id)
            stored_events = [self.server.sessions.append(session_id, event) for event in result.events]

            assistant_text = next(
                (e["content"] for e in stored_events if e.get("type") == "assistant_message"),
                None,
            )
            if assistant_text:
                _start_avatar_pipeline(session_id, assistant_text)

            return self.json_response(
                {
                    "session_id": session_id,
                    "agent": agent_id,
                    "events": stored_events,
                    "avatar": get_avatar(result.avatar_state),
                    "avatar_anim": "pending" if assistant_text else None,
                }
            )
        except KeyError as exc:
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=404)
        except Exception as exc:  # defensive boundary for local UI
            return self.json_response({"error": str(exc), "avatar": get_avatar("error")}, status=500)

    def serve_static(self, request_path: str):
        relative = "index.html" if request_path in ("/", "") else request_path.lstrip("/")
        path = (STATIC_ROOT / relative).resolve()
        if STATIC_ROOT.resolve() not in path.parents and path != STATIC_ROOT.resolve():
            return self.json_response({"error": "Chemin interdit"}, status=403)
        if not path.exists() or not path.is_file():
            return self.json_response({"error": "Fichier introuvable"}, status=404)
        content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        body = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def read_json(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8") if length else "{}"
        return json.loads(body)

    def json_response(self, payload, status: int = 200):
        body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main(argv: list[str] | None = None):
    parser = argparse.ArgumentParser(description="Agent GUI local")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--sessions", type=Path, default=DEFAULT_SESSIONS)
    args = parser.parse_args(argv)

    server = AgentGuiServer((args.host, args.port), Handler, args.config, args.sessions)
    print(f"Agent GUI lancé: http://{args.host}:{args.port}")
    print(f"Config agents: {args.config}")
    print("Ctrl+C pour arrêter.")
    server.serve_forever()

if __name__ == "__main__":
    main()

+def _start_avatar_pipeline(session_id: str, text: str) -> None:

+        _anim_store[session_id] = None  # pending

+            from .tts import synthesize

+            from .avatar_lam import audio_to_blendshapes

+            wav_path = TTS_DIR / f"{session_id}.wav"

+            bs_data = audio_to_blendshapes(wav_path)

+                _anim_store[session_id] = bs_data

+                _anim_store[session_id] = str(exc)

+    threading.Thread(target=_run, daemon=True).start()


Maintenant le frontend. D'abord `avatar.js` avec Three.js.


Write

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const AVATAR_GLB = '/avatar/barbara/skin.glb';
const FPS = 30;

let renderer, scene, camera, avatarMesh, morphDict, morphNames;
let frames = [], frameIndex = 0, animTimer = null;
let audioCtx = null, audioBuffer = null, audioSource = null;

function init() {
  const canvas = document.getElementById('avatarCanvas');
  if (!canvas) return;

  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
  renderer.outputColorSpace = THREE.SRGBColorSpace;

  scene = new THREE.Scene();

  camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.01, 100);
  camera.position.set(0, 0, 3);

  const ambient = new THREE.AmbientLight(0xffffff, 1.2);
  scene.add(ambient);
  const dir = new THREE.DirectionalLight(0xffffff, 1.5);
  dir.position.set(1, 2, 3);
  scene.add(dir);

  loadAvatar();
  renderLoop();
}

function loadAvatar() {
  const loader = new GLTFLoader();
  loader.load(AVATAR_GLB, (gltf) => {
    const root = gltf.scene;
    scene.add(root);

    root.traverse((node) => {
      if (node.isMesh && node.morphTargetDictionary && Object.keys(node.morphTargetDictionary).length > 0) {
        avatarMesh = node;
        morphDict = node.morphTargetDictionary;
      }
    });

    fitCamera(root);
    setStatus('Prêt');
  }, undefined, (err) => {
    setStatus('Erreur chargement avatar');
    console.error(err);
  });
}

function fitCamera(object) {
  const box = new THREE.Box3().setFromObject(object);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y);
  const fovRad = camera.fov * Math.PI / 180;
  const dist = (maxDim / 2) / Math.tan(fovRad / 2);

  // Focus on upper half (face/head)
  camera.position.set(center.x, center.y + size.y * 0.15, center.z + dist * 1.1);
  camera.lookAt(center.x, center.y + size.y * 0.15, center.z);
  camera.near = dist / 100;
  camera.far = dist * 100;
  camera.updateProjectionMatrix();
}

function applyFrame(frame) {
  if (!avatarMesh || !morphDict || !frame) return;
  const weights = frame.weights;
  for (let i = 0; i < morphNames.length; i++) {
    const name = morphNames[i];
    if (name in morphDict) {
      avatarMesh.morphTargetInfluences[morphDict[name]] = weights[i] ?? 0;
    }
  }
}

function resetMorphs() {
  if (!avatarMesh) return;
  avatarMesh.morphTargetInfluences.fill(0);
}

function renderLoop() {
  requestAnimationFrame(renderLoop);
  renderer.render(scene, camera);
}

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

function startAnimation(bsData) {
  if (animTimer) clearInterval(animTimer);
  morphNames = bsData.metadata.blendshape_names;
  frames = bsData.frames;
  frameIndex = 0;
  setStatus('Animation...');

  animTimer = setInterval(() => {
    if (frameIndex >= frames.length) {
      clearInterval(animTimer);
      animTimer = null;
      resetMorphs();
      setStatus('Prêt');
      return;
    }
    applyFrame(frames[frameIndex++]);
  }, 1000 / FPS);
}

async function speakWithAnimation(sessionId) {
  setStatus('Génération audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

// Exported controller
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };


File created successfully at: /mnt/DATA/WORK/agent-gui/static/avatar.js (file state is current in your context — no need to Read it back)

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const AVATAR_GLB = '/avatar/barbara/skin.glb';
const FPS = 30;

let renderer, scene, camera, avatarMesh, morphDict, morphNames;
let frames = [], frameIndex = 0, animTimer = null;
let audioCtx = null, audioBuffer = null, audioSource = null;

function init() {
  const canvas = document.getElementById('avatarCanvas');
  if (!canvas) return;

  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
  renderer.outputColorSpace = THREE.SRGBColorSpace;

  scene = new THREE.Scene();

  camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.01, 100);
  camera.position.set(0, 0, 3);

  const ambient = new THREE.AmbientLight(0xffffff, 1.2);
  scene.add(ambient);
  const dir = new THREE.DirectionalLight(0xffffff, 1.5);
  dir.position.set(1, 2, 3);
  scene.add(dir);

  loadAvatar();
  renderLoop();
}

function loadAvatar() {
  const loader = new GLTFLoader();
  loader.load(AVATAR_GLB, (gltf) => {
    const root = gltf.scene;
    scene.add(root);

    root.traverse((node) => {
      if (node.isMesh && node.morphTargetDictionary && Object.keys(node.morphTargetDictionary).length > 0) {
        avatarMesh = node;
        morphDict = node.morphTargetDictionary;
      }
    });

    fitCamera(root);
    setStatus('Prêt');
  }, undefined, (err) => {
    setStatus('Erreur chargement avatar');
    console.error(err);
  });
}

function fitCamera(object) {
  const box = new THREE.Box3().setFromObject(object);
  const center = box.getCenter(new THREE.Vector3());
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y);
  const fovRad = camera.fov * Math.PI / 180;
  const dist = (maxDim / 2) / Math.tan(fovRad / 2);

  // Focus on upper half (face/head)
  camera.position.set(center.x, center.y + size.y * 0.15, center.z + dist * 1.1);
  camera.lookAt(center.x, center.y + size.y * 0.15, center.z);
  camera.near = dist / 100;
  camera.far = dist * 100;
  camera.updateProjectionMatrix();
}

function applyFrame(frame) {
  if (!avatarMesh || !morphDict || !frame) return;
  const weights = frame.weights;
  for (let i = 0; i < morphNames.length; i++) {
    const name = morphNames[i];
    if (name in morphDict) {
      avatarMesh.morphTargetInfluences[morphDict[name]] = weights[i] ?? 0;
    }
  }
}

function resetMorphs() {
  if (!avatarMesh) return;
  avatarMesh.morphTargetInfluences.fill(0);
}

function renderLoop() {
  requestAnimationFrame(renderLoop);
  renderer.render(scene, camera);
}

function setStatus(text) {
  const el = document.getElementById('avatarStatus');
  if (el) el.textContent = text;
}

async function loadAudio(url) {
  if (!audioCtx) audioCtx = new AudioContext();
  const res = await fetch(url);
  const buf = await res.arrayBuffer();
  audioBuffer = await audioCtx.decodeAudioData(buf);
}

function playAudio() {
  if (!audioCtx || !audioBuffer) return;
  if (audioSource) { try { audioSource.stop(); } catch (_) {} }
  audioSource = audioCtx.createBufferSource();
  audioSource.buffer = audioBuffer;
  audioSource.connect(audioCtx.destination);
  audioSource.start(0);
}

function startAnimation(bsData) {
  if (animTimer) clearInterval(animTimer);
  morphNames = bsData.metadata.blendshape_names;
  frames = bsData.frames;
  frameIndex = 0;
  setStatus('Animation...');

  animTimer = setInterval(() => {
    if (frameIndex >= frames.length) {
      clearInterval(animTimer);
      animTimer = null;
      resetMorphs();
      setStatus('Prêt');
      return;
    }
    applyFrame(frames[frameIndex++]);
  }, 1000 / FPS);
}

async function speakWithAnimation(sessionId) {
  setStatus('Génération audio...');
  try {
    await loadAudio(`/api/avatar/audio?session_id=${encodeURIComponent(sessionId)}`);
    playAudio();
  } catch (e) {
    console.warn('Audio load failed:', e);
  }
}

// Exported controller
window.avatarController = { init, startAnimation, speakWithAnimation, setStatus };


Maintenant je modifie `index.html` pour ajouter l'importmap Three.js et la section avatar.


Edit

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />
</head>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>


The file /mnt/DATA/WORK/agent-gui/static/index.html has been updated successfully. (file state is current in your context — no need to Read it back)

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />
</head>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>

<!doctype html>

  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />

AG

Pixel command center

Agent GUI

Surcouche locale universelle

      Agent actif
      <select id="agentSelect"></select>

        <button id="newSessionBtn">Nouvelle session</button>
        <button id="reloadBtn" class="secondary">Reload crew</button>

Sessions

Virtual office

Bureau animé

            idle
            Session

        <canvas id="officeCanvas" width="960" height="540"></canvas>

Les agents arrivent au bureau.

Conversation

Chat

Ctrl+Enter pour envoyer

        <form id="chatForm" class="composer">
          <textarea id="messageInput" rows="3" placeholder="Écris à l'agent..."></textarea>
          <button type="submit">Envoyer</button>
        </form>

Crew

Agents présents

Selected

Agent

idle

Timeline

Activity

+      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",

+      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"


Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...

Crew

Agents présents


Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...

Crew

Agents présents

+      <section class="panel avatar-panel">

+        <canvas id="avatarCanvas" width="280" height="300"></canvas>


  <script src="/app.js"></script>
</body>

  <script src="/app.js"></script>
  <script type="module" src="/avatar.js"></script>
</body>


  <script src="/app.js"></script>
</body>

  <script src="/app.js"></script>
  <script type="module" src="/avatar.js"></script>
</body>

<!doctype html>

  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Agent GUI Local</title>
  <link rel="stylesheet" href="/styles.css" />

AG

Pixel command center

Agent GUI

Surcouche locale universelle

      Agent actif
      <select id="agentSelect"></select>

        <button id="newSessionBtn">Nouvelle session</button>
        <button id="reloadBtn" class="secondary">Reload crew</button>

Sessions

Virtual office

Bureau animé

            idle
            Session

        <canvas id="officeCanvas" width="960" height="540"></canvas>

Les agents arrivent au bureau.

Conversation

Chat

Ctrl+Enter pour envoyer

        <form id="chatForm" class="composer">
          <textarea id="messageInput" rows="3" placeholder="Écris à l'agent..."></textarea>
          <button type="submit">Envoyer</button>
        </form>

Avatar LAM

Barbara

        <canvas id="avatarCanvas" width="280" height="300"></canvas>

Chargement...

Crew

Agents présents

Selected

Agent

idle

Timeline

Activity

+  <script type="module" src="/avatar.js"></script>


Maintenant j'ajoute le CSS pour le panel avatar et le polling dans `app.js`.


1	:root {
2	  --bg: #070b12;
3	  --bg-2: #0d1422;
4	  --panel: #111a2b;
5	  --panel-2: #162338;
6	  --line: rgba(191, 219, 254, 0.10);
7	  --text: #e8f1ff;
8	  --muted: #8aa1c0;
9	  --cyan: #6ee7ff;
10	  --violet: #b38cff;
11	  --green: #5af0a6;
12	  --yellow: #ffd166;
13	  --red: #ff6b88;
14	  --shadow: rgba(0, 0, 0, 0.35);
15	}
16
17	* { box-sizing: border-box; }
18	html, body { height: 100%; }
19	body {
20	  margin: 0;
21	  color: var(--text);
22	  background:
23	    radial-gradient(circle at 20% 0%, rgba(110,231,255,.20), transparent 25%),
24	    radial-gradient(circle at 80% 10%, rgba(179,140,255,.16), transparent 22%),
25	    linear-gradient(180deg, #05070d 0%, var(--bg) 100%);
26	  font-family: "Courier New", Courier, ui-monospace, SFMono-Regular, monospace;
27	  overflow: hidden;
28	}
29
30	.scanlines {
31	  position: fixed;
32	  inset: 0;
33	  pointer-events: none;
34	  background: linear-gradient(rgba(255,255,255,0.025) 50%, rgba(0,0,0,0.08) 50%);
35	  background-size: 100% 4px;
36	  mix-blend-mode: soft-light;
37	  opacity: .25;
38	}
39
40	.app-shell {
41	  position: relative;
42	  display: grid;
43	  grid-template-columns: 280px minmax(0, 1fr) 330px;
44	  gap: 14px;
45	  padding: 14px;
46	  height: 100vh;
47	}
48
49	.sidebar, .center-column, .details-column {
50	  min-height: 0;

:root {
  --bg: #070b12;
  --bg-2: #0d1422;
  --panel: #111a2b;
  --panel-2: #162338;
  --line: rgba(191, 219, 254, 0.10);
  --text: #e8f1ff;
  --muted: #8aa1c0;
  --cyan: #6ee7ff;
  --violet: #b38cff;
  --green: #5af0a6;
  --yellow: #ffd166;
  --red: #ff6b88;
  --shadow: rgba(0, 0, 0, 0.35);
}

* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 20% 0%, rgba(110,231,255,.20), transparent 25%),
    radial-gradient(circle at 80% 10%, rgba(179,140,255,.16), transparent 22%),
    linear-gradient(180deg, #05070d 0%, var(--bg) 100%);
  font-family: "Courier New", Courier, ui-monospace, SFMono-Regular, monospace;
  overflow: hidden;
}

.scanlines {
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(rgba(255,255,255,0.025) 50%, rgba(0,0,0,0.08) 50%);
  background-size: 100% 4px;
  mix-blend-mode: soft-light;
  opacity: .25;
}

.app-shell {
  position: relative;
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr) 330px;
  gap: 14px;
  padding: 14px;
  height: 100vh;
}

.sidebar, .center-column, .details-column {
  min-height: 0;


50	  min-height: 0;
51	}
52
53	.sidebar, .panel, .office-card, .chat-card {
54	  background: linear-gradient(180deg, rgba(17,26,43,.94), rgba(9,14,25,.96));
55	  border: 1px solid var(--line);
56	  border-radius: 22px;
57	  box-shadow: 0 14px 40px var(--shadow), inset 0 1px 0 rgba(255,255,255,.04);
58	}
59
60	.sidebar, .details-column {
61	  overflow: auto;
62	  padding: 16px;
63	}
64
65	.center-column {
66	  display: grid;
67	  grid-template-rows: minmax(0, 0.9fr) minmax(0, 1.1fr);
68	  gap: 14px;
69	  min-width: 0;
70	}
71
72	.office-card, .chat-card, .panel {
73	  overflow: hidden;
74	}
75
76	.office-card {
77	  display: grid;
78	  grid-template-rows: auto minmax(0, 1fr) auto;
79	}

  min-height: 0;
}

.sidebar, .panel, .office-card, .chat-card {
  background: linear-gradient(180deg, rgba(17,26,43,.94), rgba(9,14,25,.96));
  border: 1px solid var(--line);
  border-radius: 22px;
  box-shadow: 0 14px 40px var(--shadow), inset 0 1px 0 rgba(255,255,255,.04);
}

.sidebar, .details-column {
  overflow: auto;
  padding: 16px;
}

.center-column {
  display: grid;
  grid-template-rows: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: 14px;
  min-width: 0;
}

.office-card, .chat-card, .panel {
  overflow: hidden;
}

.office-card {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
}


.office-card, .chat-card, .panel {
  overflow: hidden;
}

.office-card, .chat-card, .panel {
  overflow: hidden;
}

.avatar-panel {
  padding: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}

.avatar-panel h2 { margin: 0; font-size: 0.9rem; }

#avatarCanvas {
  width: 100%;
  max-width: 280px;
  height: 300px;
  border-radius: 12px;
  background: #05080f;
  display: block;
}

#avatarStatus {
  font-size: 0.75rem;
  text-align: center;
}