bevyra-avatar 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.
- bevyra_avatar-0.1.0/.gitignore +3 -0
- bevyra_avatar-0.1.0/PKG-INFO +7 -0
- bevyra_avatar-0.1.0/pyproject.toml +19 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/__init__.py +3 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/player.py +139 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/player.html +20 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/player.js +61 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/bevyra-core-Bskcl7l_.js +195 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/clientOnlyAvatarWorker-C6wTIDwp.js +2119 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/clientOnlySourceWorker-CNVdGzNy.js +7347 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/ort.wasm.min-BcBtKW3v.js +1400 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/ort.webgpu.min-BqBLsdhU.js +1856 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/rustAvatarWorker-vggdUXxI.js +1702 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/teacherCorrectorWorker-Deu0CCPs.js +1406 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/assets/workerHelpers-BkWezgrb.js +31 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/bevyra-avatar-sdk.js +3367 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/ort/ort-wasm-simd-threaded.jsep.mjs +136 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/ort/ort-wasm-simd-threaded.mjs +68 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/ort/ort-wasm-simd-threaded.wasm +0 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/runtime/bevyra-core.js +1515 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/runtime/bevyra-core.wasm +0 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/runtime/closure_router.onnx +0 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/runtime/runtime-manifest.json +27 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/dist/sdk-manifest.json +70 -0
- bevyra_avatar-0.1.0/src/bevyra_avatar/static/sdk/index.js +108 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bevyra-avatar"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local browser avatar player controlled from Python"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = ["aiohttp>=3.10,<4"]
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["src/bevyra_avatar"]
|
|
15
|
+
ignore-vcs = true
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.sdist]
|
|
18
|
+
include = ["src/bevyra_avatar/**", "pyproject.toml"]
|
|
19
|
+
ignore-vcs = true
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""A loopback control bridge for the bundled browser avatar renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import secrets
|
|
7
|
+
import struct
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from aiohttp import WSMsgType, web
|
|
13
|
+
|
|
14
|
+
_STATIC = Path(__file__).resolve().parent / "static"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AvatarWindow:
|
|
18
|
+
"""Render locally in a browser while Python supplies PCM16 speech audio."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
avatar_id: str,
|
|
23
|
+
bundle_dir: str | Path,
|
|
24
|
+
*,
|
|
25
|
+
on_event: Callable[[dict], None] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.avatar_id = avatar_id
|
|
28
|
+
self.bundle_dir = Path(bundle_dir).resolve()
|
|
29
|
+
if not (self.bundle_dir / "web-bundle.json").is_file():
|
|
30
|
+
raise ValueError(f"portable_v2 bundle missing: {self.bundle_dir}")
|
|
31
|
+
if not (_STATIC / "sdk" / "index.js").is_file():
|
|
32
|
+
raise RuntimeError("Browser renderer assets are missing from this Python installation")
|
|
33
|
+
self.on_event = on_event
|
|
34
|
+
self._runner: web.AppRunner | None = None
|
|
35
|
+
self._socket: web.WebSocketResponse | None = None
|
|
36
|
+
self._ready = asyncio.Event()
|
|
37
|
+
self._token = secrets.token_urlsafe(32)
|
|
38
|
+
self.url: str | None = None
|
|
39
|
+
|
|
40
|
+
async def start(self, *, open_browser: bool = True, timeout: float = 90) -> str:
|
|
41
|
+
if self._runner is not None:
|
|
42
|
+
raise RuntimeError("AvatarWindow has already started")
|
|
43
|
+
app = web.Application()
|
|
44
|
+
app.router.add_get("/", self._index)
|
|
45
|
+
app.router.add_get("/player.js", self._player_script)
|
|
46
|
+
app.router.add_get("/ws", self._websocket)
|
|
47
|
+
app.router.add_static("/sdk/", _STATIC / "sdk")
|
|
48
|
+
app.router.add_static("/avatar/", self.bundle_dir)
|
|
49
|
+
runner = web.AppRunner(app)
|
|
50
|
+
await runner.setup()
|
|
51
|
+
try:
|
|
52
|
+
site = web.TCPSite(runner, "127.0.0.1", 0)
|
|
53
|
+
await site.start()
|
|
54
|
+
port = site._server.sockets[0].getsockname()[1]
|
|
55
|
+
self._runner = runner
|
|
56
|
+
self.url = f"http://127.0.0.1:{port}/?token={self._token}"
|
|
57
|
+
if open_browser:
|
|
58
|
+
webbrowser.open(self.url)
|
|
59
|
+
await asyncio.wait_for(self._ready.wait(), timeout)
|
|
60
|
+
return self.url
|
|
61
|
+
except BaseException:
|
|
62
|
+
await runner.cleanup()
|
|
63
|
+
self._runner = None
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
def _headers(self, response: web.StreamResponse) -> web.StreamResponse:
|
|
67
|
+
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
|
68
|
+
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
|
|
69
|
+
response.headers["Cache-Control"] = "no-store"
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
async def _index(self, request: web.Request) -> web.StreamResponse:
|
|
73
|
+
response = web.FileResponse(_STATIC / "player.html")
|
|
74
|
+
return self._headers(response)
|
|
75
|
+
|
|
76
|
+
async def _player_script(self, request: web.Request) -> web.StreamResponse:
|
|
77
|
+
response = web.FileResponse(_STATIC / "player.js")
|
|
78
|
+
return self._headers(response)
|
|
79
|
+
|
|
80
|
+
async def _websocket(self, request: web.Request) -> web.StreamResponse:
|
|
81
|
+
if request.query.get("token") != self._token:
|
|
82
|
+
raise web.HTTPForbidden()
|
|
83
|
+
expected_origin = f"http://{request.host}"
|
|
84
|
+
if request.headers.get("Origin") != expected_origin:
|
|
85
|
+
raise web.HTTPForbidden()
|
|
86
|
+
if self._socket is not None and not self._socket.closed:
|
|
87
|
+
raise web.HTTPConflict(text="Avatar already connected")
|
|
88
|
+
ws = web.WebSocketResponse(heartbeat=20, max_msg_size=4096)
|
|
89
|
+
await ws.prepare(request)
|
|
90
|
+
self._socket = ws
|
|
91
|
+
await ws.send_json({"type": "configure", "avatar_id": self.avatar_id})
|
|
92
|
+
try:
|
|
93
|
+
async for message in ws:
|
|
94
|
+
if message.type is WSMsgType.TEXT:
|
|
95
|
+
data = message.json()
|
|
96
|
+
if data.get("type") == "ready":
|
|
97
|
+
self._ready.set()
|
|
98
|
+
if self.on_event:
|
|
99
|
+
self.on_event(data)
|
|
100
|
+
finally:
|
|
101
|
+
self._ready.clear()
|
|
102
|
+
if self._socket is ws:
|
|
103
|
+
self._socket = None
|
|
104
|
+
return ws
|
|
105
|
+
|
|
106
|
+
async def _send(self, payload: dict | bytes) -> None:
|
|
107
|
+
ws = self._socket
|
|
108
|
+
if ws is None or ws.closed or not self._ready.is_set():
|
|
109
|
+
raise RuntimeError("Avatar player is not ready")
|
|
110
|
+
if isinstance(payload, bytes):
|
|
111
|
+
await ws.send_bytes(payload)
|
|
112
|
+
else:
|
|
113
|
+
await ws.send_json(payload)
|
|
114
|
+
|
|
115
|
+
async def write_audio(self, pcm16: bytes, sample_rate: int = 16000) -> None:
|
|
116
|
+
if not 8000 <= sample_rate <= 96000 or len(pcm16) % 2:
|
|
117
|
+
raise ValueError("Expected even-length signed PCM16 bytes and a valid sample rate")
|
|
118
|
+
await self._send(struct.pack("<I", sample_rate) + pcm16)
|
|
119
|
+
|
|
120
|
+
async def end_utterance(self) -> None:
|
|
121
|
+
await self._send({"type": "end"})
|
|
122
|
+
|
|
123
|
+
async def interrupt(self) -> None:
|
|
124
|
+
await self._send({"type": "interrupt"})
|
|
125
|
+
|
|
126
|
+
async def close(self) -> None:
|
|
127
|
+
if self._socket is not None:
|
|
128
|
+
await self._socket.close()
|
|
129
|
+
if self._runner is not None:
|
|
130
|
+
await self._runner.cleanup()
|
|
131
|
+
self._runner = None
|
|
132
|
+
self._ready.clear()
|
|
133
|
+
|
|
134
|
+
async def __aenter__(self) -> "AvatarWindow":
|
|
135
|
+
await self.start()
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
async def __aexit__(self, *_: object) -> None:
|
|
139
|
+
await self.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>BeVyra Avatar</title>
|
|
7
|
+
<style>
|
|
8
|
+
html,body{margin:0;width:100%;height:100%;background:transparent}
|
|
9
|
+
canvas{display:block;width:100%;height:100%;object-fit:contain}
|
|
10
|
+
#start{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:1em 2em}
|
|
11
|
+
#status{position:absolute;left:1em;bottom:1em;color:white;background:#222a;padding:.4em}
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<canvas id="avatar"></canvas>
|
|
16
|
+
<button id="start" disabled>Start avatar</button>
|
|
17
|
+
<div id="status" role="status">Connecting to Python…</div>
|
|
18
|
+
<script type="module" src="/player.js"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BevyraAvatar } from "/sdk/index.js";
|
|
2
|
+
|
|
3
|
+
const status = document.getElementById("status");
|
|
4
|
+
const start = document.getElementById("start");
|
|
5
|
+
const canvas = document.getElementById("avatar");
|
|
6
|
+
const token = new URL(location.href).searchParams.get("token");
|
|
7
|
+
if (!token) throw new Error("Missing session token");
|
|
8
|
+
const socket = new WebSocket(`ws://${location.host}/ws?token=${encodeURIComponent(token)}`);
|
|
9
|
+
socket.binaryType = "arraybuffer";
|
|
10
|
+
let avatar = null;
|
|
11
|
+
let avatarId = null;
|
|
12
|
+
let pending = Promise.resolve();
|
|
13
|
+
let generation = 0;
|
|
14
|
+
const send = (value) => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify(value));
|
|
15
|
+
const enqueue = (task) => {
|
|
16
|
+
const current = generation;
|
|
17
|
+
pending = pending.catch(() => undefined).then(() => {
|
|
18
|
+
if (current === generation) return task();
|
|
19
|
+
}).catch((error) => send({ type: "error", message: String(error) }));
|
|
20
|
+
};
|
|
21
|
+
socket.onmessage = async (event) => {
|
|
22
|
+
if (typeof event.data === "string") {
|
|
23
|
+
const command = JSON.parse(event.data);
|
|
24
|
+
if (command.type === "configure") {
|
|
25
|
+
avatarId = command.avatar_id;
|
|
26
|
+
start.disabled = false;
|
|
27
|
+
status.textContent = "Click to start avatar";
|
|
28
|
+
} else if (command.type === "end") {
|
|
29
|
+
enqueue(() => avatar.endUtterance());
|
|
30
|
+
} else if (command.type === "interrupt") {
|
|
31
|
+
generation++;
|
|
32
|
+
avatar?.interrupt();
|
|
33
|
+
pending = Promise.resolve();
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const data = new DataView(event.data);
|
|
38
|
+
const sampleRate = data.getUint32(0, true);
|
|
39
|
+
const pcm = new Int16Array(event.data.slice(4));
|
|
40
|
+
enqueue(() => avatar.writeAudio(pcm, sampleRate));
|
|
41
|
+
};
|
|
42
|
+
start.onclick = async () => {
|
|
43
|
+
start.disabled = true;
|
|
44
|
+
status.textContent = "Loading avatar…";
|
|
45
|
+
try {
|
|
46
|
+
avatar = await BevyraAvatar.mount({
|
|
47
|
+
avatarId, canvas, bundleBaseUrl: "/avatar/", assetsBaseUrl: "/sdk/dist/",
|
|
48
|
+
onStage: (stage) => { status.textContent = stage.replaceAll("_", " "); },
|
|
49
|
+
});
|
|
50
|
+
avatar.onSpeechStart(() => send({ type: "speech_start" }));
|
|
51
|
+
avatar.onSpeechEnd(() => send({ type: "speech_end" }));
|
|
52
|
+
status.remove();
|
|
53
|
+
start.remove();
|
|
54
|
+
send({ type: "ready" });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
status.textContent = String(error);
|
|
57
|
+
start.disabled = false;
|
|
58
|
+
send({ type: "error", message: String(error) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
socket.onclose = () => { avatar?.close(); status.textContent = "Python connection closed"; };
|