ziomek 0.1.1__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.
- ziomek-0.1.1/.gitignore +21 -0
- ziomek-0.1.1/LICENSE +21 -0
- ziomek-0.1.1/PKG-INFO +50 -0
- ziomek-0.1.1/pyproject.toml +55 -0
- ziomek-0.1.1/src/ziomek/__init__.py +2 -0
- ziomek-0.1.1/src/ziomek/api/__init__.py +1 -0
- ziomek-0.1.1/src/ziomek/api/avatar.py +136 -0
- ziomek-0.1.1/src/ziomek/api/health.py +37 -0
- ziomek-0.1.1/src/ziomek/api/tts.py +71 -0
- ziomek-0.1.1/src/ziomek/avatar/__init__.py +1 -0
- ziomek-0.1.1/src/ziomek/avatar/skins.py +127 -0
- ziomek-0.1.1/src/ziomek/avatar/sprites.py +48 -0
- ziomek-0.1.1/src/ziomek/avatar/state_machine.py +399 -0
- ziomek-0.1.1/src/ziomek/cli.py +82 -0
- ziomek-0.1.1/src/ziomek/client/__init__.py +1 -0
- ziomek-0.1.1/src/ziomek/client/cli.py +78 -0
- ziomek-0.1.1/src/ziomek/client/gamepad.py +86 -0
- ziomek-0.1.1/src/ziomek/client/server.py +207 -0
- ziomek-0.1.1/src/ziomek/config.py +9 -0
- ziomek-0.1.1/src/ziomek/server.py +59 -0
- ziomek-0.1.1/src/ziomek/tts/__init__.py +1 -0
- ziomek-0.1.1/src/ziomek/tts/engine.py +35 -0
- ziomek-0.1.1/src/ziomek/tts/generate.py +37 -0
- ziomek-0.1.1/src/ziomek/tts/playback.py +50 -0
- ziomek-0.1.1/src/ziomek/tts/voice.py +28 -0
- ziomek-0.1.1/tests/__init__.py +1 -0
- ziomek-0.1.1/tests/avatar/__init__.py +1 -0
- ziomek-0.1.1/tests/avatar/test_api.py +53 -0
- ziomek-0.1.1/tests/avatar/test_skins.py +85 -0
- ziomek-0.1.1/tests/avatar/test_sprites.py +46 -0
- ziomek-0.1.1/tests/avatar/test_state_machine.py +158 -0
- ziomek-0.1.1/tests/client/__init__.py +0 -0
- ziomek-0.1.1/tests/client/test_gamepad.py +29 -0
- ziomek-0.1.1/tests/client/test_server.py +30 -0
- ziomek-0.1.1/tests/test_server.py +35 -0
- ziomek-0.1.1/tests/test_version.py +6 -0
- ziomek-0.1.1/tests/tts/__init__.py +1 -0
- ziomek-0.1.1/tests/tts/test_api.py +35 -0
- ziomek-0.1.1/tests/tts/test_engine.py +19 -0
- ziomek-0.1.1/tests/tts/test_generate.py +44 -0
- ziomek-0.1.1/tests/tts/test_playback.py +58 -0
- ziomek-0.1.1/tests/tts/test_voice.py +50 -0
ziomek-0.1.1/.gitignore
ADDED
ziomek-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bartlomiej Cieszkowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ziomek-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ziomek
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: ziomek - Humanize AI
|
|
5
|
+
Project-URL: Homepage, https://github.com/bartlomiejcieszkowski/ziomek
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/bartlomiejcieszkowski/ziomek/issues
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Bartlomiej Cieszkowski
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: agent,ai,llm,skill
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Requires-Python: >=3.10
|
|
33
|
+
Requires-Dist: fastapi>=0.100
|
|
34
|
+
Requires-Dist: numpy>=1.24
|
|
35
|
+
Requires-Dist: pillow>=10.0
|
|
36
|
+
Requires-Dist: pocket-tts>=3.0
|
|
37
|
+
Requires-Dist: pydantic>=2.0
|
|
38
|
+
Requires-Dist: pygame-ce>=2.5.8
|
|
39
|
+
Requires-Dist: requests>=2.31
|
|
40
|
+
Requires-Dist: safetensors>=0.4
|
|
41
|
+
Requires-Dist: scipy>=1.10
|
|
42
|
+
Requires-Dist: sounddevice>=0.4
|
|
43
|
+
Requires-Dist: soundfile>=0.12
|
|
44
|
+
Requires-Dist: torch>=2.0
|
|
45
|
+
Requires-Dist: uvicorn[standard]>=0.23
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: httpx2>=2.0; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ziomek"
|
|
3
|
+
description = "ziomek - Humanize AI"
|
|
4
|
+
dynamic = ["version"]
|
|
5
|
+
author = [
|
|
6
|
+
{name = "Bartlomiej Cieszkowski", email = "bartlomiej.cieszkowski@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = ["llm", "ai", "agent", "skill"]
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
classifiers = [
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pocket-tts>=3.0", "torch>=2.0", "numpy>=1.24", "scipy>=1.10",
|
|
18
|
+
"sounddevice>=0.4", "soundfile>=0.12", "safetensors>=0.4",
|
|
19
|
+
"fastapi>=0.100", "uvicorn[standard]>=0.23", "pydantic>=2.0",
|
|
20
|
+
"pillow>=10.0", "requests>=2.31",
|
|
21
|
+
"pygame-ce>=2.5.8",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
"Homepage" = "https://github.com/bartlomiejcieszkowski/ziomek"
|
|
26
|
+
"Bug Tracker" = "https://github.com/bartlomiejcieszkowski/ziomek/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "httpx2>=2.0", "ruff>=0.1"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
ziomek = "ziomek.cli:main"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build]
|
|
39
|
+
only-packages = true
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/ziomek"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 120
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["I"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.version]
|
|
51
|
+
path = "src/ziomek/__init__.py"
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
log_cli = true
|
|
55
|
+
log_level = "NOTSET"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ziomek API package."""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""ziomek avatar API endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
9
|
+
|
|
10
|
+
from ziomek.avatar.state_machine import AvatarInput
|
|
11
|
+
from ziomek.avatar.sprites import sprite_to_base64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
# Path to the shared HTML renderer
|
|
17
|
+
_renderer_html = Path(__file__).parent.parent / "client" / "resources" / "renderer.html"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _not_initialized(msg: str) -> JSONResponse:
|
|
21
|
+
return JSONResponse(status_code=503, content={"error": msg})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/api/avatar/view")
|
|
25
|
+
async def get_avatar_view() -> HTMLResponse:
|
|
26
|
+
"""Serve the avatar renderer HTML (fetched by client from server)."""
|
|
27
|
+
if not _renderer_html.exists():
|
|
28
|
+
return HTMLResponse(
|
|
29
|
+
content="<html><body><h1>Renderer not found</h1></body></html>",
|
|
30
|
+
status_code=404,
|
|
31
|
+
)
|
|
32
|
+
return HTMLResponse(content=_renderer_html.read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/api/avatar/state")
|
|
36
|
+
async def get_state() -> Any:
|
|
37
|
+
"""Get current avatar state."""
|
|
38
|
+
from ziomek.server import _state_machine
|
|
39
|
+
if _state_machine is None:
|
|
40
|
+
return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
|
|
41
|
+
state = _state_machine.tick(16)
|
|
42
|
+
return {
|
|
43
|
+
"expression": state.expressionName,
|
|
44
|
+
"cycleIndex": state.cycleIndex,
|
|
45
|
+
"message": state.message,
|
|
46
|
+
"messageExpiry": state.messageExpiry,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/api/avatar/input")
|
|
51
|
+
async def set_input(request: Request) -> Any:
|
|
52
|
+
"""Feed gamepad/VSCode state to the state machine."""
|
|
53
|
+
from ziomek.server import _state_machine
|
|
54
|
+
if _state_machine is None:
|
|
55
|
+
return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
|
|
56
|
+
body = await request.json()
|
|
57
|
+
inp = AvatarInput(
|
|
58
|
+
buttons=body.get("buttons", []),
|
|
59
|
+
axes=body.get("axes", [0, 0, 0]),
|
|
60
|
+
connected=body.get("connected", False),
|
|
61
|
+
streaming=body.get("streaming", False),
|
|
62
|
+
chatFocused=body.get("chatFocused", False),
|
|
63
|
+
errorState=body.get("errorState", False),
|
|
64
|
+
)
|
|
65
|
+
_state_machine.update(inp)
|
|
66
|
+
state = _state_machine.tick(16)
|
|
67
|
+
return {"expression": state.expressionName, "cycleIndex": state.cycleIndex}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/api/avatar/expression")
|
|
71
|
+
async def set_expression(request: Request) -> Any:
|
|
72
|
+
"""Force-set an expression."""
|
|
73
|
+
from ziomek.server import _state_machine
|
|
74
|
+
if _state_machine is None:
|
|
75
|
+
return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
|
|
76
|
+
body = await request.json()
|
|
77
|
+
name = body.get("name", "")
|
|
78
|
+
duration_ms = body.get("duration_ms")
|
|
79
|
+
ok = _state_machine.setExpression(name, duration_ms)
|
|
80
|
+
return {"ok": ok}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.post("/api/avatar/speak")
|
|
84
|
+
async def speak(request: Request) -> Any:
|
|
85
|
+
"""Generate speech and optionally set an expression."""
|
|
86
|
+
from ziomek.server import _tts_model, _voice_cache, _audio_player, _state_machine
|
|
87
|
+
from ziomek.tts.generate import generate_wav
|
|
88
|
+
|
|
89
|
+
body = await request.json()
|
|
90
|
+
text = body.get("text", "")
|
|
91
|
+
voice = body.get("voice", "default")
|
|
92
|
+
expression = body.get("expression")
|
|
93
|
+
|
|
94
|
+
if _tts_model is None or not _tts_model.ready:
|
|
95
|
+
return JSONResponse(status_code=503, content={"error": "TTS not ready"})
|
|
96
|
+
if _state_machine is None:
|
|
97
|
+
return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
|
|
98
|
+
if _voice_cache is None:
|
|
99
|
+
return JSONResponse(status_code=503, content={"error": "Voice cache not initialized"})
|
|
100
|
+
if expression:
|
|
101
|
+
_state_machine.setExpression(expression)
|
|
102
|
+
audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
|
|
103
|
+
import base64
|
|
104
|
+
wav_bytes = base64.b64decode(audio_b64)
|
|
105
|
+
if _audio_player is not None:
|
|
106
|
+
_audio_player.play(wav_bytes, sample_rate)
|
|
107
|
+
return {
|
|
108
|
+
"status": "playing",
|
|
109
|
+
"duration": duration,
|
|
110
|
+
"sample_rate": sample_rate,
|
|
111
|
+
"channels": 1,
|
|
112
|
+
"bit_depth": 16,
|
|
113
|
+
"voice": voice,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get("/api/avatar/sprite")
|
|
118
|
+
async def get_sprite() -> Any:
|
|
119
|
+
"""Get sprite sheet for client rendering."""
|
|
120
|
+
from ziomek.server import _skin_registry
|
|
121
|
+
if _skin_registry is None:
|
|
122
|
+
return JSONResponse(status_code=503, content={"error": "Skin registry not initialized"})
|
|
123
|
+
skin = _skin_registry.get("single-frame")
|
|
124
|
+
if skin is None:
|
|
125
|
+
return JSONResponse(status_code=404, content={"error": "No default skin"})
|
|
126
|
+
try:
|
|
127
|
+
sprite_data = sprite_to_base64(skin.getSpriteBuffer())
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return JSONResponse(status_code=404, content={"error": "Sprite file not found"})
|
|
130
|
+
return {
|
|
131
|
+
"sprite_b64": sprite_data,
|
|
132
|
+
"width": skin.spriteWidth,
|
|
133
|
+
"height": skin.spriteHeight,
|
|
134
|
+
"frameWidth": skin.frameWidth,
|
|
135
|
+
"frameHeight": skin.frameHeight,
|
|
136
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""ziomek health endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
|
|
6
|
+
from ziomek.server import _tts_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/status")
|
|
13
|
+
async def status():
|
|
14
|
+
"""Get server health status."""
|
|
15
|
+
ready = _tts_model is not None and _tts_model.ready
|
|
16
|
+
model_loaded = _tts_model is not None and _tts_model.ready
|
|
17
|
+
return {
|
|
18
|
+
"ready": ready,
|
|
19
|
+
"model_loaded": model_loaded,
|
|
20
|
+
"endpoints": {
|
|
21
|
+
"tts": ["POST /api/tts/generate", "POST /api/tts/speak", "GET /api/tts/status", "GET /api/tts/voices"],
|
|
22
|
+
"avatar": ["POST /api/avatar/input", "GET /api/avatar/state", "POST /api/avatar/expression", "GET /api/avatar/sprite", "POST /api/avatar/speak", "GET /api/avatar/view"],
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/")
|
|
28
|
+
async def root() -> dict:
|
|
29
|
+
"""Server root endpoint."""
|
|
30
|
+
return {
|
|
31
|
+
"name": "ziomek",
|
|
32
|
+
"version": "0.1.0",
|
|
33
|
+
"endpoints": {
|
|
34
|
+
"generate": "POST /api/tts/generate",
|
|
35
|
+
"status": "GET /api/status",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""ziomek TTS API endpoints."""
|
|
2
|
+
from fastapi import APIRouter, Request
|
|
3
|
+
from fastapi.responses import JSONResponse
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/api/tts")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/status")
|
|
9
|
+
async def tts_status() -> dict:
|
|
10
|
+
"""Get TTS server status."""
|
|
11
|
+
from ziomek.server import _tts_model
|
|
12
|
+
ready = _tts_model is not None and _tts_model.ready # type: ignore[arg-type]
|
|
13
|
+
return {"ready": ready, "voices": ["cosette", "marius", "javert", "alba"]}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/voices")
|
|
17
|
+
async def list_voices() -> dict:
|
|
18
|
+
"""List available voices."""
|
|
19
|
+
return {
|
|
20
|
+
"voices": ["cosette", "marius", "javert", "alba", "jean", "anna", "vera", "fantine",
|
|
21
|
+
"charles", "paul", "eponine", "azelma", "george", "mary", "jane",
|
|
22
|
+
"michael", "eve", "bill_boerst", "peter_yearsley", "stuart_bell",
|
|
23
|
+
"caro_davy", "giovanni", "lola", "juergen", "rafael", "estelle"],
|
|
24
|
+
"default": "cosette",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/generate")
|
|
29
|
+
async def tts_generate(request: Request) -> dict:
|
|
30
|
+
"""Generate WAV audio from text."""
|
|
31
|
+
from ziomek.server import _tts_model, _voice_cache
|
|
32
|
+
if _tts_model is None or not _tts_model.ready:
|
|
33
|
+
return JSONResponse(status_code=503, content={"error": "TTS model not loaded"})
|
|
34
|
+
body = await request.json()
|
|
35
|
+
text = body.get("text", "")
|
|
36
|
+
voice = body.get("voice", "default")
|
|
37
|
+
from ziomek.tts.generate import generate_wav
|
|
38
|
+
audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
|
|
39
|
+
return {
|
|
40
|
+
"audio_b64": audio_b64,
|
|
41
|
+
"duration": duration,
|
|
42
|
+
"sample_rate": sample_rate,
|
|
43
|
+
"channels": 1,
|
|
44
|
+
"bit_depth": 16,
|
|
45
|
+
"voice": voice,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post("/speak")
|
|
50
|
+
async def tts_speak(request: Request) -> dict:
|
|
51
|
+
"""Generate and play audio."""
|
|
52
|
+
from ziomek.server import _tts_model, _voice_cache, _audio_player
|
|
53
|
+
if _tts_model is None or not _tts_model.ready:
|
|
54
|
+
return JSONResponse(status_code=503, content={"error": "TTS not ready"})
|
|
55
|
+
body = await request.json()
|
|
56
|
+
text = body.get("text", "")
|
|
57
|
+
voice = body.get("voice", "default")
|
|
58
|
+
from ziomek.tts.generate import generate_wav
|
|
59
|
+
audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
|
|
60
|
+
import base64
|
|
61
|
+
wav_bytes = base64.b64decode(audio_b64)
|
|
62
|
+
if _audio_player is not None: # type: ignore[arg-type]
|
|
63
|
+
_audio_player.play(wav_bytes, sample_rate)
|
|
64
|
+
return {
|
|
65
|
+
"status": "playing",
|
|
66
|
+
"duration": duration,
|
|
67
|
+
"sample_rate": sample_rate,
|
|
68
|
+
"channels": 1,
|
|
69
|
+
"bit_depth": 16,
|
|
70
|
+
"voice": voice,
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ziomek avatar package."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""ziomek avatar skin registry — ported from TypeScript."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Row index map: expression name → sprite sheet row
|
|
11
|
+
_ROW_MAP: dict[str, int] = {
|
|
12
|
+
"happy": 0,
|
|
13
|
+
"surprised": 1,
|
|
14
|
+
"curious": 2,
|
|
15
|
+
"looking": 2,
|
|
16
|
+
"thinking": 2,
|
|
17
|
+
"focused": 3,
|
|
18
|
+
"bored": 3,
|
|
19
|
+
"dying": 4,
|
|
20
|
+
"neutral": 1,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SingleFrameSkin:
|
|
26
|
+
"""A skin that uses a single frame per expression."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
_sprite_path: str # path to PNG file
|
|
30
|
+
_sprite_buffer: bytes | None = field(default=None, repr=False)
|
|
31
|
+
|
|
32
|
+
_sprite_width: int = 0
|
|
33
|
+
_sprite_height: int = 0
|
|
34
|
+
_frame_width: int = 0
|
|
35
|
+
_frame_height: int = 0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def sprite_path(self) -> str:
|
|
39
|
+
return self._sprite_path
|
|
40
|
+
|
|
41
|
+
def _parse_png(self) -> None:
|
|
42
|
+
"""Parse PNG header to extract dimensions and compute frame size."""
|
|
43
|
+
if not self._sprite_buffer:
|
|
44
|
+
return
|
|
45
|
+
# Width: bytes 16-19 (big-endian)
|
|
46
|
+
self._sprite_width = int.from_bytes(self._sprite_buffer[16:20], "big")
|
|
47
|
+
# Height: bytes 20-23 (big-endian)
|
|
48
|
+
self._sprite_height = int.from_bytes(self._sprite_buffer[20:24], "big")
|
|
49
|
+
# Auto-calculate rows from sprite ratio (single column)
|
|
50
|
+
rows = max(1, round(self._sprite_height / self._sprite_width))
|
|
51
|
+
self._frame_width = self._sprite_width
|
|
52
|
+
self._frame_height = self._sprite_height // rows
|
|
53
|
+
|
|
54
|
+
def _load_sprite(self) -> None:
|
|
55
|
+
"""Load sprite sheet from disk."""
|
|
56
|
+
if self._sprite_buffer is not None:
|
|
57
|
+
return
|
|
58
|
+
path = Path(self._sprite_path)
|
|
59
|
+
if path.is_file():
|
|
60
|
+
try:
|
|
61
|
+
with open(path, "rb") as f:
|
|
62
|
+
self._sprite_buffer = f.read()
|
|
63
|
+
self._parse_png()
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
logger.warning("Failed to load sprite %s: %s", self._sprite_path, exc)
|
|
66
|
+
|
|
67
|
+
def getFrameRow(self, expression_name: str, cycle_index: int) -> int:
|
|
68
|
+
"""Get the sprite sheet row index for an expression."""
|
|
69
|
+
return _ROW_MAP.get(expression_name, _ROW_MAP.get("neutral", 1))
|
|
70
|
+
|
|
71
|
+
def getFrameCount(self, expression_name: str) -> int:
|
|
72
|
+
"""Get number of frames for an expression."""
|
|
73
|
+
return 1 # single frame per expression
|
|
74
|
+
|
|
75
|
+
def getExpressionNames(self) -> list[str]:
|
|
76
|
+
return sorted(_ROW_MAP.keys())
|
|
77
|
+
|
|
78
|
+
def getSpriteBuffer(self) -> bytes:
|
|
79
|
+
self._load_sprite()
|
|
80
|
+
if self._sprite_buffer is None:
|
|
81
|
+
raise FileNotFoundError(f"Sprite sheet not found: {self._sprite_path}")
|
|
82
|
+
return self._sprite_buffer
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def spriteWidth(self) -> int:
|
|
86
|
+
return self._sprite_width
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def spriteHeight(self) -> int:
|
|
90
|
+
return self._sprite_height
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def frameWidth(self) -> int:
|
|
94
|
+
return self._frame_width
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def frameHeight(self) -> int:
|
|
98
|
+
return self._frame_height
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SkinRegistry:
|
|
102
|
+
"""Registry of avatar skins."""
|
|
103
|
+
|
|
104
|
+
_instance: SkinRegistry | None = None
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._skins: dict[str, SingleFrameSkin] = {}
|
|
108
|
+
self._register_default()
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def getInstance(cls) -> SkinRegistry:
|
|
112
|
+
if cls._instance is None:
|
|
113
|
+
cls._instance = cls()
|
|
114
|
+
return cls._instance
|
|
115
|
+
|
|
116
|
+
def _register_default(self) -> None:
|
|
117
|
+
default_path = str(Path(__file__).parent.parent / "resources" / "single_frame_sprite_sheet.png")
|
|
118
|
+
self._skins["single-frame"] = SingleFrameSkin(
|
|
119
|
+
name="single-frame",
|
|
120
|
+
_sprite_path=default_path,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def get(self, name: str) -> SingleFrameSkin | None:
|
|
124
|
+
return self._skins.get(name)
|
|
125
|
+
|
|
126
|
+
def list(self) -> list[str]:
|
|
127
|
+
return list(self._skins.keys())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""ziomek avatar sprite sheet parser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SpriteMetadata:
|
|
10
|
+
spriteWidth: int
|
|
11
|
+
spriteHeight: int
|
|
12
|
+
frameWidth: int
|
|
13
|
+
frameHeight: int
|
|
14
|
+
rows: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_png_sprite(data: bytes) -> SpriteMetadata:
|
|
18
|
+
"""Parse a PNG sprite sheet.
|
|
19
|
+
|
|
20
|
+
Extracts dimensions from the PNG header (bytes 16-23 for width/height),
|
|
21
|
+
then calculates frame count from the height/width ratio.
|
|
22
|
+
|
|
23
|
+
Same logic as avatar-panel.ts `_loadSprite()`.
|
|
24
|
+
"""
|
|
25
|
+
if len(data) < 24:
|
|
26
|
+
raise ValueError("PNG file too small")
|
|
27
|
+
if data[0] != 0x89 or data[1] != 0x50: # 'P' in PNG magic
|
|
28
|
+
raise ValueError("Not a valid PNG file")
|
|
29
|
+
|
|
30
|
+
sprite_width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]
|
|
31
|
+
sprite_height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]
|
|
32
|
+
|
|
33
|
+
rows = max(1, round(sprite_height / sprite_width))
|
|
34
|
+
frame_width = sprite_width // 1 # single column
|
|
35
|
+
frame_height = sprite_height // rows
|
|
36
|
+
|
|
37
|
+
return SpriteMetadata(
|
|
38
|
+
spriteWidth=sprite_width,
|
|
39
|
+
spriteHeight=sprite_height,
|
|
40
|
+
frameWidth=frame_width,
|
|
41
|
+
frameHeight=frame_height,
|
|
42
|
+
rows=rows,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sprite_to_base64(data: bytes) -> str:
|
|
47
|
+
import base64
|
|
48
|
+
return f"data:image/png;base64,{base64.b64encode(data).decode('utf-8')}"
|