Coverage for apps/character.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
1"""Character information for StreamWise."""
3import logging
4import random
6from typing import Dict
7from typing import Optional
8from typing import Any
10from PIL import Image
12# https://github.com/hexgrad/kokoro/blob/main/kokoro.js/src/voices.js
13# https://github.com/hexgrad/kokoro/blob/main/kokoro.js/README.md
14KOKORO_VOICES = {
15 "female": [
16 "af_heart",
17 "af_alloy",
18 "af_aoede",
19 "af_bella",
20 "af_jessica",
21 "af_kore",
22 "af_nicole",
23 "af_nova",
24 "af_river",
25 "af_sarah",
26 "af_sky"
27 ],
28 "male": [
29 "am_adam",
30 "am_echo",
31 "am_eric",
32 "am_fenrir",
33 "am_liam",
34 "am_michael",
35 "am_onyx",
36 "am_puck",
37 "am_santa"
38 ]
39}
41VIBEVOICE_VOICES = {
42 "Female": [
43 "af_bella",
44 "af_heart",
45 "af_kore",
46 "af_nicole",
47 "woman_000",
48 ],
49 "Male": [
50 "am_adam",
51 "am_fenrir",
52 "am_michael",
53 "am_puck",
54 "girish_001",
55 "girish_002",
56 "ricardo_001",
57 "ricardo_002",
58 ]
59}
62# Comes from javascript/bootstrap:
63# const colorClasses = ["text-primary", "text-success", "text-danger", "text-warning", "text-info", "text-secondary"];
64CHARACTER_COLOR_CLASSES = {
65 "text-primary": "#0d6efd", # blue
66 "text-success": "#198754", # green
67 "text-danger": "#dc3545", # red
68 "text-warning": "#ffc107", # yellow
69 "text-info": "#0dcaf0", # cyan
70 "text-secondary": "#6c757d", # gray
71}
74class Character:
75 """Character information."""
76 def __init__(
77 self,
78 name: str,
79 gender: str = "Unknown",
80 description: Optional[str] = None,
81 speech_speed: float = 1.1,
82 ) -> None:
83 self.name = name
84 self.gender = gender
85 self.voice = self.get_random_voice()
86 self.speech_speed = speech_speed
87 self.image: Optional[Image.Image] = None
88 self.description = description
90 def get_random_voice(self) -> str:
91 """Get a random voice based on the gender."""
92 gender = self.gender.lower()
93 gender_voices = KOKORO_VOICES.get(gender, [])
94 if not gender_voices:
95 logging.warning(f"Unknown gender: {self.gender}.")
96 return "af_heart"
97 return random.choice(gender_voices)
100class Characters:
101 """Manage multiple characters."""
102 def __init__(self) -> None:
103 self.characters: Dict[str, Character] = {}
105 def __len__(self) -> int:
106 return len(self.characters)
108 def __contains__(self, name: str) -> bool:
109 return name in self.characters
111 def __getitem__(self, name: str) -> Character:
112 if name not in self.characters:
113 raise KeyError(f"Character '{name}' not found.")
114 return self.characters[name]
116 def __setitem__(self, name: str, character: Character) -> None:
117 if not isinstance(character, Character):
118 raise ValueError("character must be an instance of Character")
119 self.characters[name] = character
121 def __iter__(self) -> Any:
122 return iter(self.characters.values())
124 def __repr__(self) -> str:
125 return f"Characters({list(self.characters.keys())})"
127 def get_index(self, name: str) -> int:
128 """Get index of character by name."""
129 if name not in self.characters:
130 return 0
131 return list(self.characters).index(name)
133 def get_by_index(self, index: int) -> Character:
134 """Get character by index."""
135 if index < 0 or index >= len(self.characters):
136 raise IndexError("Character index out of range")
137 character = list(self.characters.values())[index]
138 return character
140 def get_position(
141 self,
142 name: str,
143 ) -> str:
144 """Get character position in the image: left, center, right."""
145 index = self.get_index(name)
146 num_characters = len(self.characters)
148 if num_characters == 1:
149 return "center"
150 if num_characters == 2:
151 if index == 0:
152 return "left"
153 elif index == 1:
154 return "right"
155 if num_characters == 3:
156 if index == 0:
157 return "left"
158 elif index == 1:
159 return "center"
160 elif index == 2:
161 return "right"
162 return "left"
164 def get_color(self, name: str) -> str:
165 index = self.get_index(name) % len(CHARACTER_COLOR_CLASSES)
166 color = CHARACTER_COLOR_CLASSES[list(CHARACTER_COLOR_CLASSES.keys())[index]]
167 return color