From 667c8941f25c81801ed27501ca0f5dadf4ed9192 Mon Sep 17 00:00:00 2001 From: mkbk Date: Wed, 17 Jun 2026 18:31:55 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=A1=8C=E5=AE=A0=20UI=20=E4=B8=8E=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E8=B5=84=E4=BA=A7]=EF=BC=9A=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=A1=8C=E5=AE=A0=E7=8A=B6=E6=80=81=E5=B1=95=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=86=85=E9=80=8F=E6=98=8E=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E5=90=AB=20UI=20=E7=8A=B6=E6=80=81=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E3=80=81=E6=97=A0=20GUI=20fallback=E3=80=81PNG=20?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=94=9F=E6=88=90=E8=84=9A=E6=9C=AC=E5=92=8C?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/pet/manifest.json | 11 ++ assets/pet/pet-error.png | Bin 0 -> 7603 bytes assets/pet/pet-idle.png | Bin 0 -> 7532 bytes assets/pet/pet-listening.png | Bin 0 -> 7871 bytes assets/pet/pet-recording.png | Bin 0 -> 7988 bytes assets/pet/pet-speaking.png | Bin 0 -> 7977 bytes assets/pet/pet-thinking.png | Bin 0 -> 8673 bytes .../changes/add-voice-pet-pipeline/design.md | 3 +- .../add-voice-pet-pipeline/proposal.md | 4 +- .../specs/voice-pet-pipeline/spec.md | 12 +- .../changes/add-voice-pet-pipeline/tasks.md | 10 +- scripts/generate_pet_assets.py | 133 ++++++++++++++++++ src/owner_voice_pet/__init__.py | 6 + src/owner_voice_pet/assets.py | 118 ++++++++++++++++ src/owner_voice_pet/ui.py | 56 ++++++++ tests/test_ui_assets.py | 31 ++++ 16 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 assets/pet/manifest.json create mode 100644 assets/pet/pet-error.png create mode 100644 assets/pet/pet-idle.png create mode 100644 assets/pet/pet-listening.png create mode 100644 assets/pet/pet-recording.png create mode 100644 assets/pet/pet-speaking.png create mode 100644 assets/pet/pet-thinking.png create mode 100644 scripts/generate_pet_assets.py create mode 100644 src/owner_voice_pet/assets.py create mode 100644 src/owner_voice_pet/ui.py create mode 100644 tests/test_ui_assets.py diff --git a/assets/pet/manifest.json b/assets/pet/manifest.json new file mode 100644 index 0000000..55fdc48 --- /dev/null +++ b/assets/pet/manifest.json @@ -0,0 +1,11 @@ +{ + "generated_by": "scripts/generate_pet_assets.py", + "states": { + "idle": "pet-idle.png", + "listening": "pet-listening.png", + "recording": "pet-recording.png", + "thinking": "pet-thinking.png", + "speaking": "pet-speaking.png", + "error": "pet-error.png" + } +} diff --git a/assets/pet/pet-error.png b/assets/pet/pet-error.png new file mode 100644 index 0000000000000000000000000000000000000000..9fcb8cc3fd6aafc7700e5710d0ad64f176b657c5 GIT binary patch literal 7603 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 ZD9FX?!_2j4#c4R$<-GTwLPy_A{|lD{8khh8 literal 0 HcmV?d00001 diff --git a/assets/pet/pet-speaking.png b/assets/pet/pet-speaking.png new file mode 100644 index 0000000000000000000000000000000000000000..2d91167a4f03b1f5d3c833f9d017b8fab6958e66 GIT binary patch literal 7977 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 CZ?dKU literal 0 HcmV?d00001 diff --git a/assets/pet/pet-thinking.png b/assets/pet/pet-thinking.png new file mode 100644 index 0000000000000000000000000000000000000000..e9dd1c6355de12b63bb2fe461885ed8f12cd79f4 GIT binary patch literal 8673 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 tu$%;rc!u1u&>9woVB!DY4-q&=0fHJRi-PRu tuple[int, int, int, int]: + sr, sg, sb, sa = src + dr, dg, db, da = dst + alpha = sa / 255 + out_a = int(sa + da * (1 - alpha)) + if out_a == 0: + return 0, 0, 0, 0 + return ( + int((sr * alpha + dr * (da / 255) * (1 - alpha)) / (out_a / 255)), + int((sg * alpha + dg * (da / 255) * (1 - alpha)) / (out_a / 255)), + int((sb * alpha + db * (da / 255) * (1 - alpha)) / (out_a / 255)), + out_a, + ) + + +def circle(pixels: list[list[tuple[int, int, int, int]]], cx: int, cy: int, radius: int, color: tuple[int, int, int, int]) -> None: + for y in range(max(0, cy - radius), min(HEIGHT, cy + radius + 1)): + for x in range(max(0, cx - radius), min(WIDTH, cx + radius + 1)): + dist = math.hypot(x - cx, y - cy) + if dist <= radius: + shade = max(0.72, 1 - dist / (radius * 2.8)) + shaded = (int(color[0] * shade), int(color[1] * shade), int(color[2] * shade), color[3]) + pixels[y][x] = blend(pixels[y][x], shaded) + + +def line_circle(pixels: list[list[tuple[int, int, int, int]]], cx: int, cy: int, radius: int, color: tuple[int, int, int, int]) -> None: + for y in range(max(0, cy - radius - 2), min(HEIGHT, cy + radius + 3)): + for x in range(max(0, cx - radius - 2), min(WIDTH, cx + radius + 3)): + dist = math.hypot(x - cx, y - cy) + if radius - 2 <= dist <= radius + 2: + pixels[y][x] = blend(pixels[y][x], color) + + +def rect(pixels: list[list[tuple[int, int, int, int]]], x0: int, y0: int, x1: int, y1: int, color: tuple[int, int, int, int]) -> None: + for y in range(max(0, y0), min(HEIGHT, y1)): + for x in range(max(0, x0), min(WIDTH, x1)): + pixels[y][x] = blend(pixels[y][x], color) + + +def draw_pet(state: str) -> list[list[tuple[int, int, int, int]]]: + pixels = [[(0, 0, 0, 0) for _ in range(WIDTH)] for _ in range(HEIGHT)] + accent = { + "idle": (78, 142, 154, 255), + "listening": (60, 122, 214, 255), + "recording": (224, 90, 64, 255), + "thinking": (142, 96, 210, 255), + "speaking": (44, 165, 108, 255), + "error": (220, 70, 70, 255), + }[state] + body = (236, 230, 210, 255) + shadow = (70, 82, 86, 90) + circle(pixels, 128, 142, 74, shadow) + circle(pixels, 128, 132, 70, body) + circle(pixels, 86, 112, 20, accent) + circle(pixels, 170, 112, 20, accent) + circle(pixels, 103, 132, 9, (35, 44, 48, 255)) + circle(pixels, 153, 132, 9, (35, 44, 48, 255)) + circle(pixels, 100, 129, 3, (255, 255, 255, 220)) + circle(pixels, 150, 129, 3, (255, 255, 255, 220)) + rect(pixels, 92, 190, 111, 207, accent) + rect(pixels, 145, 190, 164, 207, accent) + if state == "speaking": + circle(pixels, 128, 158, 15, (45, 48, 52, 255)) + circle(pixels, 128, 163, 7, (255, 130, 150, 255)) + line_circle(pixels, 198, 132, 18, accent) + line_circle(pixels, 205, 132, 30, accent) + elif state == "thinking": + circle(pixels, 128, 158, 12, (45, 48, 52, 255)) + rect(pixels, 116, 153, 140, 160, body) + circle(pixels, 180, 70, 7, accent) + circle(pixels, 197, 52, 11, accent) + circle(pixels, 216, 35, 15, accent) + elif state == "listening": + rect(pixels, 116, 157, 140, 163, (45, 48, 52, 255)) + line_circle(pixels, 58, 132, 18, accent) + line_circle(pixels, 51, 132, 30, accent) + elif state == "recording": + circle(pixels, 128, 160, 11, (45, 48, 52, 255)) + circle(pixels, 197, 58, 13, accent) + elif state == "error": + rect(pixels, 124, 149, 132, 168, (45, 48, 52, 255)) + circle(pixels, 128, 178, 4, (45, 48, 52, 255)) + rect(pixels, 190, 50, 198, 84, accent) + circle(pixels, 194, 96, 5, accent) + else: + rect(pixels, 114, 157, 142, 162, (45, 48, 52, 255)) + return pixels + + +def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + +def save_png(path: Path, pixels: list[list[tuple[int, int, int, int]]]) -> None: + raw = bytearray() + for row in pixels: + raw.append(0) + for rgba in row: + raw.extend(bytes(rgba)) + data = b"\x89PNG\r\n\x1a\n" + data += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 6, 0, 0, 0)) + data += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + data += chunk(b"IEND", b"") + path.write_bytes(data) + + +def main() -> int: + root = Path("assets/pet") + root.mkdir(parents=True, exist_ok=True) + manifest = {"generated_by": "scripts/generate_pet_assets.py", "states": {}} + for state in STATES: + name = f"pet-{state}.png" + save_png(root / name, draw_pet(state)) + manifest["states"][state] = name + (root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/owner_voice_pet/__init__.py b/src/owner_voice_pet/__init__.py index 1f5cd7c..b44f566 100644 --- a/src/owner_voice_pet/__init__.py +++ b/src/owner_voice_pet/__init__.py @@ -23,6 +23,8 @@ from .conversation import ConversationContext from .llm import MockLlmProvider, OpenAICompatibleLlmProvider from .pipeline import PipelineResult, VoicePipeline from .tts import MacSayTtsProvider, SentenceBuffer, SineTtsProvider +from .assets import validate_pet_assets +from .ui import ConsolePetWindow, PetStateController, PetVisualState __all__ = [ "AppConfig", @@ -45,6 +47,10 @@ __all__ = [ "MacSayTtsProvider", "SentenceBuffer", "SineTtsProvider", + "validate_pet_assets", + "ConsolePetWindow", + "PetStateController", + "PetVisualState", "Message", "PipelineState", "PlaybackResult", diff --git a/src/owner_voice_pet/assets.py b/src/owner_voice_pet/assets.py new file mode 100644 index 0000000..89b36fa --- /dev/null +++ b/src/owner_voice_pet/assets.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import struct +import zlib +from dataclasses import dataclass +from pathlib import Path + +from .models import ErrorCode, ProviderError +from .ui import PetVisualState + +REQUIRED_ASSET_STATES = tuple(state.value for state in PetVisualState) + + +@dataclass(frozen=True, slots=True) +class PngInfo: + width: int + height: int + color_type: int + has_transparency: bool + + +def load_asset_manifest(asset_dir: Path) -> dict[str, str]: + manifest_path = asset_dir / "manifest.json" + if not manifest_path.exists(): + raise ProviderError( + ErrorCode.ASSET_MISSING, + f"asset manifest is missing: {manifest_path}", + False, + "pet-assets", + "assets", + ) + with manifest_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + states = data.get("states", {}) + if not isinstance(states, dict): + raise ProviderError( + ErrorCode.VALIDATION_FAILED, + "asset manifest states must be an object", + False, + "pet-assets", + "assets", + ) + return {str(key): str(value) for key, value in states.items()} + + +def validate_pet_assets(asset_dir: str | Path) -> list[PngInfo]: + root = Path(asset_dir) + states = load_asset_manifest(root) + missing = [state for state in REQUIRED_ASSET_STATES if state not in states] + if missing: + raise ProviderError( + ErrorCode.ASSET_MISSING, + f"asset manifest missing states: {', '.join(missing)}", + False, + "pet-assets", + "assets", + ) + infos: list[PngInfo] = [] + for state in REQUIRED_ASSET_STATES: + path = root / states[state] + if not path.exists(): + raise ProviderError( + ErrorCode.ASSET_MISSING, + f"asset file missing for {state}: {path}", + False, + "pet-assets", + "assets", + ) + info = read_png_info(path) + if info.color_type != 6 or not info.has_transparency: + raise ProviderError( + ErrorCode.VALIDATION_FAILED, + f"asset must be RGBA with transparent pixels: {path}", + False, + "pet-assets", + "assets", + ) + infos.append(info) + return infos + + +def read_png_info(path: str | Path) -> PngInfo: + data = Path(path).read_bytes() + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"not a PNG file: {path}") + offset = 8 + width = height = color_type = -1 + idat = bytearray() + while offset < len(data): + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + chunk_type = data[offset : offset + 4] + offset += 4 + chunk_data = data[offset : offset + length] + offset += length + 4 + if chunk_type == b"IHDR": + width, height, _bit_depth, color_type, *_ = struct.unpack(">IIBBBBB", chunk_data) + elif chunk_type == b"IDAT": + idat.extend(chunk_data) + elif chunk_type == b"IEND": + break + has_transparency = False + if color_type == 6 and width > 0 and height > 0 and idat: + raw = zlib.decompress(bytes(idat)) + stride = width * 4 + pos = 0 + for _ in range(height): + filter_type = raw[pos] + pos += 1 + if filter_type != 0: + raise ValueError("unsupported PNG filter in generated asset") + row = raw[pos : pos + stride] + pos += stride + if any(row[idx] < 255 for idx in range(3, len(row), 4)): + has_transparency = True + break + return PngInfo(width, height, color_type, has_transparency) diff --git a/src/owner_voice_pet/ui.py b/src/owner_voice_pet/ui.py new file mode 100644 index 0000000..d3ae103 --- /dev/null +++ b/src/owner_voice_pet/ui.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from .models import PipelineState + + +class PetVisualState(str, Enum): + IDLE = "idle" + LISTENING = "listening" + RECORDING = "recording" + THINKING = "thinking" + SPEAKING = "speaking" + ERROR = "error" + + +STATE_MAP: dict[PipelineState, PetVisualState] = { + PipelineState.IDLE: PetVisualState.IDLE, + PipelineState.WAKE_LISTENING: PetVisualState.IDLE, + PipelineState.SPEECH_DETECTING: PetVisualState.LISTENING, + PipelineState.RECORDING: PetVisualState.RECORDING, + PipelineState.TRANSCRIBING: PetVisualState.THINKING, + PipelineState.THINKING: PetVisualState.THINKING, + PipelineState.SPEAKING: PetVisualState.SPEAKING, + PipelineState.INTERRUPTED: PetVisualState.ERROR, + PipelineState.ERROR_RECOVERING: PetVisualState.ERROR, +} + + +@dataclass(slots=True) +class PetStateController: + current: PetVisualState = PetVisualState.IDLE + + def apply_pipeline_state(self, state: PipelineState) -> PetVisualState: + self.current = STATE_MAP[state] + return self.current + + +class ConsolePetWindow: + def __init__(self, controller: PetStateController | None = None) -> None: + self.controller = controller or PetStateController() + self.history: list[PetVisualState] = [] + + def update(self, state: PipelineState) -> PetVisualState: + visual = self.controller.apply_pipeline_state(state) + self.history.append(visual) + return visual + + +class PySidePetWindow: + def __init__(self) -> None: + try: + import PySide6 # type: ignore[import-not-found] # noqa: F401 + except Exception as exc: # pragma: no cover - optional GUI path + raise RuntimeError("PySide6 is not installed; use ConsolePetWindow fallback") from exc diff --git a/tests/test_ui_assets.py b/tests/test_ui_assets.py new file mode 100644 index 0000000..b73d977 --- /dev/null +++ b/tests/test_ui_assets.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from owner_voice_pet.assets import REQUIRED_ASSET_STATES, validate_pet_assets +from owner_voice_pet.models import PipelineState +from owner_voice_pet.ui import ConsolePetWindow, PetStateController, PetVisualState + + +class UiAssetTests(unittest.TestCase): + def test_pipeline_state_maps_to_visual_state(self) -> None: + controller = PetStateController() + self.assertEqual(controller.apply_pipeline_state(PipelineState.SPEECH_DETECTING), PetVisualState.LISTENING) + self.assertEqual(controller.apply_pipeline_state(PipelineState.THINKING), PetVisualState.THINKING) + self.assertEqual(controller.apply_pipeline_state(PipelineState.ERROR_RECOVERING), PetVisualState.ERROR) + + def test_console_window_fallback_records_history(self) -> None: + window = ConsolePetWindow() + self.assertEqual(window.update(PipelineState.SPEAKING), PetVisualState.SPEAKING) + self.assertEqual(window.history, [PetVisualState.SPEAKING]) + + def test_project_pet_assets_are_rgba_and_complete(self) -> None: + infos = validate_pet_assets(Path("assets/pet")) + self.assertEqual(len(infos), len(REQUIRED_ASSET_STATES)) + self.assertTrue(all(info.width == 256 and info.height == 256 for info in infos)) + self.assertTrue(all(info.has_transparency for info in infos)) + + +if __name__ == "__main__": + unittest.main()