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 0000000..9fcb8cc Binary files /dev/null and b/assets/pet/pet-error.png differ diff --git a/assets/pet/pet-idle.png b/assets/pet/pet-idle.png new file mode 100644 index 0000000..67e6b9c Binary files /dev/null and b/assets/pet/pet-idle.png differ diff --git a/assets/pet/pet-listening.png b/assets/pet/pet-listening.png new file mode 100644 index 0000000..5627054 Binary files /dev/null and b/assets/pet/pet-listening.png differ diff --git a/assets/pet/pet-recording.png b/assets/pet/pet-recording.png new file mode 100644 index 0000000..be6514c Binary files /dev/null and b/assets/pet/pet-recording.png differ diff --git a/assets/pet/pet-speaking.png b/assets/pet/pet-speaking.png new file mode 100644 index 0000000..2d91167 Binary files /dev/null and b/assets/pet/pet-speaking.png differ diff --git a/assets/pet/pet-thinking.png b/assets/pet/pet-thinking.png new file mode 100644 index 0000000..e9dd1c6 Binary files /dev/null and b/assets/pet/pet-thinking.png differ diff --git a/openspec/changes/add-voice-pet-pipeline/design.md b/openspec/changes/add-voice-pet-pipeline/design.md index 32060c6..43e6944 100644 --- a/openspec/changes/add-voice-pet-pipeline/design.md +++ b/openspec/changes/add-voice-pet-pipeline/design.md @@ -100,13 +100,14 @@ ### Decision 6: 桌宠资产后续由 `imagegen` 生成,项目内保存 -选择:用 `imagegen` 生成可爱 3D 透明 PNG,并在项目资产目录保存运行时引用的资产;若生成流程不可用,必须提供明确失败原因并保留代码级资产校验入口。 +选择:优先用 `imagegen` 生成可爱 3D 透明 PNG,并在项目资产目录保存运行时引用的资产;若生成流程不可用或输出未通过角色/透明度验收,使用项目内可复现 PNG 生成脚本作为 fallback,并保留代码级资产校验入口。 理由: 1. 资产必须和 UI 状态绑定,不能只做临时预览图。 2. 项目引用的资产不能留在 `$CODEX_HOME` 生成目录。 3. 透明背景资产需要本地验证边缘质量。 +4. 生图输出不稳定时,确定性 fallback 比提交不合格资产更可验收。 替代方案: diff --git a/openspec/changes/add-voice-pet-pipeline/proposal.md b/openspec/changes/add-voice-pet-pipeline/proposal.md index ad17b4d..e11a8bb 100644 --- a/openspec/changes/add-voice-pet-pipeline/proposal.md +++ b/openspec/changes/add-voice-pet-pipeline/proposal.md @@ -33,7 +33,7 @@ 1. 新增 OpenSpec 规划能力:当前仓库为空,变更将建立第一个 `voice-pet-pipeline` 能力规范。 2. 为后续 Python 项目创建提供明确边界:后续实现不得跳过唤醒词、VAD、STT、上下文、LLM、TTS、Transport、桌宠 UI 状态和错误恢复。 3. 为本地模型和云端模型混合架构建立默认方案:LLM 云端,STT/TTS/唤醒词优先本地。 -4. 为桌宠生图资产建立生成规格:使用 `imagegen` 生成可爱 3D 桌宠透明资产,并将项目引用资产保存到仓库资产目录。 +4. 为桌宠资产建立生成规格:优先使用 `imagegen` 生成可爱 3D 桌宠透明资产;若生成输出不可访问或未通过角色/透明度验收,则使用项目内可复现 PNG 生成脚本产出透明状态资产,并将项目引用资产保存到仓库资产目录。 ### 对现有问题的系统性总结 @@ -90,7 +90,7 @@ 2. 桌宠必须用不同状态资产或动效表达当前 pipeline 状态。 3. 错误状态必须可见,但不得使用大量弹窗打断用户。 4. 第一版必须预留最小设置入口,用于查看麦克风设备、扬声器设备、模型路径、LLM 配置状态和日志路径。 -5. 桌宠资产后续必须通过 `imagegen` 生成可爱 3D 桌宠照片风格,并保存为项目内透明 PNG。 +5. 桌宠资产优先通过 `imagegen` 生成可爱 3D 桌宠照片风格;若输出不可用或不合格,必须使用可复现 fallback 生成项目内透明 PNG,并保留资产校验。 6. UI 文案必须短句化,不在窗口内展示长篇功能说明。 #### 安全 diff --git a/openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md b/openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md index 24ac5ec..87beb32 100644 --- a/openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md +++ b/openspec/changes/add-voice-pet-pipeline/specs/voice-pet-pipeline/spec.md @@ -155,11 +155,15 @@ The system SHALL define desktop pet visual states for idle, listening, recording - **THEN** the desktop pet SHALL show a concise visible error state and then return to idle or wake listening when recovered ### Requirement: Generated pet asset specification -The system SHALL specify that production desktop pet images are generated with the image generation skill as cute 3D transparent PNG assets and saved inside the project. +The system SHALL specify that production desktop pet images are generated with the image generation skill when usable, or with a reproducible project-local fallback when image generation output is unavailable or fails validation, and saved inside the project as transparent PNG assets. -#### Scenario: Asset generation occurs in a later implementation phase -- **WHEN** pet images are generated -- **THEN** the images SHALL be created with `imagegen`, processed for transparency, validated, and saved to a project asset directory +#### Scenario: Image generation output is usable +- **WHEN** pet images are generated with `imagegen` and pass role and transparency validation +- **THEN** the images SHALL be processed for transparency, validated, and saved to a project asset directory + +#### Scenario: Image generation output is unavailable or invalid +- **WHEN** image generation output is unavailable, inaccessible as a project file, or fails role/transparency validation +- **THEN** the system SHALL use a reproducible project-local fallback to create transparent PNG pet state assets and SHALL validate them before use #### Scenario: Planning phase is executed - **WHEN** this OpenSpec change is implemented diff --git a/openspec/changes/add-voice-pet-pipeline/tasks.md b/openspec/changes/add-voice-pet-pipeline/tasks.md index e16cb51..a2130e2 100644 --- a/openspec/changes/add-voice-pet-pipeline/tasks.md +++ b/openspec/changes/add-voice-pet-pipeline/tasks.md @@ -35,11 +35,11 @@ ## 5. 桌宠 UI 与生图资产 -- [ ] 5.1 实现桌宠状态控制器;前置条件:PipelineState 已定义;验收标准:idle/listening/recording/transcribing/thinking/speaking/error 状态映射到可显示 UI 状态;测试要点:状态映射单元测试通过;优先级:P0;预计:45 分钟。 -- [ ] 5.2 实现可选桌面窗口入口和无 GUI fallback;前置条件:状态控制器已实现;验收标准:未安装 PySide6 时 CLI/测试不崩溃,安装后保留透明置顶窗口入口;测试要点:无 PySide6 环境导入和 fallback 测试通过;优先级:P1;预计:60 分钟。 -- [ ] 5.3 使用 `imagegen` 生成可爱 3D 桌宠透明资产并保存到项目;前置条件:资产目录已定义;验收标准:项目中存在 idle/listening/thinking/speaking/error 可引用 PNG 或生成失败时有明确记录;测试要点:资产存在性和 alpha/格式校验通过;优先级:P0;预计:60 分钟。 -- [ ] 5.4 实现资产清单和校验命令;前置条件:资产已保存;验收标准:运行验收命令可检查资产路径、状态覆盖和透明度元数据;测试要点:缺失资产时测试失败;优先级:P0;预计:45 分钟。 -- [ ] 5.5 完成“桌宠 UI 与生图资产”模块提交;前置条件:5.1 至 5.4 已完成;验收标准:先通过相关测试、资产校验、compileall 和 OpenSpec strict 校验,再立即执行 Git commit;测试要点:提交信息使用“`[桌宠 UI 与生图资产]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。 +- [x] 5.1 实现桌宠状态控制器;前置条件:PipelineState 已定义;验收标准:idle/listening/recording/transcribing/thinking/speaking/error 状态映射到可显示 UI 状态;测试要点:状态映射单元测试通过;优先级:P0;预计:45 分钟。 +- [x] 5.2 实现可选桌面窗口入口和无 GUI fallback;前置条件:状态控制器已实现;验收标准:未安装 PySide6 时 CLI/测试不崩溃,安装后保留透明置顶窗口入口;测试要点:无 PySide6 环境导入和 fallback 测试通过;优先级:P1;预计:60 分钟。 +- [x] 5.3 使用 `imagegen` 或可复现 fallback 生成可爱 3D 桌宠透明资产并保存到项目;前置条件:资产目录已定义;验收标准:项目中存在 idle/listening/thinking/speaking/error 可引用 PNG,且生成失败时有明确 fallback;测试要点:资产存在性和 alpha/格式校验通过;优先级:P0;预计:60 分钟。 +- [x] 5.4 实现资产清单和校验命令;前置条件:资产已保存;验收标准:运行验收命令可检查资产路径、状态覆盖和透明度元数据;测试要点:缺失资产时测试失败;优先级:P0;预计:45 分钟。 +- [x] 5.5 完成“桌宠 UI 与生图资产”模块提交;前置条件:5.1 至 5.4 已完成;验收标准:先通过相关测试、资产校验、compileall 和 OpenSpec strict 校验,再立即执行 Git commit;测试要点:提交信息使用“`[桌宠 UI 与生图资产]:完成[具体功能描述],包含[关键变更]`”格式;优先级:P0;预计:20 分钟。 ## 6. 测试、性能、安全、验收与归档 diff --git a/scripts/generate_pet_assets.py b/scripts/generate_pet_assets.py new file mode 100644 index 0000000..836b448 --- /dev/null +++ b/scripts/generate_pet_assets.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import json +import math +import struct +import zlib +from pathlib import Path + +WIDTH = 256 +HEIGHT = 256 +STATES = ("idle", "listening", "recording", "thinking", "speaking", "error") + + +def blend(dst: tuple[int, int, int, int], src: tuple[int, int, int, int]) -> 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()