Add docker setup and switch voice chat backend to use livekit

This commit is contained in:
2026-03-11 16:06:41 +01:00
parent b051a0a851
commit f54fff5fb5
24 changed files with 1362 additions and 232 deletions

View File

@@ -12,7 +12,7 @@ class ServerConfigSection(BaseModel):
"""Bind address and port options for websocket serving."""
bind_ip: str = "127.0.0.1"
port: int = 8765
port: int = 4474
base_path: str = "/"
grid_name: str = "Chat Grid"
welcome_message: str = (
@@ -65,6 +65,14 @@ class AuthConfigSection(BaseModel):
username_max_length: int = Field(default=32, ge=1)
class LiveKitConfigSection(BaseModel):
"""LiveKit SFU connection settings for audio transport."""
api_key: str = ""
api_secret: str = ""
url: str = ""
class AppConfig(BaseModel):
"""Top-level application configuration document."""
@@ -75,6 +83,7 @@ class AppConfig(BaseModel):
storage: StorageConfigSection = StorageConfigSection()
world: WorldConfigSection = WorldConfigSection()
auth: AuthConfigSection = AuthConfigSection()
livekit: LiveKitConfigSection = LiveKitConfigSection()
def load_config(path: Path | None) -> AppConfig:

View File

@@ -12,13 +12,6 @@ class BasePacket(BaseModel):
type: str
class SignalPacket(BasePacket):
type: Literal["signal"]
targetId: str
sdp: dict | None = None
ice: dict | None = None
class UpdatePositionPacket(BasePacket):
type: Literal["update_position"]
x: int
@@ -184,8 +177,7 @@ class ItemUpdatePacket(BasePacket):
ClientPacket = (
SignalPacket
| UpdatePositionPacket
UpdatePositionPacket
| TeleportCompletePacket
| UpdateNicknamePacket
| ChatMessagePacket
@@ -294,14 +286,10 @@ class BroadcastNicknamePacket(BasePacket):
nickname: str
class ForwardSignalPacket(BasePacket):
type: Literal["signal"]
senderId: str
senderNickname: str
x: int
y: int
sdp: dict | None = None
ice: dict | None = None
class LiveKitTokenPacket(BasePacket):
type: Literal["livekit_token"]
token: str
url: str
class BroadcastChatMessagePacket(BasePacket):

View File

@@ -74,7 +74,7 @@ from .models import (
BroadcastTeleportCompletePacket,
ChatMessagePacket,
ClientPacket,
ForwardSignalPacket,
LiveKitTokenPacket,
ItemActionResultPacket,
ItemAddPacket,
ItemClockAnnouncePacket,
@@ -170,6 +170,9 @@ class SignalingServer:
"Welcome to the Chat Grid, your immersive audio playground. "
"Configure your audio, then Log in or register to join the grid."
),
livekit_api_key: str = "",
livekit_api_secret: str = "",
livekit_url: str = "",
):
"""Initialize runtime state, TLS context, and item service."""
@@ -224,6 +227,30 @@ class SignalingServer:
self._clock_alarm_markers: dict[str, str] = {}
self._started_at_monotonic = time.monotonic()
self._pending_reboot_task: asyncio.Task[None] | None = None
self.livekit_api_key = livekit_api_key.strip()
self.livekit_api_secret = livekit_api_secret.strip()
self.livekit_url = livekit_url.strip()
@property
def livekit_enabled(self) -> bool:
return bool(self.livekit_api_key and self.livekit_api_secret and self.livekit_url)
def _generate_livekit_token(self, client: "ClientConnection") -> str:
"""Generate a LiveKit access token for the given client."""
from livekit.api import AccessToken, VideoGrants
token = (
AccessToken(self.livekit_api_key, self.livekit_api_secret)
.with_identity(client.id)
.with_name(client.nickname)
.with_grants(VideoGrants(
room_join=True,
room="chatgrid",
can_publish=self._client_has_permission(client, "voice.send"),
can_subscribe=True,
))
)
return token.to_jwt()
@staticmethod
def _resolve_server_version(release_version: str) -> str:
@@ -1591,6 +1618,15 @@ class SignalingServer:
},
)
await self._send(client.websocket, packet)
if self.livekit_enabled:
await self._send(
client.websocket,
LiveKitTokenPacket(
type="livekit_token",
token=self._generate_livekit_token(client),
url=self.livekit_url,
),
)
async def _send_authenticated_welcome(self, client: ClientConnection) -> None:
"""Prepare authenticated client state and send welcome before world activation."""
@@ -3104,26 +3140,6 @@ class SignalingServer:
await self._send_item_result(client, True, "update", f"Updated {item.title}.", item.id)
return
if not self._client_has_permission(client, "voice.send"):
return
target = self._find_by_id(packet.targetId)
if not target:
PACKET_LOGGER.info("signal target not found sender=%s target=%s", client.id, packet.targetId)
return
await self._send(
target.websocket,
ForwardSignalPacket(
type="signal",
senderId=client.id,
senderNickname=client.nickname,
x=client.x,
y=client.y,
sdp=packet.sdp,
ice=packet.ice,
),
)
async def _broadcast(self, packet: object, exclude: ServerConnection | None = None) -> None:
"""Broadcast one packet to all clients except an optional websocket."""
@@ -3344,5 +3360,8 @@ def run() -> None:
base_path=config.server.base_path,
grid_name=config.server.grid_name,
welcome_message=config.server.welcome_message,
livekit_api_key=os.getenv("LIVEKIT_API_KEY", "").strip() or config.livekit.api_key,
livekit_api_secret=os.getenv("LIVEKIT_API_SECRET", "").strip() or config.livekit.api_secret,
livekit_url=os.getenv("LIVEKIT_URL", "").strip() or config.livekit.url,
)
asyncio.run(server.start())