argus-mask 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. argus_mask-0.1.0/.copier-answers.yml +21 -0
  2. argus_mask-0.1.0/.cursor/rules/agent-coding-packs.mdc +29 -0
  3. argus_mask-0.1.0/.dockerignore +15 -0
  4. argus_mask-0.1.0/.env.example +38 -0
  5. argus_mask-0.1.0/.github/workflows/ci.yml +20 -0
  6. argus_mask-0.1.0/.github/workflows/release.yml +163 -0
  7. argus_mask-0.1.0/.github/workflows/suite.yml +11 -0
  8. argus_mask-0.1.0/.gitignore +19 -0
  9. argus_mask-0.1.0/AGENTS.md +24 -0
  10. argus_mask-0.1.0/CHANGELOG.md +221 -0
  11. argus_mask-0.1.0/CLAUDE.md +89 -0
  12. argus_mask-0.1.0/Dockerfile +29 -0
  13. argus_mask-0.1.0/LICENSE +21 -0
  14. argus_mask-0.1.0/Makefile +33 -0
  15. argus_mask-0.1.0/PKG-INFO +421 -0
  16. argus_mask-0.1.0/README.md +361 -0
  17. argus_mask-0.1.0/a2f/.dockerignore +3 -0
  18. argus_mask-0.1.0/a2f/Dockerfile +113 -0
  19. argus_mask-0.1.0/a2f/README.md +139 -0
  20. argus_mask-0.1.0/a2f/check.py +165 -0
  21. argus_mask-0.1.0/a2f/server.py +557 -0
  22. argus_mask-0.1.0/a2f/worker/CMakeLists.txt +8 -0
  23. argus_mask-0.1.0/a2f/worker/main.cpp +372 -0
  24. argus_mask-0.1.0/compose.yaml +59 -0
  25. argus_mask-0.1.0/docs/synthetic-faces.md +151 -0
  26. argus_mask-0.1.0/docs/voices.md +108 -0
  27. argus_mask-0.1.0/pyproject.toml +77 -0
  28. argus_mask-0.1.0/schema/mask-wire.schema.json +628 -0
  29. argus_mask-0.1.0/scripts/README.md +140 -0
  30. argus_mask-0.1.0/scripts/fetch_stock_head.py +143 -0
  31. argus_mask-0.1.0/scripts/make_asset_catalogue.py +272 -0
  32. argus_mask-0.1.0/scripts/make_modifiers.py +196 -0
  33. argus_mask-0.1.0/scripts/stock_heads/stock-01.json +33 -0
  34. argus_mask-0.1.0/scripts/stock_heads/stock-01.lock.json +78 -0
  35. argus_mask-0.1.0/scripts/stock_heads/stock-02.json +33 -0
  36. argus_mask-0.1.0/scripts/stock_heads/stock-02.lock.json +78 -0
  37. argus_mask-0.1.0/spec.md +271 -0
  38. argus_mask-0.1.0/src/argus_mask/__init__.py +16 -0
  39. argus_mask-0.1.0/src/argus_mask/_version.py +24 -0
  40. argus_mask-0.1.0/src/argus_mask/arkit.py +64 -0
  41. argus_mask-0.1.0/src/argus_mask/audio.py +125 -0
  42. argus_mask-0.1.0/src/argus_mask/cli.py +506 -0
  43. argus_mask-0.1.0/src/argus_mask/client.py +868 -0
  44. argus_mask-0.1.0/src/argus_mask/config.py +139 -0
  45. argus_mask-0.1.0/src/argus_mask/drivers/__init__.py +61 -0
  46. argus_mask-0.1.0/src/argus_mask/drivers/a2f.py +196 -0
  47. argus_mask-0.1.0/src/argus_mask/drivers/energy.py +97 -0
  48. argus_mask-0.1.0/src/argus_mask/editor/__init__.py +14 -0
  49. argus_mask-0.1.0/src/argus_mask/editor/app.py +1000 -0
  50. argus_mask-0.1.0/src/argus_mask/editor/asset_catalogue.json +7724 -0
  51. argus_mask-0.1.0/src/argus_mask/editor/broadcast.py +281 -0
  52. argus_mask-0.1.0/src/argus_mask/editor/build_head.py +927 -0
  53. argus_mask-0.1.0/src/argus_mask/editor/builder.py +516 -0
  54. argus_mask-0.1.0/src/argus_mask/editor/library.py +221 -0
  55. argus_mask-0.1.0/src/argus_mask/editor/modifiers.json +1796 -0
  56. argus_mask-0.1.0/src/argus_mask/editor/packs.py +454 -0
  57. argus_mask-0.1.0/src/argus_mask/editor/page/broadcast.html +161 -0
  58. argus_mask-0.1.0/src/argus_mask/editor/page/broadcast.js +314 -0
  59. argus_mask-0.1.0/src/argus_mask/editor/page/editor.js +754 -0
  60. argus_mask-0.1.0/src/argus_mask/editor/page/faces.html +127 -0
  61. argus_mask-0.1.0/src/argus_mask/editor/page/faces.js +280 -0
  62. argus_mask-0.1.0/src/argus_mask/editor/page/index.html +187 -0
  63. argus_mask-0.1.0/src/argus_mask/editor/page/packs.html +89 -0
  64. argus_mask-0.1.0/src/argus_mask/editor/page/packs.js +199 -0
  65. argus_mask-0.1.0/src/argus_mask/editor/page/qr/LICENSE-qrcode-generator +27 -0
  66. argus_mask-0.1.0/src/argus_mask/editor/page/qr/qrcode.js +2297 -0
  67. argus_mask-0.1.0/src/argus_mask/editor/page/screen.js +198 -0
  68. argus_mask-0.1.0/src/argus_mask/editor/page/stage.js +286 -0
  69. argus_mask-0.1.0/src/argus_mask/editor/page/talk.json +1 -0
  70. argus_mask-0.1.0/src/argus_mask/editor/page/voices.html +150 -0
  71. argus_mask-0.1.0/src/argus_mask/editor/page/voices.js +316 -0
  72. argus_mask-0.1.0/src/argus_mask/editor/preset.py +151 -0
  73. argus_mask-0.1.0/src/argus_mask/editor/voices.py +248 -0
  74. argus_mask-0.1.0/src/argus_mask/expressions.py +235 -0
  75. argus_mask-0.1.0/src/argus_mask/faces.py +623 -0
  76. argus_mask-0.1.0/src/argus_mask/idle.py +246 -0
  77. argus_mask-0.1.0/src/argus_mask/models.py +273 -0
  78. argus_mask-0.1.0/src/argus_mask/performance.py +113 -0
  79. argus_mask-0.1.0/src/argus_mask/prosody.py +245 -0
  80. argus_mask-0.1.0/src/argus_mask/py.typed +0 -0
  81. argus_mask-0.1.0/src/argus_mask/record.py +291 -0
  82. argus_mask-0.1.0/src/argus_mask/server/__init__.py +1 -0
  83. argus_mask-0.1.0/src/argus_mask/server/app.py +688 -0
  84. argus_mask-0.1.0/src/argus_mask/speech.py +371 -0
  85. argus_mask-0.1.0/src/argus_mask/take.py +56 -0
  86. argus_mask-0.1.0/src/argus_mask/viewer/index.html +40 -0
  87. argus_mask-0.1.0/src/argus_mask/viewer/look.js +416 -0
  88. argus_mask-0.1.0/src/argus_mask/viewer/motion.js +354 -0
  89. argus_mask-0.1.0/src/argus_mask/viewer/splat.js +218 -0
  90. argus_mask-0.1.0/src/argus_mask/viewer/vendor/LICENSE-gsplat-flame-avatar-renderer +23 -0
  91. argus_mask-0.1.0/src/argus_mask/viewer/vendor/LICENSE-jszip.md +651 -0
  92. argus_mask-0.1.0/src/argus_mask/viewer/vendor/LICENSE-three +21 -0
  93. argus_mask-0.1.0/src/argus_mask/viewer/vendor/controls/OrbitControls.js +1985 -0
  94. argus_mask-0.1.0/src/argus_mask/viewer/vendor/environments/RoomEnvironment.js +185 -0
  95. argus_mask-0.1.0/src/argus_mask/viewer/vendor/gsplat-flame-avatar-renderer.esm.min.js +2 -0
  96. argus_mask-0.1.0/src/argus_mask/viewer/vendor/jszip.js +6 -0
  97. argus_mask-0.1.0/src/argus_mask/viewer/vendor/jszip.min.js +13 -0
  98. argus_mask-0.1.0/src/argus_mask/viewer/vendor/loaders/GLTFLoader.js +5006 -0
  99. argus_mask-0.1.0/src/argus_mask/viewer/vendor/math/SimplexNoise.js +470 -0
  100. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/BokehPass.js +218 -0
  101. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/EffectComposer.js +365 -0
  102. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/FilmPass.js +113 -0
  103. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/GTAOPass.js +735 -0
  104. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/MaskPass.js +195 -0
  105. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/OutputPass.js +148 -0
  106. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/Pass.js +191 -0
  107. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/RenderPass.js +193 -0
  108. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/SMAAPass.js +230 -0
  109. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/ShaderPass.js +135 -0
  110. argus_mask-0.1.0/src/argus_mask/viewer/vendor/postprocessing/UnrealBloomPass.js +541 -0
  111. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/BokehShader.js +151 -0
  112. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/CopyShader.js +52 -0
  113. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/FilmShader.js +73 -0
  114. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/GTAOShader.js +437 -0
  115. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/LuminosityHighPassShader.js +68 -0
  116. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/OutputShader.js +103 -0
  117. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/PoissonDenoiseShader.js +230 -0
  118. argus_mask-0.1.0/src/argus_mask/viewer/vendor/shaders/SMAAShader.js +489 -0
  119. argus_mask-0.1.0/src/argus_mask/viewer/vendor/three.core.js +61162 -0
  120. argus_mask-0.1.0/src/argus_mask/viewer/vendor/three.module.js +19537 -0
  121. argus_mask-0.1.0/src/argus_mask/viewer/vendor/utils/BufferGeometryUtils.js +1523 -0
  122. argus_mask-0.1.0/src/argus_mask/viewer/vendor/utils/SkeletonUtils.js +496 -0
  123. argus_mask-0.1.0/src/argus_mask/viewer/viewer.js +531 -0
  124. argus_mask-0.1.0/src/argus_mask/voice.py +548 -0
  125. argus_mask-0.1.0/tests/__init__.py +0 -0
  126. argus_mask-0.1.0/tests/helpers.py +480 -0
  127. argus_mask-0.1.0/tests/test_aliases.py +190 -0
  128. argus_mask-0.1.0/tests/test_client_cli.py +400 -0
  129. argus_mask-0.1.0/tests/test_editor.py +1232 -0
  130. argus_mask-0.1.0/tests/test_engine.py +138 -0
  131. argus_mask-0.1.0/tests/test_expressions.py +143 -0
  132. argus_mask-0.1.0/tests/test_faces.py +139 -0
  133. argus_mask-0.1.0/tests/test_fetch_stock_head.py +112 -0
  134. argus_mask-0.1.0/tests/test_packs.py +438 -0
  135. argus_mask-0.1.0/tests/test_push.py +195 -0
  136. argus_mask-0.1.0/tests/test_record.py +141 -0
  137. argus_mask-0.1.0/tests/test_server.py +368 -0
  138. argus_mask-0.1.0/tests/test_smoke.py +8 -0
  139. argus_mask-0.1.0/tests/test_speech.py +165 -0
  140. argus_mask-0.1.0/tests/test_speech_life.py +151 -0
  141. argus_mask-0.1.0/tests/test_splat.py +293 -0
  142. argus_mask-0.1.0/tests/test_voice.py +138 -0
  143. argus_mask-0.1.0/tests/test_voices_page.py +208 -0
  144. argus_mask-0.1.0/uv.lock +1091 -0
@@ -0,0 +1,21 @@
1
+ # Managed by Copier — do not edit by hand.
2
+ # Run `copier update` in this repo to pull template changes.
3
+ _commit: 939debb
4
+ _src_path: gh:smk762/argus-pkg-template
5
+ author: smk762
6
+ cli_name: argus-mask
7
+ description: 'A talking face for the Argus suite: audio in, facial animation frames
8
+ out, rendered in a browser on any screen on the LAN'
9
+ dockerfile: Dockerfile
10
+ github_owner: smk762
11
+ has_cli: true
12
+ has_server: true
13
+ image_name: argus-mask
14
+ package_name: argus_mask
15
+ post_test: uv run --no-sync argus-mask schema --check
16
+ project_name: argus-mask
17
+ python_min: '3.11'
18
+ ruff_pin: 0.15.16
19
+ test_extras: dev,server,cli
20
+ version_build_arg: false
21
+
@@ -0,0 +1,29 @@
1
+ ---
2
+ description: Fetch ai-code-auditor coding packs for argus-mask before changing code
3
+ alwaysApply: true
4
+ ---
5
+
6
+ <!-- ai-audit:agent-packs:start -->
7
+ # Agent coding packs
8
+
9
+ Do not wait for a pasted brief — fetch the audit packs for `argus-mask` yourself.
10
+
11
+ The audit API base URL is `$AI_AUDIT_PUBLIC_BASE_URL`. The real host/IP is in the local `.env`
12
+ (see `.env.example`, placeholder `http://<AUDIT_HOST>:8765`); read it there, don't guess.
13
+
14
+ 1. **Bundle (one call):** `GET $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/bundle?repo=argus-mask`
15
+ (world model + ranked specialist packs + token-capped `briefs_markdown`;
16
+ optional `&token_budget=2500&q=<keyword>&limit_specialists=5`).
17
+ CLI equivalent: `ai-audit packs --bundle --repo argus-mask`.
18
+ 2. **Claim** the specialist pack you pick:
19
+ `POST $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/<id>/status` `{"status":"claimed"}`
20
+ (send `X-API-Token: $AI_AUDIT_API_TOKEN`).
21
+ 3. **Fix:** `ai-audit repair --pack-id <id>` (agent-composer repair loop; fetches the pack for you;
22
+ HTTP: `POST http://127.0.0.1:9150/api/repair/run` `{"pack_id":"<id>","mode":"review"}`),
23
+ or read `GET $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/<id>` / `.../brief` and implement it yourself.
24
+ 4. **Close:** `POST $AI_AUDIT_PUBLIC_BASE_URL/audit/validate_patch` `{"repo":"argus-mask","patch":"<diff>","pack_id":"<id>"}`
25
+ — a passing run marks the pack `resolved`.
26
+
27
+ World-model constraints (ownership, boundaries, scan branch `dev`) win over guesswork.
28
+ Full flow: ai-code-auditor `AGENTS.md`.
29
+ <!-- ai-audit:agent-packs:end -->
@@ -0,0 +1,15 @@
1
+ # NOTE: .git is deliberately *not* ignored -- hatch-vcs reads the version from
2
+ # git history at build time. Set `version_build_arg` if you want it excluded.
3
+ .github
4
+ .venv
5
+ venv
6
+ dist
7
+ build
8
+ *.egg-info
9
+ **/__pycache__
10
+ **/*.py[cod]
11
+ .pytest_cache
12
+ .ruff_cache
13
+ .mypy_cache
14
+ .coverage
15
+ htmlcov
@@ -0,0 +1,38 @@
1
+ # Copy to .env and edit. Read by compose.yaml.
2
+
3
+ # Where the faces live: one directory per face, each a .glb and a manifest.json.
4
+ ASSETS_DIR=./assets
5
+
6
+ # energy: mouth from loudness, CPU only, crude. a2f: the Audio2Face server at A2F_URL.
7
+ DRIVER=energy
8
+ A2F_URL=
9
+
10
+ # With `docker compose --profile a2f`: DRIVER=a2f and A2F_URL=http://a2f:8110.
11
+ # Which character's Audio2Face model (james, mark or claire), and whether its solver runs on the GPU.
12
+ A2F_MODEL=james
13
+ A2F_GPU_SOLVER=0
14
+
15
+ # Animation frames per second. The viewer interpolates up to the screen's refresh rate.
16
+ FPS=30
17
+
18
+ # Host address the server is published on. 127.0.0.1 keeps it on this machine.
19
+ MASK_PUBLISH=127.0.0.1:8109
20
+
21
+ # Browser origins allowed to open the sockets, comma-separated. The server's own host is always
22
+ # allowed. Behind a TLS proxy, name the proxy's origin, e.g. https://mask.example.org
23
+ CORS_ORIGINS=
24
+
25
+ # 1 refuses every audio source; viewers still connect.
26
+ READ_ONLY=0
27
+
28
+ # The TTS stream endpoint for POST /speak (empty: /speak answers 422; /push needs none).
29
+ # From inside the container, a service on this host is http://host.docker.internal:<port>/...
30
+ # e.g. Miso TTS: http://host.docker.internal:8034/synthesise/stream, or wyoming://host:port
31
+ TTS_URL=
32
+ # The voice /speak asks for when the request names none: a stored clone's name, or a speaker number.
33
+ TTS_VOICE=
34
+
35
+ # ── Agent tooling (not read by this service) ──
36
+ # ai-code-auditor audit API base that AGENTS.md tells coding agents to use.
37
+ # Put the real host/IP in your local .env, never here.
38
+ # AI_AUDIT_PUBLIC_BASE_URL=http://<AUDIT_HOST>:8765
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [dev, main]
6
+ pull_request:
7
+ branches: [dev, main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ ci:
15
+ uses: smk762/argus-ci/.github/workflows/python-ci.yml@v1
16
+ with:
17
+ ruff-version: "0.15.16"
18
+ test-extras: "dev,server,cli"
19
+ post-test: "uv run --no-sync argus-mask schema --check"
20
+
@@ -0,0 +1,163 @@
1
+ name: Release to PyPI
2
+
3
+ # Top-level (non-reusable) on purpose: PyPI trusted publishing cannot run inside
4
+ # a reusable workflow. Keep this file in sync across the suite via `copier update`.
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+ # Sent by argus-cortex's release workflow. The image installs argus-cortex
10
+ # from the pyproject range at build time, so rebuilding the newest release's
11
+ # image is how a new cortex reaches it. Only the image jobs run: PyPI and the
12
+ # GitHub Release for that tag already exist.
13
+ repository_dispatch:
14
+ types: [cortex-released]
15
+
16
+ permissions:
17
+ contents: read
18
+ id-token: write
19
+
20
+ jobs:
21
+ build:
22
+ # Not on a cortex dispatch, so the PyPI and Release jobs that chain off it
23
+ # skip too.
24
+ if: github.event_name == 'push'
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ with:
29
+ fetch-depth: 0
30
+ - uses: astral-sh/setup-uv@v6
31
+ with:
32
+ enable-cache: true
33
+ - run: uv build
34
+ - uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ test-install:
40
+ runs-on: ubuntu-latest
41
+ needs: build
42
+ strategy:
43
+ matrix:
44
+ python-version: ["3.11", "3.12"]
45
+ steps:
46
+ - uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+ - uses: astral-sh/setup-uv@v6
51
+ with:
52
+ enable-cache: true
53
+ - name: Install wheel and smoke-test import
54
+ run: |
55
+ uv venv --python ${{ matrix.python-version }}
56
+ uv pip install dist/*.whl
57
+ uv run --no-sync python -c "import argus_mask; print(f'argus-mask {argus_mask.__version__} OK')"
58
+
59
+ publish-testpypi:
60
+ runs-on: ubuntu-latest
61
+ needs: test-install
62
+ environment: testpypi
63
+ steps:
64
+ - uses: actions/download-artifact@v4
65
+ with:
66
+ name: dist
67
+ path: dist/
68
+ - uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ repository-url: https://test.pypi.org/legacy/
71
+
72
+ publish-pypi:
73
+ runs-on: ubuntu-latest
74
+ # Ordered *after* the TestPyPI rehearsal so its outcome is visible first, but
75
+ # deliberately not gated on it. A TestPyPI-only fault — a missing trusted
76
+ # publisher, an index outage — must not silently withhold the real release:
77
+ # that failure mode shipped argus-proof images for two tags with no wheel on
78
+ # PyPI, and nothing surfaced it because publish-image is independent.
79
+ # Only the build + install gate is required; a TestPyPI failure still marks
80
+ # the whole run failed, so the problem stays loud.
81
+ needs: [test-install, publish-testpypi]
82
+ if: "!cancelled() && needs.test-install.result == 'success'"
83
+ environment: pypi
84
+ steps:
85
+ - uses: actions/download-artifact@v4
86
+ with:
87
+ name: dist
88
+ path: dist/
89
+ - uses: pypa/gh-action-pypi-publish@release/v1
90
+
91
+ release-tag:
92
+ # The tag the image jobs build: the pushed one, or on a cortex dispatch the
93
+ # newest final release. A pre-release never moved `latest`, so a dispatch
94
+ # does not rebuild one.
95
+ runs-on: ubuntu-latest
96
+ outputs:
97
+ tag: ${{ steps.tag.outputs.tag }}
98
+ sha: ${{ steps.tag.outputs.sha }}
99
+ steps:
100
+ - uses: actions/checkout@v4
101
+ if: github.event_name == 'repository_dispatch'
102
+ - id: tag
103
+ env:
104
+ CORTEX_VERSION: ${{ github.event.client_payload.version }}
105
+ run: |
106
+ if [ "$GITHUB_EVENT_NAME" != repository_dispatch ]; then
107
+ echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
108
+ echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
109
+ exit 0
110
+ fi
111
+ refs=$(git ls-remote --tags origin 'refs/tags/v*')
112
+ tag=$(echo "$refs" | sed -n 's|.*refs/tags/\(v[0-9]*\.[0-9]*\.[0-9]*\)\(\^{}\)\{0,1\}$|\1|p' | sort -uV | tail -n1)
113
+ if [ -z "$tag" ]; then
114
+ echo "::notice::No release tag yet, so no image to rebuild for cortex $CORTEX_VERSION."
115
+ exit 0
116
+ fi
117
+ # An annotated tag lists its commit on the peeled ^{} line.
118
+ sha=$(echo "$refs" | awk -v t="refs/tags/$tag" '$2 == t {l = $1} $2 == t "^{}" {p = $1} END {print p ? p : l}')
119
+ echo "Rebuilding $tag ($sha) for cortex $CORTEX_VERSION."
120
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
121
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
122
+
123
+ publish-image:
124
+ runs-on: ubuntu-latest
125
+ needs: [release-tag, test-install]
126
+ # On a cortex dispatch test-install is skipped (nothing was built for PyPI),
127
+ # so the job runs on the tag alone.
128
+ if: "!cancelled() && needs.release-tag.outputs.tag != '' && (needs.test-install.result == 'success' || github.event_name == 'repository_dispatch')"
129
+ permissions:
130
+ contents: read
131
+ packages: write
132
+ steps:
133
+ - uses: actions/checkout@v4
134
+ with:
135
+ ref: ${{ needs.release-tag.outputs.tag }}
136
+ fetch-depth: 0 # hatch-vcs reads the tag from git history
137
+ - uses: docker/setup-buildx-action@v3
138
+ - uses: docker/login-action@v3
139
+ with:
140
+ registry: ghcr.io
141
+ username: ${{ github.actor }}
142
+ password: ${{ secrets.GITHUB_TOKEN }}
143
+ - id: meta
144
+ uses: docker/metadata-action@v5
145
+ with:
146
+ images: ghcr.io/${{ github.repository_owner }}/argus-mask
147
+ labels: org.opencontainers.image.revision=${{ needs.release-tag.outputs.sha }}
148
+ tags: |
149
+ type=semver,pattern={{version}},value=${{ needs.release-tag.outputs.tag }}
150
+ type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-tag.outputs.tag }}
151
+ type=raw,value=latest
152
+ - uses: docker/build-push-action@v6
153
+ with:
154
+ context: .
155
+ file: ./Dockerfile
156
+ push: true
157
+ tags: ${{ steps.meta.outputs.tags }}
158
+ labels: ${{ steps.meta.outputs.labels }}
159
+ # A dispatch rebuilds an unchanged context, so a cached install layer
160
+ # would keep the old argus-cortex.
161
+ no-cache: ${{ github.event_name == 'repository_dispatch' }}
162
+ cache-from: type=gha
163
+ cache-to: type=gha,mode=max
@@ -0,0 +1,11 @@
1
+ name: Suite
2
+ on:
3
+ push:
4
+ branches: [dev, main]
5
+ tags: ["v*"]
6
+ pull_request:
7
+ branches: [dev, main]
8
+
9
+ jobs:
10
+ suite:
11
+ uses: smk762/argus-ci/.github/workflows/suite-check.yml@v1
@@ -0,0 +1,19 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ # hatch-vcs writes this at build time (version derived from git tags)
10
+ src/argus_mask/_version.py
11
+ .env
12
+ # Faces are large binaries with their own provenance; the stock head is added deliberately, by name.
13
+ /assets/
14
+ spec.orig.md
15
+
16
+ # Broadcast page recordings (argus-mask editor --recordings-dir)
17
+ /recordings/
18
+ # Voices page: checked clips and each voice's record and clips (argus-mask editor --voices-dir)
19
+ /voices/
@@ -0,0 +1,24 @@
1
+ <!-- ai-audit:agent-packs:start -->
2
+ ## Agent coding packs (ai-code-auditor)
3
+
4
+ Do not wait for a pasted brief — fetch the audit packs for `argus-mask` yourself.
5
+
6
+ The audit API base URL is `$AI_AUDIT_PUBLIC_BASE_URL`. The real host/IP is in the local `.env`
7
+ (see `.env.example`, placeholder `http://<AUDIT_HOST>:8765`); read it there, don't guess.
8
+
9
+ 1. **Bundle (one call):** `GET $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/bundle?repo=argus-mask`
10
+ (world model + ranked specialist packs + token-capped `briefs_markdown`;
11
+ optional `&token_budget=2500&q=<keyword>&limit_specialists=5`).
12
+ CLI equivalent: `ai-audit packs --bundle --repo argus-mask`.
13
+ 2. **Claim** the specialist pack you pick:
14
+ `POST $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/<id>/status` `{"status":"claimed"}`
15
+ (send `X-API-Token: $AI_AUDIT_API_TOKEN`).
16
+ 3. **Fix:** `ai-audit repair --pack-id <id>` (agent-composer repair loop; fetches the pack for you;
17
+ HTTP: `POST http://127.0.0.1:9150/api/repair/run` `{"pack_id":"<id>","mode":"review"}`),
18
+ or read `GET $AI_AUDIT_PUBLIC_BASE_URL/audit/packs/<id>` / `.../brief` and implement it yourself.
19
+ 4. **Close:** `POST $AI_AUDIT_PUBLIC_BASE_URL/audit/validate_patch` `{"repo":"argus-mask","patch":"<diff>","pack_id":"<id>"}`
20
+ — a passing run marks the pack `resolved`.
21
+
22
+ World-model constraints (ownership, boundaries, scan branch `dev`) win over guesswork.
23
+ Full flow: ai-code-auditor `AGENTS.md`.
24
+ <!-- ai-audit:agent-packs:end -->
@@ -0,0 +1,221 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-13
11
+
12
+ ### Added
13
+
14
+ - **Splat heads**: a LAM Gaussian-splat avatar (a `.zip`: `skin.glb`, `offset.ply`, `animation.glb`,
15
+ `vertex_order.json`) is a face like any other: served (`GET /face/{id}/model`, and `/face/{id}/model/{file}` by its
16
+ own name), shown by the viewer and the editor's Saved faces previews, driven by mask's frames, rest sets, idle life
17
+ and lip budget (measured from the archive's `skin.glb`, with a jaw cap for the weak splat mouth: `?jaw=`), and
18
+ recorded frame-exact by `record` and the Broadcast page. The manifest's model gains `format` (`gltf`, the default,
19
+ or `lam-splat`); the wire contract is 1.2, and older manifests read as before. The archive is checked in memory,
20
+ never unpacked to disk: one folder of known members, size, count and compression-ratio caps, no path tricks or
21
+ links, ARKit morph targets only. `inspect` takes a `.zip` (and `--write-manifest` for it, with the splat framing).
22
+ The viewer draws one with Myned AI's gsplat-flame-avatar-renderer 1.4.0 (MIT) and JSZip 3.10.1 (MIT), vendored
23
+ with three.js r187's `OrbitControls.js`; `window.__mask.renderer` says `splat` or `mesh`. LAM's weights (CC BY-NC
24
+ 4.0) and FLAME are non-commercial, so a LAM head is `personal`, never cleared. `display=hld` refuses a splat face
25
+ for now.
26
+ - **Personal and cleared faces and voices** (#19). Provenance gains `personal` (your own use, anything lawful in
27
+ private, such as a cartoon or a character LoRA; needs only a `source`) and `consented` (a real person or owned
28
+ character under a release: `subject` and `release_sha256`). Every provenance carries a derived `cleared`: true for
29
+ `stock`, `synthetic` and `consented`. `/faces`, the Saved faces page and the Broadcast page show it.
30
+ `inspect --write-manifest` defaults to `personal`; `stock` and `synthetic` still need a licence. The Voices page has a
31
+ **Personal use** source that records where the clips came from and is never cleared. The wire contract is 1.1.
32
+ - **The face follows the voice** (#14, #15). A new `prosody.py` reads each frame's loudness and pitch from the audio
33
+ and marks stressed syllables, phrase ends and phrase starts. In takes:
34
+ - **Nods and brows.** The head nods on stressed syllables (up to about 2.5 degrees), settles with a small tilt at
35
+ each phrase end (alternating sides), and the brows rise on emphasis, about once a phrase, added to the driver's own.
36
+ - **Blinks in pauses.** Most blinks come just as a phrase ends; none starts while a word is sounding unless the last
37
+ was more than 8 s ago. About 18 a minute, sometimes doubled.
38
+ - **Saccades.** The eyes jump and hold for 0.2-0.6 s with a slight drift. A phrase start or a pause sometimes takes
39
+ them away (to the side, or up, as people look away to think), and the head turns a little with them.
40
+ - **Micro-motion.** Slow, small changes of brow tension, and of mouth tension while not speaking.
41
+
42
+ It is all a function of the audio and the seed, so takes stay byte-identical. On five synthetic voices: 1.8 to 2.0
43
+ stresses a second of speech, brows raised in 7-20% of frames, and every blink starting in a pause or between words.
44
+ Analysis costs about 0.3% of real time.
45
+ - **Rest sets and idle life**: a face at rest shows a named baseline expression, `warm` by default (also `neutral`,
46
+ `calm`, `bright`, `attentive`, and your own in the assets' `expressions.json`), chosen per face on Saved faces and
47
+ served as `GET /face/{id}/rest`; a screen may override it (`?rest=`). While speaking, the set's brows, eyes and cheeks
48
+ hold and its mouth weights go at half. At rest the face blinks (some doubled), glances with the head following,
49
+ breathes, and shifts expression now and then (`?life=0` to `2`). Viewer-side only: takes are unchanged.
50
+ - **Lip budget**: the viewer measures how each mouth shape moves the gap between the lips, from the face's own mesh,
51
+ and scales the lip-closing shapes so the lips never pass through each other (1 mm allowed for pressing), with
52
+ `mouthShrugLower` and the rolls at 0.7. On ginger-3, lips overlapping by more than 3 mm went from 12% of frames to
53
+ none, and the lower lip no longer rides up over the upper. Screens, `record` and the editor's previews alike.
54
+ - **Smooth playback between frames** (#13): screens and the editor's `speak` preview draw a cubic through the
55
+ frames on either side instead of a straight line between two, so 30 fps motion has no corners (the speed jump at
56
+ each frame, up to 0.44 of a weight per frame on Audio2Face output, is now none). Weights use a monotone cubic, so a
57
+ mouth never overshoots a closure; the head turns as Catmull-Rom. The curve passes through every frame at its own
58
+ time: lip timing is unchanged, and `record` (which renders at the frame times) shows exactly the frames, as before.
59
+ - **The editor's previews speak**: the `speak` pose (now the default) loops 12 s of real Audio2Face motion, with the
60
+ lip budget, rest set and idle life a screen shows, instead of a sine-wave mouth; a `rest:` picker previews each set.
61
+
62
+ - **Named TTS speakers and delivery instructions**: a voice written `speaker:<name>` (`--voice`, `ARGUS_MASK_TTS_VOICE`,
63
+ `/speak`'s `voice`) is sent as the service's own speaker, not as a clone, for services that name their speakers, such
64
+ as Qwen3-TTS's CustomVoice (`speaker:Vivian`). `POST /speak` takes an optional `instruction`, how to say it, passed
65
+ to a service that reads one; like the text, it is never logged.
66
+ - **Face aliases** (#41): a stable name for a face, such as `holly` for a persona, that the agent speaking and the
67
+ kiosk showing it use for good while the face behind it is chosen in the editor (Saved faces → Aliases: add,
68
+ repoint, remove, open on a screen). Kept in the assets' `aliases.json`, written by the editor and read by the server
69
+ whenever it changes, so its read-only mount and no restart. Every route that names a face takes an alias; sources
70
+ and screens meet on the name they use; `GET /aliases` and `/health`'s `aliases` list them, `/faces` is unchanged. A
71
+ screen watching an alias that is repointed loads the new face on the next stream, whose header names the face
72
+ itself. An alias never hides a face, points only at a served face and never at another alias; renaming a face
73
+ carries its aliases, deleting one an alias points at is refused.
74
+ - **Record a broadcast from the Broadcast page**: a Record to file switch (frame size, look) for Say and Push. The
75
+ recorder listens on the face first, the broadcast goes, then the clip renders and muxes, as `argus-mask record` does,
76
+ into `ARGUS_MASK_RECORDINGS_DIR`; the page plays it, offers it to download and lists recent clips. `record` now
77
+ tells its caller it is waiting only once its socket is open, and muxes off the event loop.
78
+ - **Face voices** (#37): the editor's **Voices** page checks reference clips for a cloned voice (speech length, sample
79
+ rate, bandwidth, clipping, level, noise, edges, pauses, delivery, whether the transcript fits), each check with a
80
+ verdict and what to change (`argus_mask.voice`, pure numpy); generates synthetic candidates from the TTS service's
81
+ own speaker or a described voice, reading a script per delivery; makes the voice on the service (`/clone-voice`)
82
+ with a `voice.json` record of where it came from (generated, your own, or a consenting speaker's under a release,
83
+ by its sha256) in `ARGUS_MASK_VOICES_DIR`; refuses unsuitable clips and ids the service already has; tries a line
84
+ in the browser. `docs/voices.md`: the reference, recording your own, synthetic voices, one voice per delivery.
85
+ - **`say` tidies speech** (#36, on by default, `--as-is` and the Broadcast page's Tidy speech turn it off): numbers,
86
+ times, dates and symbols go as words (`speech.speakable`), a sentence of one to three words goes with the next, and
87
+ each sentence is faded in and out and brought to the same speech level. Recordings through Miso had invented
88
+ words for digits, one-word sentences far off in pitch, clicks at sentence ends, and 9 dB between sentences.
89
+ - **`POST /push` and `POST /speak`** on the server (#38), for agents: a WAV or raw PCM body (or text through the
90
+ configured TTS) plays into a face exactly as a source does, paced, one source per face, behind the same guards;
91
+ `require_screen=1` refuses when no screen can hear it. The server never sees text with `/push`.
92
+ - **External sound for house speakers** (#39): `?audio=external` (or `mute=1`) makes a viewer animate on the session's
93
+ timeline without playing anything and without a tap; `?delay=<ms>` lines the lips up with a speaker system's
94
+ buffer (Snapcast). `/health.screens` gains `sound: local | external`; an external screen is ready once connected.
95
+ The On screen dialog sets both.
96
+ - **`say --context`** (#40, opt-in): each sentence goes to an HTTP TTS with the one before it, text and levelled
97
+ audio, as Miso's `context` (voluptas#4); a service that refuses the field is asked again without it. Off by default:
98
+ with a cloned voice on Miso, chaining the audio made pitch drift up and loudness sink over a briefing, where
99
+ sentences sent alone held steady. The Broadcast page has it as an experimental checkbox.
100
+ - **Recording presets** (#35): 1080p, 720p and 480p, and the HLD portrait panel; an info tooltip on temperature.
101
+ - **Temperature on the Broadcast page**: a slider (0.1 to 1.5, Miso's range), left to the service's own value unless
102
+ set; it goes as the request's `temperature` (`-p temperature=…`). Over Wyoming, `-p` fields other than the voice's
103
+ now ride beside the text in the `synthesize` event, for a bridge to pass on, instead of being dropped.
104
+ - **Voices from an HTTP TTS**: for a `…/synthesise[/stream]` URL (Miso, agent-composer's TTS servers) the editor lists
105
+ `GET …/speakers` and `GET …/clones`, named as the Wyoming bridge names them (`0`, `clone:<id>`), so the Broadcast
106
+ page's voice picker works talking to Miso directly.
107
+ - **Broadcast page** in the editor: the server's faces with their readiness, and a form with every `say` option
108
+ (voice listed from a Wyoming service, pause, seed, stream, TTS URL, `-p` fields) or a WAV to `push`, run through
109
+ the CLI's own client with live progress, Stop, and the matching command line. `client.tts_voices()` asks a Wyoming
110
+ service for its voices.
111
+ - **Editor to screen** (#28). On screen… in the editor and on Saved faces: checks the server lists the exported
112
+ face (and says plainly when it does not), builds the viewer link at `ARGUS_MASK_VIEWER_URL` with display, quality,
113
+ kiosk and background, to open, copy or scan as a QR code, shows whether screens are watching and can be heard,
114
+ plays a test chime through the server's source socket, and gives the `say` command. The viewer shows **Tap to
115
+ start** until its sound is unlocked and a brief **Ready** after, takes `kiosk=1`, and reports its state on its
116
+ socket (`ViewerStatus`, in the wire schema); `/health` lists `screens` per face. The viewer's reconnect backoff
117
+ now grows as it was meant to.
118
+ - **Stock heads republished**: `stock-01` revision 4 and `stock-02`'s first lock, both built by the recipe with the wet
119
+ line and baked skin maps (about 10 MB each). `fetch_stock_head.py --id stock-02` fetches the second.
120
+ - **Recipe: wet line and skin maps** (#11, #9). A tear line along each lower lid (`wetline`, found by casting
121
+ rays at the eye, moving with the lid under every shape key), and normal and roughness maps baked from the skin's
122
+ colour map into the `.glb`. The viewer gives the wet line a fading glossy film and lays its generated pores over
123
+ the baked maps.
124
+ - **Two-pass hair** (#12): an opaque core, then the fringe blended over it. On a 10 s recording, 26% less edge
125
+ shimmer and 59% fewer edge pops than the cut-out; alpha to coverage alone managed 6%. The default in every profile.
126
+ - **Triangle budget 45k** (was 35k), while the weakest panels are not targets.
127
+ - **Viewer look** (`viewer/look.js`, issue #20 tier 1): a three-point light rig, image-based light and PBR Neutral
128
+ tone mapping (#8); soft shadows from the key light (#8); physical skin with wrap lighting for subsurface scatter,
129
+ sheen and a generated pore-scale detail map (#9); catch-lights from the eyes' cornea shell, which follow the
130
+ gaze (#11); alpha-to-coverage hair edges (#12); and post-processing (GTAO, depth of field, bloom, grain, optional
131
+ SMAA) in `low`, `medium` and `high` profiles with a URL switch per effect (#10). `record` renders with `high`
132
+ (`--quality`). The editor's previews use the same look.
133
+ - **Triangle budget 35k** (was 30k): a dressed head (a uniform is ~5k triangles, MakeHuman's teeth 7k) ran just over it.
134
+ - **Editor: compare, undo, build cache.** A Before card beside the preview shows the previous build and what
135
+ changed; the large view shows both side by side, posed together. Undo and redo step through the builds. Each
136
+ build is cached by what it was made from (the preset without id and name, the recipe, the Blender), so a repeat
137
+ copies in well under a second instead of running Blender: `GET /cache/{key}` serves an earlier build directly.
138
+ `ARGUS_MASK_BUILD_CACHE` sets how many are kept (40).
139
+ - **The engine.** Audio in, animation frames out: a streaming resampler to 16 kHz, a driver, a seeded idle layer
140
+ (blinks, gaze, head sway) and frames stamped on the audio timeline. The same audio, driver and seed give the same
141
+ take file.
142
+ - **Drivers.** `energy` moves the mouth from loudness on the CPU, for tests and hosts with no NVIDIA card. `a2f` is
143
+ the client of the Audio2Face server.
144
+ - **Faces.** A `.glb` with ARKit-named morph targets and a `manifest.json` with required provenance. A face with no
145
+ provenance, a `curated` face, or one whose model no longer matches its sha256 is not served.
146
+ - **Server** (`argus-mask serve`, port 8109): `/health`, `/faces`, `/face/{id}`, `/face/{id}/model`, a source socket
147
+ (one per face) and a viewer socket, both under the same origin rule as cortex's write guard.
148
+ - **Viewer**, bundled and served at `/`: three.js, lip sync on the page's own audio clock, the `flat` and `hld`
149
+ display tiers.
150
+ - **CLI:** `inspect` (with `--write-manifest`), `drive`, `push`, `say`, `serve`, `schema`.
151
+ - **The stock head.** `src/argus_mask/editor/build_head.py` (was `scripts/build_stock_head.py`) builds a head with headless Blender and MPFB from a preset (CC0
152
+ MakeHuman assets, adults only). The model is not in the repo: `scripts/fetch_stock_head.py` (`make head`) downloads
153
+ it and checks it against `scripts/stock_heads/stock-01.lock.json`.
154
+ - Wire schema `schema/mask-wire.schema.json`.
155
+ - **Face editor** (`argus-mask editor`, port 8111): a page that builds heads from presets with headless Blender and
156
+ MPFB, about ten seconds a build, with a posed preview. Ages 18 and up; every head wears an outfit. Skin, hair,
157
+ clothes and a hat are picked from thumbnails;
158
+ 117 shape sliders (head, forehead, brow ridge, eyes, ears, cheeks, nose, mouth, chin, neck) over MakeHuman's CC0
159
+ modelling targets, from `modifiers.json`, generated from MPFB's catalogue. Collapsible panels, a filter, a hover
160
+ preview of each asset, a preview card that opens a large view, a spinner and timer while a build runs, and
161
+ **Live update**, which builds a second after the last change. A build is a preview (`assets/.preview/`);
162
+ **Export** puts it in `assets/<id>` and on `/faces`; **Download preset** saves the form and
163
+ **Open preset…** loads one. A **Saved faces** page reviews, remixes, renames and deletes (to `assets/.trash/`) the
164
+ faces the editor made, and reviews the others. It offers only assets an installed pack licenses, writes their
165
+ licences into the face's provenance, replaces only faces it made, and is behind the write guards. Blender, MPFB and
166
+ the asset packs are the user's to install; none ships.
167
+ - **A second stock preset**, `stock-02`: a female head, about 25, short brown hair, in the same crew uniform as
168
+ `stock-01` (suits02 pack, CC0). Not published yet.
169
+ - **`argus-mask record`**: waits for the next session on a face and renders it to a video (MP4 or WebM), offline and
170
+ frame-exact, in a headless browser, muxed with the session's audio; the take and WAV are written beside it. The
171
+ viewer gets a `?record=1` mode posed from outside. Needs the `record` extra (playwright, imageio-ffmpeg).
172
+ - **The Audio2Face server** (`a2f/`): NVIDIA's Audio2Face-3D SDK behind the `a2f` driver's protocol, in its own
173
+ Docker image. It downloads its model and builds a TensorRT engine for the host's card on first start, and holds
174
+ about 0.5 GB of VRAM. `docker compose --profile a2f` runs it beside mask. The image is built locally, not published.
175
+
176
+ - The viewer's audio buffer is tunable (`?buffer=<ms>`, default 250).
177
+ - The stock head lock points at revision 2 of `stock-01`, with the mouth fixes.
178
+ - **Clothes.** A preset's `assets.clothes` lists garments; the body under them is removed and they are cut with the
179
+ bust. `stock-01` revision 3 wears `thegreatengineer_galactic_warrior_uniform` from the MakeHuman community's
180
+ suits02 pack (CC0): a high-collared crew tunic, since a bust in nothing looked undressed.
181
+
182
+ - **`say` speaks Wyoming** (`--tts-url wyoming://host:port`, `-p voice=…`), the protocol of Home Assistant's TTS
183
+ servers and agent-composer's XTTS and Miso bridges, beside the HTTP PCM stream it already understood.
184
+ - `say` says it is waiting for the TTS service, and Ctrl-C stops the wait at once instead of hanging on the socket.
185
+ - **`say --voice`** and `ARGUS_MASK_TTS_VOICE`: the voice to ask the TTS service for, a stored clone's name or a
186
+ speaker number, put in the field each kind of service expects.
187
+ - **`say --pause`**: the text goes to the TTS service one sentence at a time, with 300 ms of silence between
188
+ sentences and 600 ms between paragraphs, so sentences no longer run into each other; `--pause 0` sends it whole.
189
+ - `say` pads the utterance with 150 ms of silence before and 300 ms after, so a waking output device does not eat the
190
+ first syllable and the mouth closes before the stream ends.
191
+ - The viewer counts audio chunks that arrived while the browser had paused its audio clock (`__mask.audioDropped`) and
192
+ asks for the clock back, so a clipped start can be told from a clipped reply.
193
+
194
+ ### Fixed
195
+
196
+ - **A provenance says what it means** (#64). An unknown field is refused, not dropped in silence, and `licence` is
197
+ read as `license` (a provenance with both, disagreeing, is refused). A `source` of only whitespace is refused like a
198
+ missing one. A dumped record's `cleared` still reads back, and is recomputed.
199
+ - **A source that sends faster than real time is slowed, not cut off** (#2). The `a2f` driver lets up to 2 s of audio
200
+ wait on frames, then holds the source until the model catches up; a burst of a 15 s clip used to end the stream with
201
+ "fell more than 10s behind". A model that sends no frame for 10 s while the source is held still ends it.
202
+ - **The Audio2Face server reports a dead worker at once** (#4), not on the client's next message: a worker that crashed
203
+ during a pause in speech left the face frozen with no error.
204
+ - **`POST /speak` in Docker**: `compose.yaml` passes `TTS_URL` and `TTS_VOICE` from `.env` to the server as
205
+ `ARGUS_MASK_TTS_URL` and `ARGUS_MASK_TTS_VOICE`; without them `/speak` always answered `422`.
206
+ - A Broadcast recording that caught another source's session on the face is discarded, not kept as this broadcast's,
207
+ and a failed or stopped render no longer leaves its frames behind (#35).
208
+ - `say` (without `--stream`), `/speak` and the Voices page's Try buffered a TTS reply without limit: they now refuse
209
+ past 10 minutes of speech, saying to send less or stream (#3). The client pacing test has a wider margin (#5).
210
+ - `say --voice <name> -p speaker=<n>` against a Wyoming service dropped the voice name: a speaker now goes with the
211
+ voice (Wyoming's `{name, speaker}`), and only a name in the request stands instead of `--voice`.
212
+ - **`say` no longer dumps a slow TTS model's speech all at once.** It fetches the whole utterance before claiming the
213
+ face and plays it at the speed of speech (`--stream` keeps the old behaviour for a service faster than real time),
214
+ and a source's pacing clock now starts at its first chunk rather than at connect.
215
+
216
+ - **Audio no longer runs ahead of the mouth with a slow driver.** Audio2Face answers about 0.3 s behind the audio it
217
+ is given. The server now holds each audio chunk until frames reach its end, and ends a stream whose driver falls
218
+ more than 10 s behind.
219
+
220
+ [Unreleased]: https://github.com/smk762/argus-mask/compare/v0.1.0...HEAD
221
+ [0.1.0]: https://github.com/smk762/argus-mask/releases/tag/v0.1.0